infinicode 2.8.103 → 2.8.106

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.
@@ -21,4 +21,4 @@
21
21
  * Kept free of backticks and ${...} so the whole page fits inside this TS
22
22
  * template literal without escaping; the client JS uses string concatenation.
23
23
  */
24
- 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<meta name=\"robopark-ui-build\" content=\"provisioning-token-20260717-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:#050504;\n --grad: radial-gradient(900px 520px at 7% -8%, rgba(255,198,79,0.18), transparent 64%),\n radial-gradient(760px 520px at 98% 4%, rgba(161,105,16,0.16), transparent 62%),\n radial-gradient(650px 520px at 62% 112%, rgba(255,211,123,0.08), transparent 68%),\n linear-gradient(145deg,#030303 0%,#0b0905 46%,#050504 100%);\n --panel: rgba(16,14,10,0.64); --panel-2: rgba(27,23,15,0.68); --panel-solid:#0d0b08;\n --glass-border: rgba(255,224,151,0.14); --glass-hi: rgba(255,241,204,0.09);\n --border: rgba(255,224,151,0.12); --border-strong: rgba(255,205,93,0.52);\n --ink:#F6F0E3; --muted:#A99D87;\n --gold:#C9962E; --gold-bright:#F6D77B; --gold-pale:#FFF0BE; --gold-deep:#78520F;\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 18px 55px rgba(0,0,0,0.56),0 2px 12px rgba(0,0,0,0.36);\n --mono:\"Cascadia Code\",\"SFMono-Regular\",Consolas,monospace;\n --sans:\"Trebuchet MS\",\"Segoe UI\",sans-serif;\n --display:\"Palatino Linotype\",\"Book Antiqua\",Palatino,serif;\n}\n@media (prefers-color-scheme:dark){:root{}}\n:root[data-theme=\"dark\"]{}\n:root[data-theme=\"light\"]{\n --bg:#090806;--panel:rgba(24,20,13,.72);--panel-2:rgba(38,31,18,.76);--panel-solid:#100e09;\n --ink:#FFF8E8;--muted:#B5A88F;--glass-border:rgba(255,224,151,.17);--glass-hi:rgba(255,244,214,.1);\n --border:rgba(255,224,151,.14);--border-strong:rgba(255,205,93,.58);--gold:#D3A43D;--gold-bright:#FFE094;\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;}\nbody:before{content:\"\";position:fixed;inset:0;pointer-events:none;z-index:-1;opacity:.22;background-image:linear-gradient(rgba(255,221,140,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,221,140,.025) 1px,transparent 1px);background-size:42px 42px;mask-image:linear-gradient(to bottom,black,transparent 82%);}\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),inset 0 -1px 0 rgba(0,0,0,.5);}\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-family:var(--display);font-size:1.08rem;letter-spacing:.015em;color:var(--gold-pale);text-shadow:0 0 24px rgba(246,215,123,.2);}\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/* fleet operations overview */\n.fleet-ops{display:flex;flex-direction:column;gap:.8rem;margin-bottom:1rem;}\n.fleet-hero{position:relative;overflow:hidden;display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;padding:1.05rem 1.15rem;border:1px solid var(--glass-border);border-radius:18px;background:radial-gradient(520px 170px at 5% 0%,rgba(246,215,123,.15),transparent 70%),linear-gradient(145deg,rgba(29,24,14,.8),rgba(7,6,4,.72));box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.fleet-hero:after{content:\"\";position:absolute;width:180px;height:180px;right:9%;top:-120px;border:1px solid rgba(246,215,123,.13);border-radius:50%;box-shadow:0 0 0 30px rgba(246,215,123,.025),0 0 0 62px rgba(246,215,123,.015);}\n.fleet-hero .eyebrow{font-family:var(--mono);font-size:.56rem;letter-spacing:.17em;text-transform:uppercase;color:var(--gold);}\n.fleet-hero h1{font-family:var(--display);font-size:clamp(1.45rem,2.5vw,2.25rem);line-height:1.05;color:var(--gold-pale);font-weight:500;margin:.18rem 0;}\n.fleet-hero p{font-size:.72rem;color:var(--muted);max-width:62ch;}\n.fleet-hero-actions{display:flex;gap:.45rem;flex-wrap:wrap;justify-content:flex-end;position:relative;z-index:1;}\n.fleet-kpis{display:grid;grid-template-columns:repeat(6,minmax(105px,1fr));gap:.55rem;}\n.fleet-kpi{padding:.75rem .8rem;border:1px solid var(--glass-border);border-radius:14px;background:linear-gradient(150deg,rgba(34,28,16,.58),rgba(8,7,5,.64));box-shadow:inset 0 1px 0 var(--glass-hi);min-width:0;}\n.fleet-kpi .label{font-family:var(--mono);font-size:.5rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.fleet-kpi .value{font-family:var(--display);font-size:1.55rem;line-height:1.15;color:var(--gold-pale);font-variant-numeric:tabular-nums;}\n.fleet-kpi .detail{font-family:var(--mono);font-size:.52rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.fleet-kpi.ok .value{color:var(--ok)}.fleet-kpi.warn .value{color:var(--warn)}.fleet-kpi.bad .value{color:var(--bad)}\n.fleet-insights{display:grid;grid-template-columns:1.05fr 1.35fr 1.25fr;gap:.65rem;}\n.fleet-panel{min-width:0;border:1px solid var(--glass-border);border-radius:16px;background:rgba(12,10,7,.68);box-shadow:inset 0 1px 0 var(--glass-hi);padding:.8rem;}\n.fleet-panel-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding-bottom:.55rem;margin-bottom:.55rem;border-bottom:1px solid var(--glass-border);}\n.fleet-panel-head b{font-family:var(--display);font-size:.92rem;color:var(--gold-pale);font-weight:500}.fleet-panel-head span{font-family:var(--mono);font-size:.5rem;color:var(--muted)}\n.fleet-readiness{display:grid;grid-template-columns:104px 1fr;gap:.75rem;align-items:center;}\n.fleet-donut{width:100px;height:100px;border-radius:50%;position:relative;display:grid;place-items:center;background:conic-gradient(var(--ok) 0 var(--ready-angle,0deg),var(--warn) var(--ready-angle,0deg) var(--warn-angle,0deg),var(--bad) var(--warn-angle,0deg) 360deg);box-shadow:0 0 30px rgba(246,215,123,.08);}\n.fleet-donut:after{content:\"\";position:absolute;inset:15px;border-radius:50%;background:#0c0a07;border:1px solid var(--glass-border)}.fleet-donut strong{position:relative;z-index:1;font-family:var(--display);font-size:1.15rem;color:var(--gold-pale)}\n.fleet-legend{display:grid;gap:.32rem}.fleet-legend div{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-family:var(--mono);font-size:.56rem;color:var(--muted)}.fleet-legend i{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:.35rem}.fleet-legend b{color:var(--ink)}\n.fleet-bars{display:grid;gap:.4rem;max-height:154px;overflow:auto}.fleet-bar-row{display:grid;grid-template-columns:minmax(58px,90px) 1fr 48px;gap:.45rem;align-items:center;font-family:var(--mono);font-size:.54rem;color:var(--muted)}.fleet-bar-row .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}.fleet-bar-row .track{height:7px;border-radius:10px;background:rgba(255,255,255,.045);overflow:hidden}.fleet-bar-row .fill{height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--gold-deep),var(--gold-bright));box-shadow:0 0 9px rgba(246,215,123,.2)}.fleet-bar-row .val{text-align:right}\n.fleet-attention{display:grid;gap:.36rem;max-height:154px;overflow:auto}.fleet-alert-row{display:grid;grid-template-columns:7px 1fr auto;gap:.48rem;align-items:start;padding:.36rem .42rem;border-radius:8px;background:rgba(255,255,255,.018);font-size:.61rem}.fleet-alert-row i{width:7px;height:7px;border-radius:50%;margin-top:.28rem;background:var(--warn)}.fleet-alert-row.bad i{background:var(--bad)}.fleet-alert-row.ok i{background:var(--ok)}.fleet-alert-row b{display:block;color:var(--ink);font-size:.62rem}.fleet-alert-row span{color:var(--muted);font-size:.56rem}.fleet-alert-row em{font-family:var(--mono);font-style:normal;font-size:.5rem;color:var(--muted);white-space:nowrap}\n.fleet-release{grid-column:1/-1;display:flex;align-items:center;gap:.55rem;flex-wrap:wrap;padding:.55rem .7rem}.fleet-release .version-pill{font-family:var(--mono);font-size:.56rem;border:1px solid var(--glass-border);border-radius:100px;padding:.18rem .5rem;color:var(--muted)}.fleet-release .version-pill.current{color:var(--ok);border-color:rgba(111,224,166,.28)}\n.node .fleet-card-actions{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.65rem;padding-top:.55rem;border-top:1px solid var(--glass-border)}\n.fleet-live-camera{position:relative;aspect-ratio:16/9;margin:.55rem 0;border:1px solid var(--glass-border);border-radius:12px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(246,215,123,.08),transparent 44%),#050504;box-shadow:inset 0 1px 0 var(--glass-hi)}\n.fleet-live-camera img{width:100%;height:100%;display:block;object-fit:cover;background:#050504}.fleet-live-camera .state{position:absolute;left:.5rem;bottom:.45rem;padding:.18rem .4rem;border-radius:100px;background:rgba(3,3,2,.78);border:1px solid var(--glass-border);font-family:var(--mono);font-size:.5rem;color:var(--warn);backdrop-filter:blur(8px)}.fleet-live-camera.live .state{color:var(--ok)}.fleet-live-camera.failed .state{color:var(--bad)}\n@media(max-width:1150px){.fleet-kpis{grid-template-columns:repeat(3,1fr)}.fleet-insights{grid-template-columns:1fr 1fr}.fleet-insights .fleet-panel:last-of-type{grid-column:1/-1}}\n@media(max-width:720px){.fleet-hero{align-items:flex-start;flex-direction:column}.fleet-hero-actions{justify-content:flex-start}.fleet-kpis{grid-template-columns:repeat(2,1fr)}.fleet-insights{display:block}.fleet-panel{margin-bottom:.65rem}.fleet-readiness{grid-template-columns:90px 1fr}.fleet-donut{width:86px;height:86px}}\n\n/* fullscreen RoboVision operations view */\n.pk-camera-ops{position:fixed;inset:0;z-index:80;display:none;background:#030302;color:var(--ink);overflow:hidden;}\n.pk-camera-ops.open{display:grid;grid-template-rows:64px 1fr;}\n.pk-camera-ops-head{display:flex;align-items:center;gap:.75rem;padding:.65rem 1rem;border-bottom:1px solid var(--glass-border);background:rgba(12,10,7,.9);backdrop-filter:blur(22px);}\n.pk-camera-ops-head .avatar{width:40px;height:40px;object-fit:contain;filter:drop-shadow(0 7px 14px rgba(0,0,0,.5))}.pk-camera-ops-head .title{font-family:var(--display);font-size:1.08rem;color:var(--gold-pale)}.pk-camera-ops-head .sub{font-family:var(--mono);font-size:.54rem;color:var(--muted)}\n.pk-camera-ops-actions{margin-left:auto;display:flex;gap:.4rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}\n.pk-camera-ops-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;min-height:0;}\n.pk-camera-stage{position:relative;min-width:0;min-height:0;background:radial-gradient(circle at 50% 50%,#17130b,#030302 72%);overflow:hidden;display:grid;place-items:center;}\n.pk-camera-stage img{width:100%;height:100%;object-fit:contain;display:block}.pk-camera-stage .empty{position:absolute;border:0;font-family:var(--mono);font-size:.68rem;letter-spacing:.08em;text-transform:uppercase}\n.pk-camera-stage:after{content:\"\";position:absolute;inset:18px;border:1px solid rgba(246,215,123,.13);pointer-events:none;clip-path:polygon(0 0,13% 0,13% 1px,1px 1px,1px 15%,0 15%,0 0,100% 0,100% 15%,calc(100% - 1px) 15%,calc(100% - 1px) 1px,87% 1px,87% 0,100% 0,100% 100%,87% 100%,87% calc(100% - 1px),calc(100% - 1px) calc(100% - 1px),calc(100% - 1px) 85%,100% 85%,100% 100%,0 100%,0 85%,1px 85%,1px calc(100% - 1px),13% calc(100% - 1px),13% 100%,0 100%)}\n.pk-camera-rec{position:absolute;left:34px;top:31px;display:flex;align-items:center;gap:.45rem;padding:.28rem .48rem;border-radius:7px;background:rgba(0,0,0,.58);backdrop-filter:blur(8px);font-family:var(--mono);font-size:.57rem;color:#fff}.pk-camera-rec i{width:7px;height:7px;border-radius:50%;background:var(--bad);box-shadow:0 0 10px var(--bad);animation:pulse 1.4s infinite}\n.pk-camera-clock{position:absolute;right:34px;top:31px;padding:.28rem .48rem;border-radius:7px;background:rgba(0,0,0,.58);font-family:var(--mono);font-size:.57rem;color:var(--gold-pale)}\n.pk-camera-overlay{position:absolute;left:34px;bottom:31px;width:min(660px,calc(100% - 68px));display:grid;grid-template-columns:repeat(4,1fr);gap:.45rem}.pk-camera-overlay .metric{padding:.55rem .6rem;border:1px solid rgba(246,215,123,.18);border-radius:10px;background:rgba(5,4,3,.68);backdrop-filter:blur(12px)}.pk-camera-overlay b{display:block;font-family:var(--display);font-size:.98rem;color:var(--gold-pale)}.pk-camera-overlay span{font-family:var(--mono);font-size:.46rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}\n.pk-camera-side{min-height:0;overflow:auto;padding:.8rem;background:linear-gradient(180deg,rgba(20,16,9,.94),rgba(7,6,4,.98));border-left:1px solid var(--glass-border)}\n.pk-camera-side .section{padding:.7rem;margin-bottom:.65rem;border:1px solid var(--glass-border);border-radius:13px;background:rgba(255,255,255,.018)}.pk-camera-side h3{font-family:var(--mono);font-size:.55rem;letter-spacing:.13em;text-transform:uppercase;color:var(--gold);margin-bottom:.5rem}.pk-camera-insight{display:grid;grid-template-columns:1fr auto;gap:.38rem .55rem;font-family:var(--mono);font-size:.56rem}.pk-camera-insight span{color:var(--muted)}.pk-camera-insight b{color:var(--ink);text-align:right;font-weight:600}.pk-camera-insight b.ok{color:var(--ok)}.pk-camera-insight b.bad{color:var(--bad)}\n.pk-camera-pipeline{display:grid;gap:.34rem}.pk-camera-pipeline div{display:grid;grid-template-columns:8px 1fr auto;gap:.42rem;align-items:center;font-family:var(--mono);font-size:.53rem;color:var(--muted)}.pk-camera-pipeline i{width:7px;height:7px;border-radius:50%;background:var(--muted)}.pk-camera-pipeline i.ok{background:var(--ok);box-shadow:0 0 8px rgba(111,224,166,.45)}.pk-camera-pipeline i.warn{background:var(--warn)}\nbody.camera-ops-open{overflow:hidden}\n@media(max-width:850px){.pk-camera-ops.open{grid-template-rows:auto 1fr}.pk-camera-ops-head{align-items:flex-start;flex-wrap:wrap}.pk-camera-ops-actions{width:100%;margin-left:0;justify-content:flex-start}.pk-camera-ops-grid{display:block;overflow:auto}.pk-camera-stage{height:58vh;min-height:340px}.pk-camera-side{border-left:0;border-top:1px solid var(--glass-border)}.pk-camera-overlay{grid-template-columns:repeat(2,1fr)}}\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.pk-pipeline{display:grid;gap:0.42rem;margin-top:0.7rem;position:relative;}\n.pk-live-stage-focus{--stage-color:var(--gold-bright);position:relative;overflow:hidden;margin-top:.58rem;padding:.78rem .85rem .78rem 1rem;border:1px solid var(--stage-color);border-radius:13px;background:linear-gradient(105deg,color-mix(in srgb,var(--stage-color) 24%,transparent),rgba(8,8,8,.68));box-shadow:0 10px 30px rgba(0,0,0,.28),inset 4px 0 0 var(--stage-color);}\n.pk-live-stage-focus:before{content:'';position:absolute;width:150px;height:150px;right:-65px;top:-95px;border-radius:50%;background:var(--stage-color);opacity:.12;filter:blur(5px);animation:pulse 1.3s infinite;}.pk-live-stage-focus .eyebrow{display:block;font-family:var(--mono);font-size:.49rem;letter-spacing:.13em;text-transform:uppercase;color:var(--stage-color);}.pk-live-stage-focus b{display:block;margin-top:.2rem;font-size:1rem;letter-spacing:.015em;color:var(--ink);}.pk-live-stage-focus span:last-child{display:block;margin-top:.18rem;font-family:var(--mono);font-size:.57rem;color:var(--muted);}.pk-live-stage-focus.blocked,.pk-live-stage-focus.failed{--stage-color:var(--bad)}.pk-live-stage-focus.waiting,.pk-live-stage-focus.paused{--stage-color:var(--muted)}.pk-live-stage-focus.active,.pk-live-stage-focus.running{--stage-color:var(--warn)}\n.pk-pipe-step{--phase:var(--gold);display:grid;grid-template-columns:2rem minmax(7.2rem,.72fr) minmax(9rem,1.35fr);gap:0.55rem;align-items:center;padding:0.55rem 0.62rem;border-radius:11px;border:1px solid rgba(255,255,255,.06);background:linear-gradient(110deg,color-mix(in srgb,var(--phase) 7%,transparent),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.58rem;color:var(--muted);transition:transform .18s,border-color .18s,background .18s;position:relative;overflow:hidden;}\n.pk-pipe-step.phase-hardware{--phase:#63d5c6}.pk-pipe-step.phase-trigger{--phase:#efb94c}.pk-pipe-step.phase-session{--phase:#66a8ff}.pk-pipe-step.phase-media{--phase:#68d48f}.pk-pipe-step.phase-intelligence{--phase:#e38bff}.pk-pipe-step.phase-playback{--phase:#ff9b62}.pk-pipe-step.phase-close{--phase:#a8b0bd}\n.pk-pipe-step .step-index{display:grid;place-items:center;width:1.65rem;height:1.65rem;border-radius:8px;border:1px solid color-mix(in srgb,var(--phase) 35%,transparent);color:var(--phase);background:color-mix(in srgb,var(--phase) 10%,transparent);font-weight:700;}\n.pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.055em;font-weight:650;display:block;}.pk-pipe-step .phase{display:block;color:var(--phase);font-size:.49rem;letter-spacing:.08em;margin-top:.14rem;}\n.pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;line-height:1.35;}.pk-pipe-step .time{display:block;color:color-mix(in srgb,var(--muted) 75%,transparent);font-size:.5rem;margin-top:.12rem;}.pk-pipe-step .step-state{display:inline-block;margin-left:.35rem;padding:.1rem .3rem;border:1px solid currentColor;border-radius:999px;font-size:.43rem;letter-spacing:.07em;vertical-align:1px;color:var(--muted)}\n.pk-pipe-step.ok{border-color:color-mix(in srgb,var(--ok) 22%,transparent)}\n.pk-pipe-step.ok .step-state{color:var(--ok)}.pk-pipe-step.pending{opacity:.52}.pk-pipe-step.pending .step-index{filter:saturate(.3)}\n.pk-pipe-step.failed,.pk-pipe-step.blocked{border-color:rgba(255,90,90,.48);background:linear-gradient(110deg,rgba(255,70,70,.14),rgba(255,255,255,.02));}\n.pk-pipe-step.current{transform:translateX(5px) scale(1.012);border-color:var(--phase);background:linear-gradient(105deg,color-mix(in srgb,var(--phase) 24%,transparent),rgba(255,255,255,.045));box-shadow:0 0 0 1px color-mix(in srgb,var(--phase) 28%,transparent),0 10px 32px rgba(0,0,0,.3);opacity:1;z-index:2;}\n.pk-pipe-step.current:after{content:'ACTIVE';position:absolute;right:-1.7rem;top:.45rem;transform:rotate(38deg);padding:.12rem 2rem;background:var(--phase);color:#080808;font-size:.42rem;font-weight:800;letter-spacing:.08em;}\n.pk-pipe-step.current .step-index{box-shadow:0 0 14px color-mix(in srgb,var(--phase) 55%,transparent);animation:pulse 1.15s infinite;}\n.pk-pipeline-summary{padding:0.7rem 0.75rem;border:1px solid var(--glass-border);border-radius:11px;background:linear-gradient(120deg,rgba(212,170,72,.08),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.62rem;color:var(--muted);line-height:1.45;}\n.pk-pipeline-summary.ready{color:var(--ok);border-color:rgba(70,220,150,.35);}\n.pk-pipeline-summary.running{color:var(--warn);border-color:rgba(255,196,70,.35);}\n.pk-pipeline-summary.blocked{color:var(--bad);border-color:rgba(255,90,90,.4);}\n.fleet-stage-focus{margin-top:.48rem;padding:.48rem .58rem;border:1px solid color-mix(in srgb,var(--warn) 48%,transparent);border-radius:9px;background:linear-gradient(100deg,rgba(255,184,62,.14),rgba(255,255,255,.02));font-family:var(--mono)}.fleet-stage-focus span{display:block;color:var(--warn);font-size:.45rem;letter-spacing:.1em;text-transform:uppercase}.fleet-stage-focus b{display:block;margin-top:.12rem;color:var(--ink);font-size:.66rem}.fleet-stage-focus.blocked,.fleet-stage-focus.failed{border-color:color-mix(in srgb,var(--bad) 55%,transparent)}.fleet-stage-focus.blocked span,.fleet-stage-focus.failed span{color:var(--bad)}.fleet-stage-focus.waiting,.fleet-stage-focus.paused{border-color:var(--glass-border)}.fleet-stage-focus.waiting span,.fleet-stage-focus.paused span{color:var(--muted)}\n.pk-pipe-history{margin-top:.75rem;border-top:1px solid var(--glass-border);padding-top:.7rem}.pk-pipe-history-head{display:flex;align-items:center;justify-content:space-between;gap:.6rem;margin-bottom:.5rem}.pk-pipe-history-head b{font-family:var(--mono);font-size:.65rem;color:var(--ink);text-transform:uppercase;letter-spacing:.06em}.pk-pipe-history-list{display:grid;gap:.45rem}.pk-run-card{border:1px solid var(--glass-border);border-radius:10px;background:var(--panel-2);padding:.55rem .62rem;cursor:pointer}.pk-run-card:hover{border-color:var(--gold)}.pk-run-top{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:start}.pk-run-title{font-family:var(--mono);font-size:.61rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pk-run-meta{font-family:var(--mono);font-size:.53rem;color:var(--muted);margin-top:.17rem}.pk-run-state{font-family:var(--mono);font-size:.5rem;text-transform:uppercase;border:1px solid var(--glass-border);border-radius:999px;padding:.18rem .4rem}.pk-run-state.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}.pk-run-state.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-run-state.running{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}.pk-run-stages{display:flex;gap:3px;margin-top:.45rem}.pk-run-stages i{height:4px;flex:1;border-radius:3px;background:rgba(255,255,255,.07)}.pk-run-stages i.ok{background:var(--ok)}.pk-run-stages i.failed,.pk-run-stages i.blocked{background:var(--bad)}.pk-history-pager{display:flex;align-items:center;gap:.35rem}.pk-history-pager span{font-family:var(--mono);font-size:.54rem;color:var(--muted)}\n.pk-mic-suite{margin-top:.6rem;display:grid;gap:.55rem}.pk-mic-health{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem}.pk-mic-check{padding:.48rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-mic-check .k{font-family:var(--mono);font-size:.49rem;color:var(--muted);text-transform:uppercase}.pk-mic-check .v{font-family:var(--mono);font-size:.62rem;color:var(--ink);margin-top:.16rem;overflow-wrap:anywhere}.pk-mic-check.ok{border-color:color-mix(in srgb,var(--ok) 30%,transparent)}.pk-mic-check.bad{border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-mic-result{border:1px solid var(--glass-border);border-radius:10px;padding:.58rem;background:var(--panel-2);font-family:var(--mono);font-size:.58rem}.pk-mic-result.ok{border-color:var(--ok);background:rgba(60,180,110,.08)}.pk-mic-result.bad{border-color:var(--bad);background:rgba(220,80,80,.08)}.pk-mic-readings{display:grid;grid-template-columns:repeat(4,1fr);gap:.35rem;margin-top:.45rem}.pk-mic-reading{padding:.35rem;background:rgba(0,0,0,.16);border-radius:7px}.pk-mic-reading span{display:block;color:var(--muted);font-size:.48rem;text-transform:uppercase}.pk-mic-reading b{display:block;color:var(--ink);font-size:.67rem;margin-top:.1rem}\n.pk-motor-registry{display:grid;gap:.4rem}.pk-motor-row{display:grid;grid-template-columns:1fr 4.5rem 5.5rem auto;gap:.35rem;align-items:end;padding:.45rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-motor-canvas{display:flex;align-items:stretch;gap:.55rem;overflow-x:auto;padding:.65rem .2rem;min-height:130px}.pk-motor-node{min-width:165px;padding:.55rem;border:1px solid rgba(255,205,93,.35);border-radius:12px;background:linear-gradient(145deg,rgba(201,150,46,.13),rgba(255,255,255,.025));position:relative}.pk-motor-node:not(:last-child):after{content:'';position:absolute;right:-.56rem;top:50%;width:.56rem;border-top:2px solid var(--gold)}.pk-motor-node .node-num{font-family:var(--mono);font-size:.48rem;color:var(--gold);text-transform:uppercase;margin-bottom:.35rem}.pk-motor-node .rp-field{margin-bottom:.3rem}.pk-motor-empty{display:grid;place-items:center;width:100%;min-height:100px;border:1px dashed var(--glass-border);border-radius:10px;color:var(--muted);font-family:var(--mono);font-size:.58rem}.pk-motor-status{font-family:var(--mono);font-size:.58rem;padding:.5rem;border-radius:8px;background:var(--panel-2);border:1px solid var(--glass-border)}.pk-motor-status.ok{color:var(--ok);border-color:var(--ok)}.pk-motor-status.bad{color:var(--bad);border-color:var(--bad)}\n@media(max-width:720px){.pk-pipe-step{grid-template-columns:1.8rem 1fr}.pk-pipe-step .detail{grid-column:2;text-align:left}.pk-mic-health{grid-template-columns:1fr}.pk-mic-readings{grid-template-columns:1fr 1fr}.pk-motor-row{grid-template-columns:1fr 4.5rem}}\n.pk-prodtoggle{display:flex;align-items:center;gap:0.5rem;margin-top:0.6rem;padding:0.45rem 0.6rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;}\n.pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}\n.pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}\n.pk-prodtoggle:has(input:checked){border-color:var(--ok);background:rgba(60,180,110,0.12);}\n.pk-svc-list{display:flex;flex-direction:column;gap:0.35rem;}\n.pk-svc-row{display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.55rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);font-family:var(--mono);font-size:0.64rem;}\n.pk-svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--muted);}\n.pk-svc-row.up .pk-svc-dot{background:var(--ok);box-shadow:0 0 6px var(--ok);}\n.pk-svc-row.down .pk-svc-dot{background:var(--bad);}\n.pk-svc-row.disabled .pk-svc-dot{background:var(--muted);}\n.pk-svc-name{font-weight:600;color:var(--ink);min-width:6.5em;}\n.pk-svc-meta{color:var(--muted);flex:1;}\n.pk-svc-restart-btn{font-size:0.6rem;padding:0.2rem 0.5rem;flex-shrink:0;}\n\n/* \u2500\u2500 C3: operator identity (top-bar badge + audit attribution) \u2500\u2500 */\n.rp-operator-badge{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.6rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;margin-left:0.4rem;user-select:none;}\n.rp-operator-badge:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-operator-badge.set{color:var(--muted);font-style:italic;}\n.rp-operator-badge.set:hover{color:var(--gold-bright);}\n.rp-actor-chip{font-family:var(--mono);font-size:0.62rem;padding:0.28em 0.7em;border-radius:100px;cursor:pointer;border:1px solid var(--border-strong);color:var(--muted);background:var(--panel-2);user-select:none;}\n.rp-actor-chip:hover{color:var(--ink);border-color:var(--border-strong);}\n.rp-actor-chip.active{color:var(--gold-bright);border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 10%,transparent);}\n.rp-actor-chip.disabled{opacity:0.45;cursor:not-allowed;}\n.rp-actor-mine{color:var(--gold-bright);}\n.rp-actor-mine b{color:var(--gold-bright);}\n.rp-actor-other{color:var(--muted);}\n.rp-actor-other b{color:var(--ink);font-weight:500;}\n.rp-audit-row-mine{background:color-mix(in srgb,var(--gold-bright) 6%,transparent);}\n\n/* \u2500\u2500 C4: Incidents sub-tab \u2500\u2500 */\n.rp-incidents-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:0.6rem;margin:0.5rem 0 0.8rem;}\n.rp-inc-kpi{background:var(--panel-2);border:1px solid var(--border-strong);border-radius:8px;padding:0.55rem 0.7rem;display:flex;flex-direction:column;gap:0.15rem;}\n.rp-inc-kpi .lab{font-family:var(--mono);font-size:0.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;display:flex;align-items:center;gap:0.35rem;}\n.rp-inc-kpi .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-weight:600;}\n.rp-inc-kpi .meta{font-family:var(--mono);font-size:0.58rem;color:var(--muted);}\n.pk-kpi-dot{width:8px;height:8px;border-radius:50%;background:var(--muted);display:inline-block;}\n.pk-kpi-dot.ok{background:var(--ok);box-shadow:0 0 6px var(--ok);}\n.pk-kpi-dot.warn{background:var(--warn,#e0a23a);box-shadow:0 0 6px var(--warn,#e0a23a);}\n.pk-kpi-dot.bad{background:var(--bad);box-shadow:0 0 6px var(--bad);}\n.rp-inc-table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:0.66rem;}\n.rp-inc-table th{text-align:left;padding:0.45rem 0.6rem;color:var(--muted);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid var(--glass-border);background:var(--panel-2);position:sticky;top:0;z-index:1;}\n.rp-inc-table td{padding:0.45rem 0.6rem;border-bottom:1px solid rgba(255,255,255,0.04);vertical-align:middle;}\n.rp-inc-table tr.rp-inc-sev-bad td{background:rgba(220,80,80,0.06);}\n.rp-inc-table tr.rp-inc-sev-warn td{background:rgba(224,162,58,0.05);}\n.rp-inc-sev{display:inline-block;padding:0.05em 0.55em;border-radius:5px;font-size:0.6rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;}\n.rp-inc-sev-bad .rp-inc-sev{color:var(--bad);border:1px solid color-mix(in srgb,var(--bad) 40%,transparent);background:rgba(220,80,80,0.10);}\n.rp-inc-sev-warn .rp-inc-sev{color:var(--warn,#e0a23a);border:1px solid color-mix(in srgb,var(--warn,#e0a23a) 40%,transparent);background:rgba(224,162,58,0.10);}\n.rp-inc-sev-info .rp-inc-sev{color:var(--muted);border:1px solid var(--border-strong);background:var(--panel-2);}\n.rp-inc-id{color:var(--muted);font-size:0.6rem;margin-left:0.4rem;}\n.rp-inc-acked-tag{display:inline-block;padding:0.05em 0.5em;border-radius:5px;font-size:0.58rem;color:var(--ok);border:1px solid color-mix(in srgb,var(--ok) 40%,transparent);background:rgba(60,180,110,0.12);margin-left:0.5rem;}\n.rp-inc-acked-row td{opacity:0.7;font-style:italic;}\n.rp-btn.ghost{background:transparent;}\n@media (max-width:900px){ .rp-incidents-kpis{grid-template-columns:repeat(2,1fr);} }\n\n/* \u2500\u2500 C2: speaker roundtrip test panel \u2500\u2500 */\n#pk-spk-test{font-family:var(--mono);}\n#pk-spk-test-stateline{font-family:var(--mono);font-size:0.62rem;color:var(--muted);}\n.pk-spk-result{margin-top:0.45rem;padding:0.55rem 0.65rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);font-family:var(--mono);font-size:0.66rem;}\n.pk-spk-result.ok{border-color:var(--ok);background:rgba(60,180,110,0.10);}\n.pk-spk-result.bad{border-color:var(--bad);background:rgba(220,80,80,0.10);}\n.pk-spk-banner{font-weight:600;font-size:0.78rem;margin-bottom:0.35rem;}\n.pk-spk-banner.ok{color:var(--ok);}\n.pk-spk-banner.bad{color:var(--bad);}\n.pk-spk-meta{display:grid;grid-template-columns:repeat(2,1fr);gap:0.2rem 0.6rem;margin:0.3rem 0;}\n.pk-spk-meta>div{display:flex;justify-content:space-between;gap:0.4rem;}\n.pk-spk-meta .l{color:var(--muted);text-transform:uppercase;font-size:0.58rem;letter-spacing:0.04em;}\n.pk-spk-meta .v{color:var(--ink);font-size:0.66rem;}\n.pk-spk-err{color:var(--bad);font-style:italic;margin-top:0.2rem;}\n.pk-spk-diag{color:var(--muted);font-style:italic;margin-top:0.3rem;font-size:0.6rem;line-height:1.4;}\n.pk-spk-hist-head{font-family:var(--mono);font-size:0.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-top:0.5rem;padding-top:0.4rem;border-top:1px solid var(--glass-border);}\n.pk-spk-hist-row{display:grid;grid-template-columns:1em 4em 4em 8em 1fr;gap:0.45rem;padding:2px 0;font-family:var(--mono);font-size:0.62rem;align-items:baseline;}\n.pk-spk-mark{font-weight:700;}\n.pk-spk-when{color:var(--muted);}\n.pk-spk-freq{color:var(--ink);}\n.pk-spk-peak{color:var(--ink);}\n\n/* \u2500\u2500 C1: per-robot shell section in the Park drawer \u2500\u2500 */\n.pk-shell-row{display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;}\n.pk-shell-lbl{font-family:var(--mono);font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;}\n.pk-shell-sel,.pk-shell-num{font-family:var(--mono);font-size:0.7rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:6px;padding:0.32rem 0.5rem;min-width:0;}\n.pk-shell-sel{flex:1;max-width:220px;}\n.pk-shell-num{width:5.5em;}\n.pk-shell-out-wrap{margin-top:0.4rem;border:1px solid var(--glass-border);border-radius:8px;background:var(--panel-solid);overflow:hidden;}\n.pk-shell-out{font-family:var(--mono);font-size:0.66rem;line-height:1.45;color:var(--ink);max-height:240px;overflow-y:auto;padding:0.45rem 0.6rem;background:#0c0e14;}\n.pk-shell-out:focus{outline:none;}\n.pk-shell-line{display:flex;gap:0.55rem;align-items:flex-start;padding:1px 0;}\n.pk-shell-ts{color:var(--muted);flex-shrink:0;font-size:0.58rem;padding-top:0.15em;min-width:3.6em;}\n.pk-shell-text{white-space:pre-wrap;word-break:break-word;flex:1;}\n.pk-shell-status{color:var(--gold-bright,var(--gold));font-style:italic;}\n.pk-shell-header{color:var(--muted);}\n.pk-shell-stderr{color:var(--bad);}\n.pk-shell-stdout{color:var(--ink);}\n.pk-shell-input{color:var(--info);font-weight:600;}\n.pk-shell-input .pk-shell-text::before{content:'$ ';color:var(--muted);font-weight:400;}\n#pk-shell-stateline{font-family:var(--mono);font-size:0.62rem;color:var(--muted);}\n#pk-shell-cmd{font-family:var(--mono);font-size:0.7rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:6px;padding:0.4rem 0.55rem;}\n\n/* \u2500\u2500 RoboPark Sessions & Telemetry \u2014 sub-tab control room \u2500\u2500 */\n.rp-subtabs{display:flex;gap:0.15rem;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:97px;z-index:18;flex-wrap:wrap;}\n.rp-subtab{font-family:var(--mono);font-size:0.72rem;padding:0.55rem 0.85rem;cursor:pointer;color:var(--muted);background:none;border:none;\n border-bottom:2px solid transparent;display:inline-flex;align-items:center;gap:0.4rem;transition:color .12s,border-color .12s;}\n.rp-subtab:hover{color:var(--ink);}\n.rp-subtab.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.rp-subtab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.rp-subtab .badge{font-family:var(--mono);font-size:0.6rem;padding:0.04em 0.45em;border-radius:100px;background:var(--panel-2);\n border:1px solid var(--glass-border);color:var(--muted);font-weight:600;}\n.rp-subtab.active .badge{color:var(--gold-bright);border-color:var(--border-strong);}\n.rp-sub{display:none;padding:0.8rem 1.1rem 1.5rem;}\n.rp-sub.active{display:block;}\n\n.rp-kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.7rem;margin-bottom:0.9rem;}\n.rp-kpi{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);\n -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);position:relative;overflow:hidden;}\n.rp-kpi::after{content:\"\";position:absolute;inset:0;pointer-events:none;background:linear-gradient(135deg,rgba(255,214,112,0.04),transparent 60%);}\n.rp-kpi .l{font-family:var(--mono);font-size:0.6rem;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);margin-bottom:0.25rem;}\n.rp-kpi .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.7rem;color:var(--ink);font-weight:600;line-height:1.1;}\n.rp-kpi .v.ok{color:var(--ok);} .rp-kpi .v.warn{color:var(--warn);} .rp-kpi .v.bad{color:var(--bad);} .rp-kpi .v.info{color:var(--info);}\n.rp-kpi .s{font-family:var(--mono);font-size:0.62rem;color:var(--muted);margin-top:0.2rem;}\n\n.rp-mini-chart-wrap{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);\n -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);margin-bottom:0.9rem;}\n.rp-mini-chart-wrap h4{font-family:var(--mono);font-size:0.62rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);\n margin-bottom:0.4rem;display:flex;justify-content:space-between;align-items:baseline;}\n.rp-mini-chart-wrap h4 .now{color:var(--muted);font-size:0.6rem;}\n.rp-mini-chart{width:100%;height:120px;display:block;}\n\n.rp-section-h{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.55rem;display:flex;align-items:center;gap:0.5rem;justify-content:space-between;}\n.rp-section-h .meta{color:var(--muted);font-size:0.62rem;text-transform:none;letter-spacing:0.02em;font-weight:400;}\n\n.rp-top3{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:0.6rem;margin-bottom:0.9rem;}\n.rp-top3 .rp-card{padding:0.55rem 0.7rem;}\n\n.rp-stream{border-radius:13px;background:var(--panel);\n -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);padding:0.55rem 0.7rem;max-height:300px;overflow-y:auto;}\n.rp-stream .line{padding:0.22rem 0.4rem;}\n.rp-stream .line .ic{font-size:0.8rem;flex:none;}\n\n.rp-filterbar{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;padding:0.5rem 0.7rem;background:var(--panel-2);\n border:1px solid var(--glass-border);border-radius:11px;margin-bottom:0.6rem;}\n.rp-filterbar .rp-field{flex:1;min-width:120px;max-width:240px;}\n.rp-filterbar .rp-field label{font-size:0.6rem;}\n.rp-filterbar .rp-field input,.rp-filterbar .rp-field select{font-size:0.7rem;padding:0.35rem 0.5rem;}\n.rp-filterbar .rp-btn{padding:0.32rem 0.55rem;}\n\n.rp-sess-table,.rp-history-table,.rp-audit-table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:0.7rem;background:var(--panel);\n -webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);border-radius:11px;overflow:hidden;box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-sess-table th,.rp-history-table th,.rp-audit-table th{padding:0.55rem 0.7rem;text-align:left;font-weight:600;\n font-size:0.6rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-bright);background:var(--panel-2);\n border-bottom:1px solid var(--glass-border);position:sticky;top:0;z-index:1;}\n.rp-sess-table td,.rp-history-table td,.rp-audit-table td{padding:0.5rem 0.7rem;border-bottom:1px solid var(--glass-border);\n color:var(--ink);vertical-align:middle;}\n.rp-sess-table tr:last-child td,.rp-history-table tr:last-child td,.rp-audit-table tr:last-child td{border-bottom:none;}\n.rp-sess-table tr:hover td,.rp-history-table tr:hover td,.rp-audit-table tr:hover td{background:color-mix(in srgb,var(--gold-bright) 5%,transparent);}\n.rp-sess-table .sid,.rp-history-table .sid{font-size:0.62rem;color:var(--muted);max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}\n.rp-sess-table .t-rel{color:var(--ink);display:block;font-size:0.7rem;}\n.rp-sess-table .t-abs{color:var(--muted);font-size:0.58rem;}\n.rp-sess-table .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 6px var(--ok);margin-right:0.4rem;animation:pulse 2.4s infinite;vertical-align:middle;}\n.rp-tbl-wrap{max-height:520px;overflow:auto;border-radius:11px;}\n.rp-tbl-pager{display:flex;justify-content:space-between;align-items:center;padding:0.55rem 0.2rem;gap:0.5rem;font-family:var(--mono);font-size:0.66rem;color:var(--muted);}\n.rp-tbl-pager .rp-btn{padding:0.3rem 0.55rem;}\n.rp-tbl-empty{padding:1.5rem;text-align:center;color:var(--muted);font-family:var(--mono);font-size:0.75rem;}\n.rp-end-badge{display:inline-block;padding:0.05em 0.45em;border-radius:5px;font-size:0.6rem;font-family:var(--mono);\n border:1px solid var(--glass-border);color:var(--muted);background:var(--panel-2);}\n.rp-end-badge.operator{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent);background:var(--bad-bg);}\n.rp-end-badge.silence{color:var(--muted);}\n.rp-end-badge.error{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.rp-end-badge.timeout{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);background:var(--warn-bg);}\n.rp-end-badge.completed{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:var(--ok-bg);}\n.rp-end-badge.active,.rp-end-badge.running{color:var(--ok);border-color:color(--ok);background:var(--ok-bg);}\n\n.rp-cat-chip{font-family:var(--mono);font-size:0.62rem;padding:0.28em 0.7em;border-radius:100px;\n border:1px solid var(--glass-border);background:var(--panel-2);color:var(--muted);cursor:pointer;\n display:inline-flex;align-items:center;gap:0.3rem;transition:all .12s;}\n.rp-cat-chip:hover{color:var(--ink);border-color:var(--border-strong);}\n.rp-cat-chip.active{color:var(--gold-bright);border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 10%,transparent);}\n.rp-cat-chip .ct{font-size:0.55rem;opacity:0.8;}\n\n.rp-modal-back{position:fixed;inset:0;z-index:60;background:rgba(4,3,12,0.62);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);\n display:none;align-items:flex-start;justify-content:center;padding:6vh 1rem 1rem;overflow-y:auto;}\n.rp-modal-back.open{display:flex;}\n.rp-modal{position:relative;width:min(720px,100%);max-height:80vh;background:linear-gradient(180deg,var(--panel-2),var(--panel));\n -webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);\n border:1px solid var(--border-strong);border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,0.55);display:flex;flex-direction:column;overflow:hidden;}\n.rp-modal-head{display:flex;align-items:center;gap:0.6rem;padding:0.8rem 1rem;border-bottom:1px solid var(--glass-border);background:var(--panel-2);}\n.rp-modal-head h3{font-family:var(--mono);font-size:0.86rem;font-weight:600;color:var(--gold-bright);flex:1;word-break:break-word;}\n.rp-modal-head .x{background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;\n width:30px;height:30px;cursor:pointer;font-size:0.95rem;flex:none;}\n.rp-modal-head .x:hover{color:var(--ink);border-color:var(--border-strong);}\n.rp-modal-body{padding:1rem;overflow-y:auto;flex:1;font-family:var(--mono);font-size:0.72rem;line-height:1.5;color:var(--ink);}\n.rp-modal-body .kv{display:grid;grid-template-columns:160px 1fr;gap:0.4rem 0.8rem;margin-bottom:0.4rem;}\n.rp-modal-body .kv .k{color:var(--muted);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.05em;}\n.rp-modal-body .kv .v{color:var(--ink);word-break:break-word;white-space:pre-wrap;}\n.rp-modal-body pre{background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;\n font-size:0.66rem;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;color:var(--ink);}\n\n.rp-call-shell{position:relative;min-height:720px;overflow:hidden;border:1px solid var(--glass-border);border-radius:24px;background:linear-gradient(145deg,rgba(18,15,9,.84),rgba(5,5,4,.9));box-shadow:0 30px 90px rgba(0,0,0,.7),inset 0 1px 0 rgba(255,238,194,.11);}\n.rp-call-shell:before{content:\"\";position:absolute;width:520px;height:520px;border-radius:50%;left:18%;top:-340px;background:rgba(246,215,123,.15);filter:blur(75px);pointer-events:none;}\n.rp-call-top{height:58px;padding:0 1.1rem;display:flex;align-items:center;gap:.75rem;border-bottom:1px solid var(--glass-border);background:rgba(8,7,5,.56);backdrop-filter:blur(24px);position:relative;z-index:3;}\n.rp-call-top .eyebrow{font-family:var(--mono);font-size:.57rem;letter-spacing:.16em;color:var(--gold);text-transform:uppercase;}\n.rp-call-top .title{font-family:var(--display);font-size:1.02rem;color:var(--gold-pale);letter-spacing:.03em;}\n.rp-call-top .session{margin-left:auto;font-family:var(--mono);font-size:.58rem;color:var(--muted);max-width:32ch;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.rp-call-grid{display:grid;grid-template-columns:280px minmax(380px,1fr) 330px;gap:1px;background:var(--glass-border);min-height:662px;position:relative;z-index:2;}\n.rp-call-rail,.rp-call-stage,.rp-call-chat{background:rgba(8,7,5,.82);padding:1rem;min-width:0;}\n.rp-call-rail{display:flex;flex-direction:column;gap:.8rem;}\n.rp-call-card{border:1px solid var(--glass-border);border-radius:16px;background:linear-gradient(145deg,rgba(38,31,18,.58),rgba(13,11,8,.66));box-shadow:inset 0 1px 0 var(--glass-hi),0 12px 35px rgba(0,0,0,.25);padding:.8rem;}\n.rp-call-character{position:relative;display:flex;align-items:center;gap:.75rem;padding:.75rem;border:1px solid rgba(246,215,123,.2);border-radius:15px;background:radial-gradient(circle at 12% 10%,rgba(246,215,123,.15),transparent 52%),rgba(16,14,10,.65);overflow:hidden;}\n.rp-call-character img{width:76px;height:76px;object-fit:contain;filter:drop-shadow(0 10px 20px rgba(0,0,0,.48));}\n.rp-call-character .name{font-family:var(--display);font-size:1.18rem;color:var(--gold-pale);font-weight:700;}\n.rp-call-character .meta{font-size:.64rem;line-height:1.4;}\n.rp-call-service-grid{display:grid;gap:.4rem;}\n.rp-call-service{display:grid;grid-template-columns:29px 1fr auto;align-items:center;gap:.45rem;padding:.48rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(255,255,255,.018);}\n.rp-call-service .ico{width:29px;height:29px;display:grid;place-items:center;border-radius:8px;background:rgba(246,215,123,.09);color:var(--gold-bright);font-size:.75rem;}\n.rp-call-service b{display:block;font-size:.64rem;color:var(--ink);}.rp-call-service small{display:block;font-family:var(--mono);font-size:.52rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:130px;}\n.rp-call-service .ready{font-family:var(--mono);font-size:.48rem;letter-spacing:.08em;text-transform:uppercase;color:var(--ok);}\n.rp-call-device{display:grid;gap:.22rem;margin-top:.45rem}.rp-call-device:first-child{margin-top:0}.rp-call-device label{font-family:var(--mono);font-size:.51rem;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}\n.rp-call-device select{width:100%;min-width:0;font-family:var(--mono);font-size:.62rem;background:#0c0a07;border:1px solid var(--glass-border);color:var(--ink);border-radius:9px;padding:.42rem .5rem;}\n.rp-call-device-note{margin-top:.55rem;padding:.52rem .58rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(0,0,0,.22);font-family:var(--mono);font-size:.54rem;line-height:1.45;color:var(--muted)}\n.rp-call-device-note.ok{color:var(--ok);border-color:rgba(87,224,156,.28);background:rgba(28,64,50,.28)}.rp-call-device-note.bad{color:var(--bad);border-color:rgba(255,126,110,.34);background:rgba(64,24,20,.34)}.rp-call-device-note.warn{color:var(--warn);border-color:rgba(242,193,78,.3);background:rgba(64,50,20,.3)}\n.rp-call-device-actions{display:grid;grid-template-columns:1fr 1fr;gap:.4rem;margin-top:.55rem}.rp-call-device-actions .rp-btn{width:100%}\n.rp-call-stage{display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;}\n.rp-call-stage:before{content:\"\";position:absolute;width:420px;height:420px;border:1px solid rgba(246,215,123,.08);border-radius:50%;box-shadow:0 0 0 42px rgba(246,215,123,.018),0 0 0 92px rgba(246,215,123,.012);}\n.rp-call-visual{position:relative;width:min(330px,80%);aspect-ratio:1;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle,rgba(246,215,123,.14),rgba(10,8,5,.58) 52%,rgba(0,0,0,.82) 72%);border:1px solid rgba(246,215,123,.24);box-shadow:0 0 70px rgba(201,150,46,.13),inset 0 0 48px rgba(246,215,123,.06);z-index:1;transition:box-shadow .3s,border-color .3s;}\n.rp-call-visual.speaking{box-shadow:0 0 95px rgba(246,215,123,.28),inset 0 0 55px rgba(246,215,123,.11);border-color:rgba(255,229,157,.55)}\n.rp-call-avatar{position:absolute;width:58%;height:58%;object-fit:contain;filter:drop-shadow(0 18px 30px rgba(0,0,0,.65));opacity:.96;}\n.rp-call-avatar:not([src]){display:none}\n.rp-call-avatar-fallback{position:absolute;width:128px;height:128px;border-radius:50%;display:grid;place-items:center;font-family:var(--display);font-size:2.8rem;letter-spacing:.08em;color:var(--gold-pale);background:radial-gradient(circle at 32% 24%,rgba(255,234,174,.18),rgba(201,150,46,.07) 48%,rgba(0,0,0,.2));border:1px solid rgba(246,215,123,.2);box-shadow:inset 0 0 34px rgba(246,215,123,.06),0 18px 45px rgba(0,0,0,.42);}\n.rp-call-avatar[src]{z-index:1}.rp-call-avatar[src]+.rp-call-avatar-fallback{display:none}\n.rp-call-bars{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:7px;opacity:.82}.rp-call-bars i{width:5px;height:16px;border-radius:8px;background:linear-gradient(var(--gold-pale),var(--gold));box-shadow:0 0 12px rgba(246,215,123,.35);transition:height .09s ease;}\n.rp-call-ring{position:absolute;inset:-10px;border:1px solid rgba(246,215,123,.13);border-radius:50%;animation:rp-call-spin 18s linear infinite}.rp-call-ring:before,.rp-call-ring:after{content:\"\";position:absolute;width:7px;height:7px;border-radius:50%;background:var(--gold-bright);box-shadow:0 0 15px var(--gold-bright)}.rp-call-ring:before{left:15%;top:12%}.rp-call-ring:after{right:10%;bottom:18%}\n@keyframes rp-call-spin{to{transform:rotate(360deg)}}\n.rp-call-agent-state{position:relative;z-index:2;margin-top:1.15rem;text-align:center}.rp-call-agent-state strong{display:block;font-family:var(--display);font-size:1.35rem;color:var(--gold-pale);font-weight:500}.rp-call-agent-state span{font-family:var(--mono);font-size:.55rem;letter-spacing:.13em;color:var(--muted);text-transform:uppercase}\n.rp-call-state{display:flex;align-items:center;justify-content:center;gap:.5rem;font-family:var(--mono);font-size:.62rem;padding:.55rem 0;position:relative;z-index:2;}\n.rp-call-orb{width:8px;height:8px;border-radius:50%;background:var(--muted)}.rp-call-orb.live{background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 1.6s infinite}.rp-call-orb.busy{background:var(--gold-bright);box-shadow:0 0 12px var(--gold-bright);animation:pulse 1s infinite}\n.rp-call-latency{position:relative;z-index:2;display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;width:min(420px,92%);margin-top:.65rem}.rp-call-latency div{padding:.48rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(0,0,0,.28);text-align:center}.rp-call-latency b{display:block;font-family:var(--mono);font-size:.72rem;color:var(--gold-bright)}.rp-call-latency span{font-family:var(--mono);font-size:.46rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}\n.rp-call-media{position:absolute;right:1rem;top:1rem;width:128px;aspect-ratio:4/3;border:1px solid var(--glass-border);border-radius:12px;background:#050504;overflow:hidden;display:none;z-index:4;box-shadow:var(--shadow)}.rp-call-media.on{display:block}.rp-call-media video{width:100%;height:100%;object-fit:cover}.rp-call-media .tag{position:absolute;left:.35rem;bottom:.3rem;font-family:var(--mono);font-size:.46rem;color:var(--gold-pale);background:rgba(0,0,0,.7);padding:.12rem .3rem;border-radius:4px}\n.rp-call-controls{position:relative;z-index:3;display:flex;align-items:center;justify-content:center;gap:.52rem;margin-top:1rem;flex-wrap:wrap}.rp-call-ctl{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--glass-border);background:rgba(255,255,255,.035);color:var(--ink);cursor:pointer;font-size:1rem;transition:.18s}.rp-call-ctl:hover:not(:disabled){transform:translateY(-2px);border-color:var(--gold-bright);color:var(--gold-bright);box-shadow:0 7px 24px rgba(0,0,0,.38)}.rp-call-ctl.on{background:rgba(246,215,123,.12);border-color:rgba(246,215,123,.46);color:var(--gold-bright)}.rp-call-ctl.danger{background:rgba(255,78,63,.13);border-color:rgba(255,126,110,.35);color:var(--bad)}.rp-call-ctl:disabled{opacity:.32;cursor:not-allowed}.rp-call-start{width:auto;border-radius:100px;padding:0 1.15rem;font-family:var(--mono);font-size:.61rem;text-transform:uppercase;letter-spacing:.08em;background:linear-gradient(135deg,#ffe59a,#b77b16);color:#201401;border-color:#ffdc7d;font-weight:700}\n.rp-call-chat{display:flex;flex-direction:column;min-height:0}.rp-call-chat-head{display:flex;align-items:center;gap:.45rem;padding-bottom:.65rem;border-bottom:1px solid var(--glass-border)}.rp-call-chat-head b{font-family:var(--display);font-size:.94rem;color:var(--gold-pale)}.rp-call-chat-head button{margin-left:auto;background:none;border:0;color:var(--muted);font-family:var(--mono);font-size:.55rem;cursor:pointer}.rp-call-chat-head button:hover{color:var(--gold-bright)}\n.rp-call-transcript{height:500px;overflow-y:auto;display:flex;flex-direction:column;gap:.55rem;padding:.7rem .15rem;scrollbar-color:var(--gold-deep) transparent}.rp-call-transcript.collapsed{display:none}\n.rp-turn{max-width:90%;border:1px solid var(--glass-border);border-radius:14px;padding:.6rem .7rem;font-size:.71rem;line-height:1.5;background:rgba(255,255,255,.025);box-shadow:0 8px 22px rgba(0,0,0,.2)}.rp-turn.user{align-self:flex-end;border-color:rgba(246,215,123,.22);background:linear-gradient(145deg,rgba(201,150,46,.13),rgba(255,255,255,.025));border-bottom-right-radius:4px}.rp-turn.assistant{align-self:flex-start;border-color:rgba(246,215,123,.38);border-bottom-left-radius:4px}.rp-turn .who{font-family:var(--mono);font-size:.5rem;text-transform:uppercase;letter-spacing:.1em;color:var(--gold);margin-bottom:.2rem}\n.rp-call-compose{display:flex;gap:.4rem;margin-top:auto;padding-top:.65rem;border-top:1px solid var(--glass-border)}.rp-call-compose input{flex:1;min-width:0;background:#0c0a07;border:1px solid var(--glass-border);color:var(--ink);border-radius:11px;padding:.58rem .65rem;font-size:.7rem}.rp-call-compose input:focus{outline:none;border-color:var(--gold-bright);box-shadow:0 0 0 3px rgba(246,215,123,.08)}.rp-call-compose button{width:38px;border-radius:11px;border:1px solid rgba(246,215,123,.38);background:rgba(246,215,123,.1);color:var(--gold-bright);cursor:pointer}\n.rp-call-event{min-height:42px;margin-top:.65rem;padding:.5rem .6rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(0,0,0,.22);font-family:var(--mono);font-size:.54rem;color:var(--muted);line-height:1.45}.rp-call-event b{color:var(--gold-bright)}\n.rp-detail-hero{display:flex;align-items:center;gap:.8rem;padding:.6rem;border:1px solid var(--glass-border);border-radius:12px;background:var(--panel-2);margin-bottom:.8rem;}\n.rp-detail-hero img{width:72px;height:72px;object-fit:contain;}\n.rp-pipeline{display:flex;flex-direction:column;gap:.3rem;margin-top:.5rem;}\n.rp-pipeline-row{display:grid;grid-template-columns:76px 125px 70px 1fr;gap:.5rem;padding:.38rem .5rem;border-left:3px solid var(--glass-border);background:var(--panel-2);border-radius:5px;font-family:var(--mono);font-size:.64rem;}\n.rp-pipeline-row.ok{border-left-color:var(--ok)} .rp-pipeline-row.failed,.rp-pipeline-row.blocked{border-left-color:var(--bad)}\n.node .node-hero{display:flex;align-items:center;gap:.65rem;margin:.45rem 0 .55rem;}\n.node .node-avatar{width:68px;height:68px;object-fit:contain;flex:none;filter:drop-shadow(0 7px 12px rgba(0,0,0,.3));}\n.node .node-health{font-family:var(--mono);font-size:.61rem;line-height:1.45;color:var(--muted);}\n.node .node-health b{color:var(--ink);}\n@media(max-width:1150px){.rp-call-grid{grid-template-columns:250px minmax(360px,1fr)}.rp-call-chat{grid-column:1/-1;min-height:390px}.rp-call-transcript{height:280px}}\n@media(max-width:760px){.rp-call-shell{border-radius:15px}.rp-call-grid{display:block}.rp-call-rail,.rp-call-stage,.rp-call-chat{border-bottom:1px solid var(--glass-border)}.rp-call-stage{min-height:590px}.rp-call-top .session{display:none}.rp-call-visual{width:min(285px,78vw)}.rp-call-transcript{height:320px}.rp-pipeline-row{grid-template-columns:62px 105px 58px 1fr}}\n\n/* \u2500\u2500 TTS voice picker (used inside the generic modal) \u2500\u2500 */\n.rp-voice-picker{display:flex;flex-direction:column;gap:0.6rem;min-height:320px;}\n.rp-voice-picker-toolbar{display:flex;gap:0.4rem;align-items:stretch;}\n.rp-voice-picker-toolbar input[type=search]{flex:1;min-width:0;font-family:var(--mono);font-size:0.78rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:8px;padding:0.5rem 0.7rem;}\n.rp-voice-picker-toolbar input[type=search]: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-voice-picker-status{font-size:0.66rem;color:var(--muted);display:flex;align-items:center;gap:0.5rem;min-height:1.1em;}\n.rp-voice-picker-status.busy{color:var(--gold-bright);}\n.rp-voice-picker-status.bad{color:var(--bad);}\n.rp-voice-picker-status.warn{color:var(--warn,#e0a23a);}\n.rp-voice-picker-status .rp-mono{font-family:var(--mono);}\n.rp-voice-picker-status .rp-spinner{display:inline-block;width:0.8em;height:0.8em;border:2px solid color-mix(in srgb,var(--gold-bright) 30%,transparent);border-top-color:var(--gold-bright);border-radius:50%;animation:rp-vp-spin 0.8s linear infinite;margin-right:0.25rem;}\n@keyframes rp-vp-spin{to{transform:rotate(360deg);}}\n.rp-voice-picker-results{display:flex;flex-direction:column;gap:0.7rem;max-height:55vh;overflow-y:auto;padding-right:0.25rem;}\n.rp-voice-picker-empty{color:var(--muted);font-style:italic;padding:1rem;text-align:center;border:1px dashed var(--glass-border);border-radius:8px;}\n.rp-vp-group{display:flex;flex-direction:column;gap:0.3rem;}\n.rp-vp-group-head{font-family:var(--mono);font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;padding:0.1rem 0.2rem;}\n.rp-vp-item{display:block;text-align:left;width:100%;background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;cursor:pointer;color:var(--ink);font-family:var(--mono);transition:border-color .12s, background .12s, transform .04s;}\n.rp-vp-item:hover{border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 6%,var(--panel-solid));}\n.rp-vp-item:active{transform:translateY(1px);}\n.rp-vp-item:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-vp-row1{display:flex;align-items:baseline;gap:0.5rem;justify-content:space-between;}\n.rp-vp-name{font-size:0.78rem;font-weight:600;color:var(--ink);}\n.rp-vp-meta{font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;flex:none;}\n.rp-vp-id{font-size:0.62rem;color:var(--muted);margin-top:0.1rem;word-break:break-all;}\n.rp-vp-desc{font-size:0.64rem;color:var(--muted);margin-top:0.2rem;line-height:1.4;}\n.rp-voice-picker-foot{font-size:0.6rem;color:var(--muted);text-align:center;padding-top:0.2rem;border-top:1px solid var(--glass-border);}\n.rp-voice-picker-foot .rp-muted{color:var(--muted);}\n\n/* Character and voice configuration workspace */\n.rp-config-workspace{grid-column:1/-1;display:grid;gap:1rem;}\n.rp-config-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;padding:1rem 1.1rem;border:1px solid var(--border-strong);border-radius:18px;background:radial-gradient(circle at 8% 0,rgba(246,215,123,.14),transparent 34%),linear-gradient(135deg,rgba(39,31,17,.82),rgba(8,7,5,.72));box-shadow:inset 0 1px 0 rgba(255,238,194,.1),0 18px 50px rgba(0,0,0,.28);}\n.rp-config-hero h3{font-family:var(--display);font-size:1.25rem;color:var(--gold-pale);margin:0;}\n.rp-config-hero p{max-width:72ch;margin:.25rem 0 0;color:var(--muted);font-size:.68rem;line-height:1.55;}\n.rp-config-hero-actions,.rp-config-toolbar,.rp-config-actions{display:flex;align-items:center;gap:.45rem;flex-wrap:wrap;}\n.rp-config-columns{display:grid;grid-template-columns:1fr 1fr;gap:1rem;align-items:start;}\n.rp-config-library{min-width:0;padding:.9rem;border:1px solid var(--glass-border);border-radius:18px;background:linear-gradient(145deg,rgba(31,26,18,.62),rgba(9,8,6,.7));box-shadow:inset 0 1px 0 var(--glass-hi),0 14px 44px rgba(0,0,0,.24);}\n.rp-config-library-head{display:flex;align-items:flex-start;gap:.7rem;margin-bottom:.7rem;}\n.rp-config-library-head .icon{width:38px;height:38px;display:grid;place-items:center;border-radius:11px;background:rgba(246,215,123,.1);border:1px solid rgba(246,215,123,.22);color:var(--gold-bright);font-size:1rem;flex:none;}\n.rp-config-library-head h4{font-family:var(--display);font-size:1rem;color:var(--gold-pale);margin:0;}\n.rp-config-library-head p{font-size:.58rem;color:var(--muted);margin:.1rem 0 0;line-height:1.4;}\n.rp-config-library-head .count{margin-left:auto;font-family:var(--mono);font-size:.56rem;color:var(--gold);padding:.25rem .5rem;border:1px solid var(--glass-border);border-radius:999px;}\n.rp-config-toolbar{margin-bottom:.65rem;}.rp-config-toolbar input{flex:1;min-width:150px;}\n.rp-config-cards{display:grid;gap:.55rem;max-height:560px;overflow:auto;padding-right:.2rem;scrollbar-color:var(--gold-deep) transparent;}\n.rp-config-card{position:relative;padding:.72rem;border:1px solid var(--glass-border);border-radius:13px;background:rgba(255,255,255,.022);transition:border-color .15s,transform .15s,background .15s;cursor:pointer;overflow:hidden;}\n.rp-config-card:before{content:\"\";position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(var(--gold-pale),var(--gold-deep));opacity:.65;}\n.rp-config-card:hover{transform:translateY(-1px);border-color:rgba(246,215,123,.38);background:rgba(246,215,123,.045);}\n.rp-config-card-head{display:flex;align-items:center;gap:.55rem;min-width:0;}.rp-config-card-head img{width:46px;height:46px;object-fit:contain;filter:drop-shadow(0 8px 12px rgba(0,0,0,.45));flex:none;}\n.rp-config-card-title{min-width:0;flex:1}.rp-config-card-title b{display:block;font-family:var(--display);font-size:.9rem;color:var(--gold-pale);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rp-config-card-title small{display:block;font-family:var(--mono);font-size:.5rem;color:var(--muted);margin-top:.08rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.rp-config-badges{display:flex;gap:.3rem;flex-wrap:wrap;margin-top:.55rem}.rp-config-badge{font-family:var(--mono);font-size:.48rem;letter-spacing:.04em;text-transform:uppercase;padding:.18rem .38rem;border-radius:999px;border:1px solid var(--glass-border);color:var(--muted);background:rgba(0,0,0,.18)}.rp-config-badge.ok{color:var(--ok);border-color:rgba(87,224,156,.28)}.rp-config-badge.gold{color:var(--gold-bright);border-color:rgba(246,215,123,.28)}\n.rp-config-pipeline{display:grid;grid-template-columns:1fr auto 1fr auto 1fr;gap:.32rem;align-items:center;margin-top:.55rem;font-family:var(--mono);font-size:.53rem}.rp-config-pipeline span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:.28rem .4rem;border-radius:7px;background:rgba(0,0,0,.24);border:1px solid var(--glass-border);color:var(--ink)}.rp-config-pipeline i{font-style:normal;color:var(--gold)}\n.rp-config-summary{margin-top:.5rem;color:var(--muted);font-size:.57rem;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}\n.rp-config-card .rp-config-actions{margin-top:.6rem}.rp-config-card .rp-btn{font-size:.53rem;padding:.32rem .5rem;}\n.rp-config-editor{display:grid;gap:.8rem}.rp-config-editor-intro{display:flex;gap:.75rem;padding:.72rem;border:1px solid rgba(246,215,123,.22);border-radius:13px;background:radial-gradient(circle at 0 0,rgba(246,215,123,.12),transparent 48%),rgba(0,0,0,.18)}.rp-config-editor-intro .mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid rgba(246,215,123,.25);border-radius:12px;color:var(--gold-bright);background:rgba(246,215,123,.08);font-size:1rem;flex:none}.rp-config-editor-intro b{display:block;font-family:var(--display);font-size:.95rem;color:var(--gold-pale)}.rp-config-editor-intro span{display:block;color:var(--muted);font-size:.59rem;line-height:1.45;margin-top:.15rem}\n.rp-config-section{padding:.75rem;border:1px solid var(--glass-border);border-radius:13px;background:rgba(255,255,255,.018)}.rp-config-section-head{display:flex;align-items:center;gap:.45rem;margin-bottom:.55rem;font-family:var(--mono);font-size:.58rem;text-transform:uppercase;letter-spacing:.09em;color:var(--gold-bright)}.rp-config-section-head span{margin-left:auto;color:var(--muted);font-size:.48rem;letter-spacing:.03em;text-transform:none}.rp-config-fields{display:grid;grid-template-columns:1fr 1fr;gap:.55rem}.rp-config-fields .wide{grid-column:1/-1}.rp-config-fields textarea{width:100%;resize:vertical;min-height:110px;font-family:var(--mono);font-size:.67rem;line-height:1.5}.rp-config-checks{display:flex;gap:.8rem;flex-wrap:wrap}.rp-config-checks label{display:flex;align-items:center;gap:.35rem;color:var(--ink);font-size:.62rem}.rp-config-checks input{width:auto;margin:0;accent-color:var(--gold-bright)}\n.rp-config-editor-foot{position:sticky;bottom:-1rem;display:flex;justify-content:flex-end;gap:.45rem;padding:.7rem 0 .1rem;background:linear-gradient(transparent,var(--panel) 28%)}\n.rp-modal.config-modal{width:min(980px,100%);max-height:90vh}.rp-modal.config-modal .rp-modal-body{padding:1rem 1.1rem 1.2rem;}\n@media(max-width:900px){.rp-config-columns{grid-template-columns:1fr}.rp-config-hero{align-items:flex-start;flex-direction:column}.rp-config-cards{max-height:430px}}\n@media(max-width:620px){.rp-config-fields{grid-template-columns:1fr}.rp-config-fields .wide{grid-column:auto}.rp-config-pipeline{grid-template-columns:1fr}.rp-config-pipeline i{display:none}.rp-config-hero-actions{width:100%}.rp-config-hero-actions .rp-btn{flex:1}}\n\n/* command sheet */\n.rp-command-shell{display:grid;gap:1rem}.rp-command-env{display:grid;grid-template-columns:repeat(3,minmax(170px,1fr));gap:.65rem;padding:.85rem;border:1px solid var(--glass-border);border-radius:16px;background:var(--panel-2)}\n.rp-command-toolbar{display:flex;gap:.55rem;align-items:center;flex-wrap:wrap}.rp-command-toolbar input{flex:1;min-width:220px}.rp-command-toolbar select{width:auto;min-width:170px}\n.rp-command-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.75rem}.rp-command-card{display:flex;flex-direction:column;gap:.55rem;min-width:0;padding:.85rem;border:1px solid var(--glass-border);border-radius:16px;background:linear-gradient(145deg,rgba(31,26,18,.66),rgba(9,8,6,.76));box-shadow:inset 0 1px 0 var(--glass-hi),0 12px 32px rgba(0,0,0,.22)}\n.rp-command-head{display:flex;align-items:flex-start;justify-content:space-between;gap:.6rem}.rp-command-head b{font-family:var(--mono);font-size:.74rem;color:var(--gold-pale)}.rp-command-tag{font-family:var(--mono);font-size:.52rem;text-transform:uppercase;letter-spacing:.06em;color:var(--gold-bright);border:1px solid var(--border-strong);border-radius:100px;padding:.15rem .42rem;white-space:nowrap}.rp-command-card p{font-size:.64rem;line-height:1.5;color:var(--muted);min-height:2.9em}\n.rp-command-code{position:relative;min-height:86px;padding:.68rem .72rem;border:1px solid rgba(255,224,151,.1);border-radius:11px;background:#050504;overflow:auto}.rp-command-code code{display:block;font-family:var(--mono);font-size:.59rem;line-height:1.55;color:#f2dfab;white-space:pre-wrap;overflow-wrap:anywhere}.rp-command-actions{display:flex;gap:.4rem;align-items:center;margin-top:auto}.rp-command-actions .rp-btn:first-child{flex:1}.rp-command-empty{grid-column:1/-1;padding:2rem;text-align:center;color:var(--muted);border:1px dashed var(--glass-border);border-radius:16px}\n.rp-command-custom{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;padding:.9rem;border:1px solid var(--glass-border);border-radius:16px;background:var(--panel-2)}.rp-command-custom h4,.rp-command-custom .wide{grid-column:1/-1}.rp-command-custom h4{font-family:var(--mono);font-size:.72rem;color:var(--gold-bright)}.rp-command-custom textarea{min-height:92px;resize:vertical}.rp-command-note{font-size:.61rem;color:var(--muted)}\n@media(max-width:800px){.rp-command-env{grid-template-columns:1fr 1fr}.rp-command-grid{grid-template-columns:1fr}}@media(max-width:540px){.rp-command-env,.rp-command-custom{grid-template-columns:1fr}.rp-command-custom h4,.rp-command-custom .wide{grid-column:auto}}\n\n.rp-fleet-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:0.7rem;padding:0;}\n.rp-fleet-card{position:relative;}\n.rp-fleet-card .spark{margin-top:0.5rem;height:36px;}\n.rp-fleet-card .rp-actions{margin-top:0.6rem;}\n.rp-fleet-card .pulse{position:absolute;top:0.75rem;right:0.85rem;display:flex;align-items:center;gap:0.35rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.rp-fleet-card .pulse .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);}\n.rp-fleet-card .pulse.live .dot{background:var(--ok);box-shadow:0 0 8px var(--ok);animation:pulse 2.4s infinite;}\n.rp-fleet-card .pulse.idle .dot{background:var(--muted);}\n.rp-fleet-card .pulse.offline .dot{background:var(--bad);}\n.rp-spark{width:100%;height:36px;display:block;opacity:0.92;}\n\n.rp-setup-grid{display:grid;grid-template-columns:1fr 1fr;gap:0.9rem;}\n@media (max-width:980px){.rp-setup-grid{grid-template-columns:1fr;}}\n\n.rp-form .rp-form{margin:0.5rem 0 0;padding:0.6rem 0.75rem;}\n\n.rp-input-grow{flex:1;min-width:140px;}\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 <span class=\"rp-operator-badge set\" id=\"rp-operator-badge\" role=\"button\" tabindex=\"0\">set operator</span>\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 &amp; Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <div class=\"fleet-ops\">\n <div class=\"fleet-hero\">\n <div><div class=\"eyebrow\">Production command center</div><h1>Fleet intelligence</h1><p>Live readiness, workload, release compliance, hardware pressure, and operator actions across every RoboPark device.</p></div>\n <div class=\"fleet-hero-actions\"><button class=\"rp-btn primary\" id=\"fleet-open-park\">Open Park map</button><button class=\"rp-btn\" id=\"fleet-open-sessions\">Live sessions</button><button class=\"rp-btn\" id=\"fleet-open-incidents\">Incidents</button><button class=\"rp-btn\" id=\"fleet-refresh\">Refresh</button></div>\n </div>\n <div class=\"fleet-kpis\" id=\"fleet-kpis\"></div>\n <div class=\"fleet-insights\">\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Operational readiness</b><span id=\"fleet-readiness-meta\">live</span></div><div id=\"fleet-readiness\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Robot workload</b><span>sessions / triggers</span></div><div class=\"fleet-bars\" id=\"fleet-workload\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Needs attention</b><span id=\"fleet-attention-meta\">0 issues</span></div><div class=\"fleet-attention\" id=\"fleet-attention\"></div></div>\n <div class=\"fleet-panel fleet-release\" id=\"fleet-release\"></div>\n </div>\n </div>\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 \u00B7 <b>ctrl+drag a robot</b> to reposition (persists)</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=\"pk-camera-ops\" id=\"pk-camera-ops\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Fullscreen robot camera operations view\">\n <div class=\"pk-camera-ops-head\">\n <img class=\"avatar\" id=\"pk-camera-ops-avatar\" alt=\"\">\n <div><div class=\"title\" id=\"pk-camera-ops-name\">Robot camera</div><div class=\"sub\" id=\"pk-camera-ops-sub\">RoboVision mesh relay</div></div>\n <div class=\"pk-camera-ops-actions\"><button class=\"rp-btn primary\" id=\"pk-camera-ops-trigger\">Simulate motion</button><button class=\"rp-btn\" id=\"pk-camera-ops-production\">Production mode</button><button class=\"rp-btn danger\" id=\"pk-camera-ops-end\">End session</button><button class=\"rp-btn\" id=\"pk-camera-ops-refresh\">Refresh feed</button><button class=\"rp-btn\" id=\"pk-camera-ops-native\">Browser fullscreen</button><button class=\"rp-btn\" id=\"pk-camera-ops-close\">Close</button></div>\n </div>\n <div class=\"pk-camera-ops-grid\">\n <div class=\"pk-camera-stage\" id=\"pk-camera-stage\">\n <img id=\"pk-camera-ops-img\" alt=\"Live robot camera feed\">\n <div class=\"empty\" id=\"pk-camera-ops-empty\">opening RoboVision camera...</div>\n <div class=\"pk-camera-rec\"><i></i><span id=\"pk-camera-ops-rec\">REC / CONNECTING</span></div>\n <div class=\"pk-camera-clock\" id=\"pk-camera-ops-clock\">--:--:--</div>\n <div class=\"pk-camera-overlay\" id=\"pk-camera-ops-overlay\"></div>\n </div>\n <aside class=\"pk-camera-side\">\n <div class=\"section\"><h3>Live insights</h3><div class=\"pk-camera-insight\" id=\"pk-camera-ops-insights\"></div></div>\n <div class=\"section\"><h3>Conversation pipeline</h3><div class=\"pk-camera-pipeline\" id=\"pk-camera-ops-pipeline\"></div></div>\n <div class=\"section\"><h3>Hardware and media</h3><div class=\"pk-camera-insight\" id=\"pk-camera-ops-hardware\"></div></div>\n <div class=\"section\"><h3>Operator note</h3><div class=\"pk-note\">Video is relayed directly from the robot's RoboVision MJPEG service through the authenticated mesh. Voice remains on LiveKit, avoiding camera ownership conflicts.</div></div>\n </aside>\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 &amp; 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 <!-- Sub-tab nav bar (sticky, just under main tabs) -->\n <div class=\"rp-subtabs\" id=\"rp-subtabs\" role=\"tablist\">\n <button class=\"rp-subtab active\" data-sub=\"overview\" id=\"rpsbtn-overview\" role=\"tab\" aria-selected=\"true\">Overview</button>\n <button class=\"rp-subtab\" data-sub=\"incidents\" id=\"rpsbtn-incidents\" role=\"tab\" aria-selected=\"false\">Incidents <span class=\"badge\" id=\"rps-incidents-badge\">0</span></button>\n <button class=\"rp-subtab\" data-sub=\"live\" id=\"rpsbtn-live\" role=\"tab\" aria-selected=\"false\">Live Sessions <span class=\"badge\" id=\"rps-live-badge\">0</span></button>\n <button class=\"rp-subtab\" data-sub=\"voice\" id=\"rpsbtn-voice\" role=\"tab\" aria-selected=\"false\">Voice Call</button>\n <button class=\"rp-subtab\" data-sub=\"history\" id=\"rpsbtn-history\" role=\"tab\" aria-selected=\"false\">History</button>\n <button class=\"rp-subtab\" data-sub=\"audit\" id=\"rpsbtn-audit\" role=\"tab\" aria-selected=\"false\">Audit Log</button>\n <button class=\"rp-subtab\" data-sub=\"fleet\" id=\"rpsbtn-fleet\" role=\"tab\" aria-selected=\"false\">Fleet</button>\n <button class=\"rp-subtab\" data-sub=\"config\" id=\"rpsbtn-config\" role=\"tab\" aria-selected=\"false\">Characters &amp; Voice</button>\n <button class=\"rp-subtab\" data-sub=\"commands\" id=\"rpsbtn-commands\" role=\"tab\" aria-selected=\"false\">Commands</button>\n <button class=\"rp-subtab\" data-sub=\"setup\" id=\"rpsbtn-setup\" role=\"tab\" aria-selected=\"false\">Setup</button>\n </div>\n\n <!-- \u2550\u2550\u2550 Overview \u2550\u2550\u2550 -->\n <div class=\"rp-sub active\" id=\"rp-sub-overview\">\n <div class=\"rp-kpi-grid\" id=\"rp-overview-kpis\"></div>\n <div class=\"rp-mini-chart-wrap\">\n <h4>Fleet health \u00B7 sessions (last 60 min) <span class=\"now\" id=\"rp-overview-now\">\u2014</span></h4>\n <canvas class=\"rp-mini-chart\" id=\"rp-overview-chart\" aria-label=\"Sessions over time\"></canvas>\n </div>\n <div class=\"rp-section-h\">Top robots by activity <span class=\"meta\" id=\"rp-overview-top-meta\"></span></div>\n <div class=\"rp-top3\" id=\"rp-overview-top\"></div>\n <div class=\"rp-section-h\">Recent activity <span class=\"meta\" id=\"rp-overview-stream-meta\">last 20</span></div>\n <div class=\"rp-stream\" id=\"rp-overview-stream\"></div>\n </div>\n\n <!-- \u2550\u2550\u2550 Live Sessions \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-live\">\n <div class=\"rp-filterbar\">\n <div class=\"rp-field\"><label>Search</label><input id=\"rp-live-search\" placeholder=\"session id, robot\u2026\"></div>\n <div class=\"rp-field\"><label>Robot</label><select id=\"rp-live-robot\"><option value=\"\">all robots</option></select></div>\n <label class=\"rp-btn\" style=\"display:inline-flex;align-items:center;gap:0.35rem;cursor:pointer;\">\n <input type=\"checkbox\" id=\"rp-live-autoref\" checked style=\"width:auto;margin:0;accent-color:var(--gold-bright);\"> auto-refresh\n </label>\n <button class=\"rp-btn\" id=\"rp-live-refresh\">\u21BB Refresh now</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-live-wrap\">\n <table class=\"rp-sess-table\" id=\"rp-live-table\">\n <thead><tr>\n <th>Robot</th><th>Session</th><th>Started</th><th>Duration</th><th>End reason</th><th>Actions</th>\n </tr></thead>\n <tbody id=\"rp-live-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-tbl-empty\" id=\"rp-live-empty\" style=\"display:none;\">No active sessions right now \u2014 sessions appear here the moment a robot joins a LiveKit room.</div>\n </div>\n\n <!-- \u2550\u2550\u2550 History \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-voice\">\n <div class=\"rp-call-shell\">\n <div class=\"rp-call-top\">\n <div><div class=\"eyebrow\">RoboVoice operator channel</div><div class=\"title\">Royal Voice Studio</div></div>\n <div class=\"session\" id=\"rp-call-session\">No active session</div>\n </div>\n <div class=\"rp-call-grid\">\n <aside class=\"rp-call-rail\">\n <div class=\"rp-call-character\" id=\"rp-call-character\"><div><div class=\"name\">Choose a character</div><div class=\"meta\">Preset and voice stack are frozen into session history.</div></div></div>\n <div class=\"rp-call-card\">\n <div class=\"rp-call-device\"><label>Character preset</label><select id=\"rp-call-character-select\"><option value=\"\">select character...</option></select></div>\n <div class=\"rp-call-device\"><label>Voice stack</label><select id=\"rp-call-stack-select\"><option value=\"\">character default</option></select></div>\n <div class=\"rp-config-actions\" style=\"margin-top:.6rem\"><button class=\"rp-btn\" id=\"rp-call-edit-character\">Edit character</button><button class=\"rp-btn\" id=\"rp-call-new-character\">New</button><button class=\"rp-btn\" id=\"rp-call-edit-stack\">Edit stack</button><button class=\"rp-btn\" id=\"rp-call-new-stack\">New stack</button></div>\n </div>\n <div class=\"rp-call-card\">\n <div class=\"rp-section-h\" style=\"margin:0 0 .5rem\">Voice pipeline <span class=\"meta\">selected stack</span></div>\n <div class=\"rp-call-service-grid\" id=\"rp-call-services\">\n <div class=\"rp-call-service\"><span class=\"ico\">STT</span><div><b>Speech recognition</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>\n <div class=\"rp-call-service\"><span class=\"ico\">LLM</span><div><b>Language model</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>\n <div class=\"rp-call-service\"><span class=\"ico\">TTS</span><div><b>Voice synthesis</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>\n </div>\n </div>\n <div class=\"rp-call-card\">\n <div class=\"rp-section-h\" style=\"margin:0 0 .5rem\">Browser devices <span class=\"meta\" id=\"rp-call-device-status\">permission required</span></div>\n <div class=\"rp-call-device\"><label>Microphone</label><select id=\"rp-call-mic-device\"><option value=\"\">system default</option></select></div>\n <div class=\"rp-call-device\"><label>Camera</label><select id=\"rp-call-cam-device\"><option value=\"\">system default</option></select></div>\n <div class=\"rp-call-device\"><label>Speaker output</label><select id=\"rp-call-speaker-device\"><option value=\"\">system default</option></select></div>\n <div class=\"rp-call-device-note\" id=\"rp-call-device-note\">Enable media access to reveal the actual microphone, camera, and speaker names available to this browser.</div>\n <div class=\"rp-call-device-actions\"><button class=\"rp-btn primary\" id=\"rp-call-refresh-devices\">Enable media access</button><button class=\"rp-btn\" id=\"rp-call-test-mic\">Test microphone</button></div>\n </div>\n </aside>\n <main class=\"rp-call-stage\">\n <div class=\"rp-call-media\" id=\"rp-call-media\"><video id=\"rp-call-video\" autoplay muted playsinline></video><span class=\"tag\" id=\"rp-call-media-tag\">CAMERA</span></div>\n <div class=\"rp-call-visual\" id=\"rp-call-visual\">\n <div class=\"rp-call-ring\"></div>\n <img class=\"rp-call-avatar\" id=\"rp-call-avatar\" alt=\"\" onerror=\"this.removeAttribute('src')\">\n <div class=\"rp-call-avatar-fallback\" id=\"rp-call-avatar-fallback\" aria-hidden=\"true\">RP</div>\n <div class=\"rp-call-bars\" id=\"rp-call-bars\"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>\n </div>\n <div class=\"rp-call-agent-state\"><strong id=\"rp-call-agent-state\">Ready when you are</strong><span id=\"rp-call-agent-detail\">select character and begin</span></div>\n <div class=\"rp-call-state\"><span class=\"rp-call-orb\" id=\"rp-call-orb\"></span><span id=\"rp-call-status\">ready to start</span><span class=\"mono\" id=\"rp-call-timer\">00:00</span></div>\n <div class=\"rp-call-latency\">\n <div><b id=\"rp-call-lat-total\">--</b><span>Total response</span></div>\n <div><b id=\"rp-call-lat-stt\">--</b><span>STT to LLM</span></div>\n <div><b id=\"rp-call-lat-tts\">--</b><span>LLM to TTS</span></div>\n </div>\n <div class=\"rp-call-controls\">\n <button class=\"rp-call-ctl rp-call-start\" id=\"rp-call-start\">Start call</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-mute\" title=\"Microphone\" disabled>MIC</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-camera\" title=\"Camera\" disabled>CAM</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-screen\" title=\"Share screen\" disabled>SCR</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-chat-toggle\" title=\"Toggle transcript\">TXT</button>\n <button class=\"rp-call-ctl danger\" id=\"rp-call-end\" title=\"End call\" disabled>END</button>\n </div>\n <div class=\"rp-call-event\" id=\"rp-call-event\"><b>System</b> Voice pipeline is standing by.</div>\n </main>\n <section class=\"rp-call-chat\">\n <div class=\"rp-call-chat-head\"><b>Conversation</b><span class=\"chip\" id=\"rp-call-message-count\">0 messages</span><button id=\"rp-call-collapse\">collapse transcript</button></div>\n <div class=\"rp-call-transcript\" id=\"rp-call-transcript\"><div class=\"rp-tbl-empty\">Your live transcript and typed messages appear here.</div></div>\n <form class=\"rp-call-compose\" id=\"rp-call-compose\"><input id=\"rp-call-message\" autocomplete=\"off\" placeholder=\"Type a message to the character...\" disabled><button type=\"submit\" id=\"rp-call-send\" disabled>SEND</button></form>\n <div id=\"rp-call-audio\" hidden></div>\n </section>\n </div>\n </div>\n </div>\n\n <div class=\"rp-sub\" id=\"rp-sub-history\">\n <div class=\"rp-filterbar\">\n <div class=\"rp-field\"><label>Search</label><input id=\"rp-hist-search\" placeholder=\"session id, robot\u2026\"></div>\n <div class=\"rp-field\"><label>Robot</label><select id=\"rp-hist-robot\"><option value=\"\">all robots</option></select></div>\n <div class=\"rp-field\"><label>End reason</label><select id=\"rp-hist-reason\">\n <option value=\"\">any</option>\n <option value=\"operator\">operator</option>\n <option value=\"silence\">silence</option>\n <option value=\"error\">error</option>\n <option value=\"timeout\">timeout</option>\n <option value=\"completed\">completed</option>\n <option value=\"unknown\">unknown</option>\n </select></div>\n <div class=\"rp-field\"><label>Range</label><select id=\"rp-hist-range\">\n <option value=\"24h\">last 24h</option>\n <option value=\"7d\" selected>last 7 days</option>\n <option value=\"30d\">last 30 days</option>\n <option value=\"all\">all</option>\n </select></div>\n <button class=\"rp-btn\" id=\"rp-hist-refresh\">\u21BB Refresh</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-hist-wrap\">\n <table class=\"rp-history-table\" id=\"rp-hist-table\">\n <thead><tr>\n <th>Session</th><th>Robot</th><th>Started</th><th>Ended</th><th>Duration</th><th>End reason</th><th>Turns</th><th>Pipeline</th><th>Latency</th><th>Actions</th>\n </tr></thead>\n <tbody id=\"rp-hist-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-tbl-empty\" id=\"rp-hist-empty\" style=\"display:none;\">No sessions match the current filters.</div>\n <div class=\"rp-tbl-pager\" id=\"rp-hist-pager\">\n <span id=\"rp-hist-info\">\u2014</span>\n <div>\n <button class=\"rp-btn\" id=\"rp-hist-prev\">\u2039 Prev</button>\n <button class=\"rp-btn\" id=\"rp-hist-next\">Next \u203A</button>\n </div>\n </div>\n </div>\n\n <!-- \u2550\u2550\u2550 Audit Log \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-audit\">\n <div class=\"rp-filterbar\" style=\"gap:0.4rem;\">\n <span class=\"rp-cat-chip active\" data-cat=\"\" id=\"rp-audit-cat-all\">All</span>\n <span class=\"rp-cat-chip\" data-cat=\"robot\" id=\"rp-audit-cat-robot\">\uD83E\uDD16 Robot</span>\n <span class=\"rp-cat-chip\" data-cat=\"device\" id=\"rp-audit-cat-device\">\uD83D\uDCE1 Device</span>\n <span class=\"rp-cat-chip\" data-cat=\"session\" id=\"rp-audit-cat-session\">\uD83C\uDF99 Session</span>\n <span class=\"rp-cat-chip\" data-cat=\"server\" id=\"rp-audit-cat-server\">\uD83D\uDDA5 Server</span>\n <span class=\"rp-cat-chip\" data-cat=\"voice\" id=\"rp-audit-cat-voice\">\uD83D\uDDE3 Voice</span>\n <span class=\"rp-cat-chip\" data-cat=\"character\" id=\"rp-audit-cat-character\">\uD83C\uDFAD Character</span>\n <div style=\"flex:1;\"></div>\n <span class=\"rp-actor-chip\" id=\"rp-audit-byme\" data-actor=\"\" title=\"Filter the audit log to actions attributed to the current operator\">\uD83D\uDC64 by me</span>\n <button class=\"rp-btn\" id=\"rp-audit-refresh\">\u21BB Refresh</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-audit-wrap\">\n <table class=\"rp-audit-table\" id=\"rp-audit-table\">\n <thead><tr>\n <th>Time</th><th>Category</th><th>Action</th><th>Target</th><th>Details</th><th>Operator</th>\n </tr></thead>\n <tbody id=\"rp-audit-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-tbl-empty\" id=\"rp-audit-empty\" style=\"display:none;\">No audit events match the current filter.</div>\n </div>\n\n <!-- \u2550\u2550\u2550 Incidents \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-incidents\">\n <div class=\"rp-section-h\">Liveness <span class=\"meta\" id=\"rp-incidents-meta\">polling mesh + scheduler + devices</span></div>\n <div class=\"rp-incidents-kpis\" id=\"rp-incidents-kpis\">\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-mesh\">\n <div class=\"lab\">mesh <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-mesh-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-mesh-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-mesh-meta\">awaiting first poll\u2026</div>\n </div>\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-sched\">\n <div class=\"lab\">scheduler <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-sched-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-sched-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-sched-meta\">awaiting first poll\u2026</div>\n </div>\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-cam\">\n <div class=\"lab\">cameras <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-cam-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-cam-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-cam-meta\">awaiting first session\u2026</div>\n </div>\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-prod\">\n <div class=\"lab\">production mode <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-prod-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-prod-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-prod-meta\">awaiting first poll\u2026</div>\n </div>\n </div>\n\n <div class=\"rp-section-h\" style=\"margin-top:1rem;\">Active incidents <span class=\"meta\" id=\"rp-incidents-count\">0</span></div>\n <div class=\"rp-filterbar\" style=\"gap:0.4rem;\">\n <span class=\"rp-cat-chip active\" data-inc-sev=\"\" id=\"rp-inc-sev-all\">All</span>\n <span class=\"rp-cat-chip\" data-inc-sev=\"bad\" id=\"rp-inc-sev-bad\">\u2717 critical</span>\n <span class=\"rp-cat-chip\" data-inc-sev=\"warn\" id=\"rp-inc-sev-warn\">\u26A0 warning</span>\n <span class=\"rp-cat-chip\" data-inc-sev=\"info\" id=\"rp-inc-sev-info\">\u00B7 info</span>\n <div style=\"flex:1\"></div>\n <button class=\"rp-btn\" id=\"rp-inc-refresh\">\u21BB Refresh</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-inc-wrap\" style=\"display:none;\">\n <table class=\"rp-inc-table\" id=\"rp-inc-table\">\n <thead><tr>\n <th>Severity</th><th>Robot</th><th>Readiness</th><th>First seen</th><th>Duration</th><th>Message</th><th></th>\n </tr></thead>\n <tbody id=\"rp-inc-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-empty\" id=\"rp-inc-empty\">no active incidents</div>\n\n <div class=\"rp-section-h\" style=\"margin-top:1rem;\">Acknowledged <span class=\"meta\" id=\"rp-inc-ack-count\">0</span></div>\n <div class=\"rp-tbl-wrap\" id=\"rp-inc-ack-wrap\" style=\"display:none;\">\n <table class=\"rp-inc-table\" id=\"rp-inc-ack-table\">\n <thead><tr>\n <th>Acked at</th><th>By</th><th>Robot</th><th>Readiness</th><th>Message</th><th></th>\n </tr></thead>\n <tbody id=\"rp-inc-ack-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-empty\" id=\"rp-inc-ack-empty\">no acknowledged incidents</div>\n <div class=\"pk-note\" style=\"margin-top:0.5rem;\">incidents are derived from /api/telemetry readiness codes. Acknowledgments are stored in your browser (localStorage) so multiple operators on different machines each get their own list. When a robot's readiness returns to \"ready\" or \"in_session\" the incident auto-closes.</div>\n </div>\n\n <!-- \u2550\u2550\u2550 Fleet \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-fleet\">\n <div class=\"fleet-ops\" style=\"margin-bottom:1rem\">\n <div class=\"fleet-kpis\" id=\"rp-fleet-kpis\"></div>\n <div class=\"fleet-insights\">\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Operational readiness</b><span>scheduler</span></div><div id=\"rp-fleet-readiness\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Robot workload</b><span>sessions / triggers</span></div><div class=\"fleet-bars\" id=\"rp-fleet-workload\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Needs attention</b><span id=\"rp-fleet-attention-meta\">0 issues</span></div><div class=\"fleet-attention\" id=\"rp-fleet-attention\"></div></div>\n </div>\n </div>\n <div class=\"rp-section-h\">Robots <span class=\"meta\" id=\"rp-fleet-meta\">live + idle + offline</span></div>\n <div class=\"rp-fleet-grid\" id=\"rp-grid\"></div>\n </div>\n\n <!-- \u2550\u2550\u2550 Setup \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-setup\">\n <div class=\"rp-form\" id=\"rp-provider-secrets\">\n <h3>Voice provider secret</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-field\" style=\"flex:1\"><label>ElevenLabs API key</label><input type=\"password\" id=\"rp-elevenlabs-key\" autocomplete=\"new-password\" placeholder=\"stored only on the scheduler\"></div>\n <button class=\"rp-btn primary\" id=\"rp-elevenlabs-save\" style=\"height:fit-content\">Save secret</button>\n </div>\n <div class=\"rp-autonote\" id=\"rp-elevenlabs-status\">not configured \u00B7 cached voice speaker tests require this key</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 &amp; 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 <div class=\"rp-field\"><label>Robot network</label><select id=\"rb-network\"><option value=\"auto\">Auto from hub address</option><option value=\"lan\">LAN</option><option value=\"tailscale\">Tailscale</option></select></div>\n <div class=\"rp-field\"><label>Mesh token</label><input id=\"rb-token\" spellcheck=\"false\" autocomplete=\"off\" placeholder=\"required in generated command\"></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>complete production one-liner \u2014 run this ON THE ROBOT</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>Site</label><input id=\"rb-site\" placeholder=\"Tel Aviv\"></div>\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 <!-- Local runtime / Docker manager -->\n <div class=\"rp-form\" id=\"rp-runtime\" style=\"grid-template-columns:1fr;\">\n <h3>Runtime health \u00B7 scheduler \u00B7 LiveKit \u00B7 Docker</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-autonote\" id=\"rp-runtime-summary\" style=\"flex:1;margin:0;\">not checked</div>\n <button class=\"rp-btn\" id=\"rp-runtime-refresh\">\u21BB Refresh health</button>\n </div>\n <div class=\"rp-tbl-wrap\"><table class=\"rp-history-table\"><thead><tr><th>Container</th><th>State</th><th>Image</th><th>Actions</th></tr></thead><tbody id=\"rp-runtime-containers\"></tbody></table></div>\n <div class=\"rp-autonote\" id=\"rp-runtime-note\">Docker controls are protected by the hub token. Logs are limited to the latest 100 lines.</div>\n </div>\n\n </div>\n\n <!-- Command sheet -->\n <div class=\"rp-sub\" id=\"rp-sub-commands\">\n <div class=\"rp-command-shell\">\n <div class=\"rp-config-hero\">\n <div><h3>RoboPark Command Sheet</h3><p>Production one-liners for the hub, LAN and Tailscale robots, health checks, recovery, Docker, LiveKit, and package operations. Edit the environment once; every generated command below updates immediately.</p></div>\n <div class=\"rp-config-hero-actions\"><button class=\"rp-btn\" id=\"rp-command-reset\">Reset environment</button><button class=\"rp-btn primary\" id=\"rp-command-copy-all\">Copy visible commands</button></div>\n </div>\n <div class=\"rp-command-env\">\n <div class=\"rp-field\"><label>Hub LAN IP</label><input id=\"rp-command-lan\" value=\"192.168.1.12\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Hub Tailscale IP / DNS</label><input id=\"rp-command-tail\" value=\"100.84.147.24\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Site</label><input id=\"rp-command-site\" value=\"Tel Aviv\"></div>\n <div class=\"rp-field\"><label>Robot name</label><input id=\"rp-command-robot\" value=\"bmw\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Enrollment token</label><input id=\"rp-command-enrollment\" placeholder=\"paste robot enrollment token\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Mesh token</label><input id=\"rp-command-token\" spellcheck=\"false\"></div>\n </div>\n <div class=\"rp-command-toolbar\">\n <input type=\"search\" id=\"rp-command-search\" placeholder=\"Search setup, logs, camera, Docker, LiveKit, npm...\">\n <select id=\"rp-command-category\"><option value=\"\">All categories</option><option>Provision</option><option>Health</option><option>Recovery</option><option>Media</option><option>Packages</option><option>Custom</option></select>\n <span class=\"rp-command-note\" id=\"rp-command-count\"></span>\n </div>\n <div class=\"rp-command-grid\" id=\"rp-command-grid\"></div>\n <form class=\"rp-command-custom\" id=\"rp-command-custom-form\">\n <h4>Add a persistent command</h4>\n <div class=\"rp-field\"><label>Title</label><input id=\"rp-command-custom-title\" required placeholder=\"Restart camera service\"></div>\n <div class=\"rp-field\"><label>Category</label><input id=\"rp-command-custom-category\" value=\"Custom\" placeholder=\"Custom\"></div>\n <div class=\"rp-field wide\"><label>Simple explanation</label><input id=\"rp-command-custom-description\" placeholder=\"When to use this and what success looks like\"></div>\n <div class=\"rp-field wide\"><label>Command</label><textarea id=\"rp-command-custom-command\" required spellcheck=\"false\" placeholder=\"Paste a safe command or one-liner\"></textarea></div>\n <div class=\"wide\"><button class=\"rp-btn primary\" type=\"submit\">Save command to this browser</button> <span class=\"rp-command-note\">Custom commands persist in localStorage on this operator browser.</span></div>\n </form>\n </div>\n </div>\n\n <!-- Character + voice management -->\n <div class=\"rp-sub\" id=\"rp-sub-config\">\n <div class=\"rp-config-workspace\" id=\"rp-templates\">\n <div class=\"rp-config-hero\">\n <div><h3>Character &amp; Voice Studio</h3><p>Build reusable personas and production voice pipelines in one place. Changes are applied centrally and become available immediately in Voice Studio, Fleet, and every Park robot panel.</p></div>\n <div class=\"rp-config-hero-actions\"><button class=\"rp-btn primary\" id=\"rp-new-character\">New character</button><button class=\"rp-btn\" id=\"rp-new-voice-stack\">New voice stack</button></div>\n </div>\n <div class=\"rp-config-columns\">\n <section class=\"rp-config-library\">\n <div class=\"rp-config-library-head\"><span class=\"icon\">VOX</span><div><h4>Voice stacks</h4><p>Speech recognition, language model, synthesis, turn-taking, and wake behavior.</p></div><span class=\"count\" id=\"rp-voice-count\">0 stacks</span></div>\n <div class=\"rp-config-toolbar\"><input id=\"rp-voice-search\" type=\"search\" placeholder=\"Search provider, model, voice, or stack\u2026\"><button class=\"rp-btn primary\" id=\"rp-addvoice\">Create</button></div>\n <div class=\"rp-config-cards\" id=\"rp-voice-list\"></div>\n <input id=\"rp-newvoice-name\" type=\"hidden\"><div id=\"rp-voice-edit\" style=\"display:none\"></div>\n </section>\n <section class=\"rp-config-library\">\n <div class=\"rp-config-library-head\"><span class=\"icon\">AI</span><div><h4>Character presets</h4><p>Persona, prompt, robot avatar, park placement, motors, and default voice stack.</p></div><span class=\"count\" id=\"rp-char-count\">0 presets</span></div>\n <div class=\"rp-config-toolbar\"><input id=\"rp-char-search\" type=\"search\" placeholder=\"Search character, stack, motor, or prompt\u2026\"><button class=\"rp-btn primary\" id=\"rp-addchar\">Create</button></div>\n <div class=\"rp-config-cards\" id=\"rp-char-list\"></div>\n <input id=\"rp-newchar-name\" type=\"hidden\"><input id=\"rp-newchar-stack\" type=\"hidden\"><datalist id=\"rp-newchar-stack-list\"></datalist><div id=\"rp-char-edit\" style=\"display:none\"></div>\n </section>\n </div>\n </div>\n </div>\n</div>\n\n<!-- Generic modal: title + scrollable body, used for session + audit detail views -->\n<div class=\"rp-modal-back\" id=\"rp-modal-back\" role=\"dialog\" aria-modal=\"true\">\n <div class=\"rp-modal\" id=\"rp-modal\">\n <div class=\"rp-modal-head\">\n <h3 id=\"rp-modal-title\">Details</h3>\n <button class=\"x\" id=\"rp-modal-close\" aria-label=\"Close\">\u2715</button>\n </div>\n <div class=\"rp-modal-body\" id=\"rp-modal-body\"></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 function dashboardMeshToken(){\n if(TOKEN)return TOKEN;\n try{var saved=JSON.parse(localStorage.getItem('rp-command-env-v1')||'null');return saved&&String(saved.token||'').trim()||'';}catch(e){return '';}\n }\n var UI_BUILD=(document.querySelector('meta[name=\"robopark-ui-build\"]')||{}).content||'unknown';\n fetch('/fed/ui-build'+QS,{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(info){\n if(!info||!info.build||info.build===UI_BUILD)return;\n var key='rp-ui-reload-'+info.build;if(sessionStorage.getItem(key))return;sessionStorage.setItem(key,'1');\n var url=new URL(location.href);url.searchParams.set('ui',info.build);location.replace(url.toString());\n }).catch(function(){});\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n var lastSchedulerPollAt = 0; // updated by pollRobopark() on every successful /api/telemetry fetch\n var lastFedStatusAt = 0; // updated by poll() on every successful /fed/status fetch\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 schedulerDevicesById = {}; // enrolled device rows + camera/audio inventory\n var schedulerDevicesByName = {}; // same rows, keyed by device name (id != name, e.g. dev_8984a80e vs jaguar2)\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.40, ny:0.68, 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 rpAvatarFor(label){\n var key=String(label||'').toLowerCase();\n var presets=(typeof characterPresets!=='undefined' && Array.isArray(characterPresets))?characterPresets:[];\n for(var i=0;i<presets.length;i++){\n var p=presets[i], aliases=[p.id,p.name];\n for(var j=0;j<aliases.length;j++){\n var a=String(aliases[j]||'').toLowerCase();\n if(a && (key===a || key.indexOf(a)>=0 || a.indexOf(key)>=0)) return p.img||'';\n }\n }\n if(key.indexOf('bmw')>=0 || key.indexOf('vixen')>=0) return '/static/robopark/bmw.png';\n var known=['volt','tesla','jaguar','panda'];\n for(var k=0;k<known.length;k++) if(key.indexOf(known[k])>=0) return '/static/robopark/'+known[k]+'.png';\n return '';\n }\n function rpTelemetryForNode(n){\n if(typeof roboparkByName==='undefined' || !roboparkByName) return null;\n var aliases=[n&&n.displayName,n&&n.name,n&&n.nodeId];\n for(var i=0;i<aliases.length;i++){\n var key=String(aliases[i]||'').toLowerCase();\n if(roboparkByName[key]) return roboparkByName[key];\n for(var existing in roboparkByName){\n if(existing===key || (key && existing.indexOf(key)>=0) || (existing && key.indexOf(existing)>=0)) return roboparkByName[existing];\n }\n }\n return null;\n }\n function renderFleetOperations(nodes, prefix, telemetryRows){\n prefix=prefix||'fleet'; nodes=Array.isArray(nodes)?nodes:[]; telemetryRows=Array.isArray(telemetryRows)?telemetryRows:null;\n var rows=[];\n function readinessOf(t,connected){ return String((t&&(t.readiness_code||t.status))||(!connected?'offline':'mesh_online')).toLowerCase(); }\n if(telemetryRows){\n for(var ti=0;ti<telemetryRows.length;ti++){\n var t=telemetryRows[ti], rn=robotName(t), rc=readinessOf(t,true), offline=rc==='offline'||rc==='robot_offline'||rc==='unreachable';\n rows.push({name:rn,connected:!offline,tele:t,hw:null,version:t.version||'',node:null,readiness:rc});\n }\n } else {\n for(var ni=0;ni<nodes.length;ni++){\n var n=nodes[ni], tele=rpTelemetryForNode(n), role=String(n.role||'').toLowerCase();\n if(role!=='satellite'&&role!=='robot'&&!tele) continue;\n rows.push({name:n.displayName||n.name||n.nodeId,connected:!!n.connected,tele:tele,hw:hwByNode[n.nodeId]||null,version:n.version||'',node:n,readiness:readinessOf(tele,!!n.connected)});\n }\n }\n var connected=0,ready=0,active=0,totalSessions=0,totalTriggers=0,latencies=[],drops=[],attention=[];\n for(var i=0;i<rows.length;i++){\n var r=rows[i], t=r.tele||{}, code=r.readiness;\n if(r.connected)connected++;\n if(code==='ready'||code==='in_session')ready++;\n if(code==='in_session'||t.active_session||t.in_session||t.session_active||t.current_session_id||t.status==='running'||t.status==='connecting')active++;\n totalSessions+=Number(t.sessions_total||0);totalTriggers+=Number(t.trigger_count||0);\n if(t.avg_latency_ms!=null)latencies.push(Number(t.avg_latency_ms));if(t.drop_rate!=null)drops.push(Number(t.drop_rate));\n var issues=[];\n if(!r.connected)issues.push('mesh or heartbeat offline');\n if(code!=='ready'&&code!=='in_session'&&code!=='mesh_online')issues.push(String(t.readiness_message||code).replace(/_/g,' '));\n if(t.drop_rate!=null&&Number(t.drop_rate)>=.05)issues.push(Math.round(Number(t.drop_rate)*100)+'% session drop rate');\n if(t.avg_latency_ms!=null&&Number(t.avg_latency_ms)>=3000)issues.push(Math.round(Number(t.avg_latency_ms))+'ms average response');\n if(r.hw&&r.hw.temperatureC!=null&&r.hw.temperatureC>=70)issues.push(Math.round(r.hw.temperatureC)+'C device temperature');\n if(r.hw&&r.hw.cpu&&r.hw.cpu.percent>=90)issues.push(Math.round(r.hw.cpu.percent)+'% CPU utilization');\n if(issues.length)attention.push({name:r.name,message:issues.join(' / '),bad:!r.connected||code==='stuck_session'||code==='join_timeout'||code==='robot_offline'});\n }\n var avgLatency=latencies.length?Math.round(latencies.reduce(function(a,b){return a+b;},0)/latencies.length):null;\n var avgDrop=drops.length?drops.reduce(function(a,b){return a+b;},0)/drops.length:null;\n var kpis=$(prefix+'-kpis');\n if(kpis){\n function k(label,value,detail,cls){return '<div class=\"fleet-kpi '+(cls||'')+'\"><div class=\"label\">'+esc(label)+'</div><div class=\"value\">'+esc(value)+'</div><div class=\"detail\">'+esc(detail)+'</div></div>';}\n kpis.innerHTML=k('Robots online',connected+'/'+rows.length,rows.length-connected+' unavailable',connected===rows.length?'ok':connected?'warn':'bad')+k('Ready',ready,rows.length?Math.round(ready/rows.length*100)+'% fleet readiness':'no robots',ready===rows.length?'ok':ready?'warn':'bad')+k('Live sessions',active,active?'operator oversight active':'fleet standing by',active?'ok':'')+k('Sessions',totalSessions,totalTriggers+' motion triggers','')+k('Response',avgLatency!=null?avgLatency+'ms':'--','fleet average',avgLatency==null?'':avgLatency>=3000?'bad':avgLatency>=1800?'warn':'ok')+k('Drop rate',avgDrop!=null?Math.round(avgDrop*100)+'%':'--','fleet average',avgDrop==null?'':avgDrop>=.15?'bad':avgDrop>=.05?'warn':'ok');\n }\n var read=$(prefix+'-readiness');\n if(read){\n var warn=Math.max(0,connected-ready),bad=Math.max(0,rows.length-connected),den=Math.max(1,rows.length),a1=ready/den*360,a2=(ready+warn)/den*360;\n read.innerHTML='<div class=\"fleet-readiness\"><div class=\"fleet-donut\" style=\"--ready-angle:'+a1+'deg;--warn-angle:'+a2+'deg\"><strong>'+Math.round(ready/den*100)+'%</strong></div><div class=\"fleet-legend\"><div><span><i style=\"background:var(--ok)\"></i>ready / in session</span><b>'+ready+'</b></div><div><span><i style=\"background:var(--warn)\"></i>degraded</span><b>'+warn+'</b></div><div><span><i style=\"background:var(--bad)\"></i>offline</span><b>'+bad+'</b></div><div><span>active conversations</span><b>'+active+'</b></div></div></div>';\n }\n var workload=$(prefix+'-workload');\n if(workload){\n var maxWork=1;for(var wi=0;wi<rows.length;wi++){var wt=rows[wi].tele||{},wv=Number(wt.sessions_total||0)+Number(wt.trigger_count||0);if(wv>maxWork)maxWork=wv;}\n workload.innerHTML=rows.length?rows.slice().sort(function(a,b){var at=a.tele||{},bt=b.tele||{};return (Number(bt.sessions_total||0)+Number(bt.trigger_count||0))-(Number(at.sessions_total||0)+Number(at.trigger_count||0));}).map(function(x){var xt=x.tele||{},sessions=Number(xt.sessions_total||0),triggers=Number(xt.trigger_count||0),work=sessions+triggers;return '<div class=\"fleet-bar-row\"><span class=\"nm\">'+esc(x.name)+'</span><span class=\"track\"><i class=\"fill\" style=\"display:block;width:'+Math.max(3,work/maxWork*100)+'%\"></i></span><span class=\"val\">'+sessions+' / '+triggers+'</span></div>';}).join(''):'<div class=\"pk-note\">No robot workload reported yet.</div>';\n }\n var attentionEl=$(prefix+'-attention'), attentionMeta=$(prefix+'-attention-meta');\n if(attentionMeta)attentionMeta.textContent=attention.length+' issue'+(attention.length===1?'':'s');\n if(attentionEl)attentionEl.innerHTML=attention.length?attention.map(function(a){return '<div class=\"fleet-alert-row '+(a.bad?'bad':'')+'\"><i></i><div><b>'+esc(a.name)+'</b><span>'+esc(a.message)+'</span></div><em>inspect</em></div>';}).join(''):'<div class=\"fleet-alert-row ok\"><i></i><div><b>All systems nominal</b><span>No active fleet readiness, latency, drop, temperature, or CPU alerts.</span></div><em>live</em></div>';\n var release=$(prefix+'-release');\n if(release){\n var versions={};for(var vi=0;vi<nodes.length;vi++){var version=nodes[vi].version||'unknown';versions[version]=(versions[version]||0)+1;}\n var versionKeys=Object.keys(versions).sort().reverse(),current=versionKeys[0]||'unknown';\n release.innerHTML='<div class=\"fleet-panel-head\" style=\"border:0;margin:0;padding:0\"><b>Release compliance</b><span>'+nodes.length+' mesh nodes</span></div>'+versionKeys.map(function(v){return '<span class=\"version-pill '+(v===current?'current':'')+'\">v'+esc(v)+' / '+versions[v]+' node'+(versions[v]===1?'':'s')+'</span>';}).join('')+'<span class=\"pk-note\" style=\"margin-left:auto\">updated '+clock(Date.now())+'</span>';\n }\n }\n function renderFleet(nodes){\n var el = $('fleet');\n // Keep existing MJPEG elements alive while telemetry cards are rebuilt.\n // Replacing an <img> tears down its long-lived stream and made cameras\n // appear to reconnect on every fleet poll.\n var preservedCameras={};\n if(el){Array.prototype.forEach.call(el.querySelectorAll('[data-fleet-camera]'),function(img){var key=img.getAttribute('data-fleet-camera');if(key)preservedCameras[key]=img;});}\n renderFleetOperations(nodes||[],'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 tele=rpTelemetryForNode(n);\n var avatar=rpAvatarFor(tele&&(tele.character_name||tele.character_id)||n.displayName);\n var readiness=tele&&(tele.readiness_code||tele.status)||(!up?'offline':'mesh online');\n var hb=tele&&tele.heartbeat_age_seconds!=null?tele.heartbeat_age_seconds:null;\n var site = n.site?(' \u00B7 '+esc(n.site)):'';\n var cameraRef=n.displayName||n.name||n.nodeId||n.id;\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 + (avatar?('<div class=\"node-hero\"><img class=\"node-avatar\" alt=\"'+esc(n.displayName)+'\" src=\"'+esc(avatar+QS)+'\"><div class=\"node-health\"><b>'+esc(String(readiness).replace(/_/g,' '))+'</b><br>'\n +(tele?('sessions '+esc(tele.sessions_total||0)+' / triggers '+esc(tele.trigger_count||0)+'<br>latency '+esc(tele.avg_latency_ms!=null?Math.round(tele.avg_latency_ms)+'ms':'n/a')+' / drops '+esc(tele.drop_rate!=null?Math.round(tele.drop_rate*100)+'%':'n/a')+'<br>heartbeat '+esc(hb!=null?hb+'s ago':'unavailable')):'scheduler telemetry pending')+'</div></div>'):'');\n if(tele){var fleetStage=tele.current_stage||{},fleetStageStatus=fleetStage.status||tele.current_stage_status||'waiting',fleetStageLabel=fleetStage.label||tele.current_stage_label||'Waiting for pipeline telemetry';html+='<div class=\"fleet-stage-focus '+esc(fleetStageStatus)+'\"><span>Live production stage</span><b>'+esc(fleetStageLabel)+'</b></div>';}\n\n if((tele||String(n.role||'').toLowerCase()==='satellite'||String(n.role||'').toLowerCase()==='robot')&&cameraRef){\n html += '<div class=\"fleet-live-camera\" data-fleet-camera-host=\"'+esc(cameraRef)+'\"><img alt=\"Live camera for '+esc(n.displayName)+'\" data-fleet-camera=\"'+esc(cameraRef)+'\"><span class=\"state\">'+(up?'connecting camera':'robot offline')+'</span></div>';\n }\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>'):'')\n + ((tele||String(n.role||'').toLowerCase()==='satellite')?('<div class=\"fleet-card-actions\"><button class=\"rp-btn primary\" data-fleet-action=\"camera\" data-robot=\"'+esc(n.displayName)+'\">Live camera</button><button class=\"rp-btn\" data-fleet-action=\"sessions\" data-robot=\"'+esc(n.displayName)+'\">Sessions</button><button class=\"rp-btn\" data-fleet-action=\"incidents\" data-robot=\"'+esc(n.displayName)+'\">Health</button>'+(tele&&(tele.character_id||tele.character_preset_id)?'<button class=\"rp-btn\" data-config-edit-character=\"'+esc(tele.character_id||tele.character_preset_id)+'\">Edit character</button>':'')+(tele&&tele.voice_stack_id?'<button class=\"rp-btn\" data-config-edit-stack=\"'+esc(tele.voice_stack_id)+'\">Edit voice</button>':'')+'</div>'):'')+'</div>';\n }\n el.innerHTML = html;\n Array.prototype.forEach.call(el.querySelectorAll('[data-fleet-camera]'),function(placeholder){\n var key=placeholder.getAttribute('data-fleet-camera'),old=preservedCameras[key];\n if(old){placeholder.parentNode.replaceChild(old,placeholder);placeholder=old;}\n var host=placeholder.closest('[data-fleet-camera-host]'),state=host&&host.querySelector('.state');\n function connect(){\n if(!placeholder.isConnected)return;\n placeholder.src='/fed/media/robots/'+encodeURIComponent(key)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();\n }\n placeholder.onload=function(){if(host){host.classList.add('live');host.classList.remove('failed');}if(state)state.textContent='always-on live';};\n placeholder.onerror=function(){if(host){host.classList.remove('live');host.classList.add('failed');}if(state)state.textContent='reconnecting camera';placeholder.removeAttribute('src');setTimeout(connect,4000);};\n if(!placeholder.getAttribute('src'))connect();\n else if(host){host.classList.add('live');if(state)state.textContent='always-on live';}\n });\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(ev){ if(ev&&ev.target&&ev.target.closest&&ev.target.closest('[data-fleet-action],[data-config-edit-stack],[data-config-edit-character]'))return; 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 var fleetButtons=el.querySelectorAll('[data-fleet-action]');\n for(var fb=0;fb<fleetButtons.length;fb++)(function(btn){btn.addEventListener('click',function(ev){ev.stopPropagation();var act=btn.getAttribute('data-fleet-action'),name=btn.getAttribute('data-robot');if(act==='camera'){selectTab('park');Park.openDrawerForRobot(name);}else{selectTab('robopark');setRpSubTab(act==='sessions'?'live':'incidents');}});})(fleetButtons[fb]);\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 // Scheduler-enrolled robots are not necessarily infinicode mesh peers. Add\n // them to the same Fleet/Park collection so a robot running only the\n // preview agent is still visible and selectable in the full dashboard.\n // \u2500\u2500 C3: Operator identity \u2500\u2500\n // The dashboard is a single-tenant surface (one hub = one operator\n // group), so we don't have full RBAC. We DO have a per-action\n // attribution problem: when an operator clicks \"End session\" the\n // audit log needs to know who. The scheduler logs whatever actor\n // string it sees, so we set an X-Operator header on every API\n // call from a name the operator typed into a profile modal on\n // first load. The name is editable from the top bar.\n var OPERATOR_NAME_KEY = 'rp-operator-name-v1';\n var _operatorName = '';\n function getOperatorName(){ return _operatorName || ''; }\n function setOperatorName(name){\n var s = String(name == null ? '' : name).trim();\n if(s.length > 64) s = s.slice(0, 64);\n _operatorName = s;\n try{ if(s) localStorage.setItem(OPERATOR_NAME_KEY, s); else localStorage.removeItem(OPERATOR_NAME_KEY); }catch(e){}\n updateOperatorBadge();\n }\n function loadOperatorName(){\n try{ _operatorName = (localStorage.getItem(OPERATOR_NAME_KEY) || '').trim(); }catch(e){ _operatorName = ''; }\n updateOperatorBadge();\n return _operatorName;\n }\n function operatorHeaders(){\n var n = getOperatorName();\n return n ? {'X-Operator': n} : {};\n }\n function updateOperatorBadge(){\n var el = $('rp-operator-badge');\n if(!el) return;\n var n = getOperatorName();\n el.textContent = n ? n : 'set operator';\n el.classList.toggle('set', !n);\n el.title = n ? ('Operator: '+n+' \u2014 click to change') : 'Click to set your operator name (used in the audit log)';\n }\n function openOperatorModal(){\n var cur = getOperatorName();\n var html = ''\n + '<div class=\"rp-field\"><label>Your name (operator)</label>'\n + '<input id=\"rp-operator-input\" maxlength=\"64\" value=\"'+esc(cur)+'\" placeholder=\"e.g. Jane Operator\" autocomplete=\"off\">'\n + '</div>'\n + '<div class=\"rp-field\"><label style=\"font-weight:400;color:var(--muted)\">'\n + 'This name is sent to the scheduler as the <code>X-Operator</code> header on every action you take (End session, Restart, Set production mode, etc.) '\n + 'and recorded in the audit log. Stored only in your browser\\'s localStorage \u2014 not on the server. '\n + 'Use a real name so postmortems can answer \"who clicked that?\".'\n + '</label></div>';\n showModal('Operator identity', html);\n setTimeout(function(){\n var inp = $('rp-operator-input');\n if(inp){ try{ inp.focus(); inp.select(); }catch(e){} }\n }, 30);\n }\n // First-load prompt: shown if the operator hasn't set a name yet.\n // They can dismiss (no name \u2192 actions still work, audit shows\n // \"operator\") or set one.\n function maybePromptOperator(){\n if(getOperatorName()) return;\n setTimeout(openOperatorModal, 800);\n }\n // click-to-edit from the top bar; the top bar element id is\n // \"rp-operator-badge\" \u2014 wired up near the end of the IIFE.\n document.addEventListener('click', function(e){\n var b = e.target.closest && e.target.closest('#rp-operator-badge');\n if(b){ e.preventDefault(); openOperatorModal(); }\n });\n // Save on Enter / Save-button in the modal\n document.addEventListener('keydown', function(e){\n if(e.target && e.target.id === 'rp-operator-input' && e.key === 'Enter'){\n e.preventDefault();\n var v = e.target.value;\n setOperatorName(v);\n closeModal();\n showOut(v ? ('Operator set to: '+v+' \u2014 your actions will be attributed to '+v+' in the audit log')\n : 'Operator name cleared \u2014 actions will be attributed to \"operator\"');\n }\n });\n\n function getJson(path){\n // QS is \"?token=...\"; a path that already carries its own query string\n // (e.g. \"/robopark/api/sessions?active_only=false&limit=200\") needs \"&\"\n // instead, or the token param lands inside an invalid second \"?\" and\n // the request goes out unauthenticated -> 401.\n var url = path + (path.indexOf('?')>=0 ? QS.replace('?','&') : QS);\n var headers = operatorHeaders();\n return fetch(url, {cache:'no-store', headers: headers}).then(function(r){\n if(!r.ok) throw new Error(path+' '+r.status);\n return r.json();\n });\n }\n function mergeSchedulerRobots(nodes){\n return Promise.all([getJson('/robopark/api/robots'), getJson('/robopark/api/devices')]).then(function(data){\n var rows = data[0], devices = data[1];\n var deviceById = {}, deviceByName = {};\n function norm(v){ return String(v||'').trim().toLowerCase(); }\n // A stale re-enrollment can leave more than one row with the same\n // display name. Keep the live/fresh device, never whichever SQLite\n // happened to return last, because all operator routes are id-keyed.\n function deviceScore(device){\n var status=norm(device&&device.status), at=Date.parse(device&&device.last_heartbeat||''), score=0;\n if(status==='online'||status==='running'||status==='detecting'||status==='connecting') score+=10000;\n if(Number.isFinite(at)) score+=Math.max(0, Math.floor(at/1000));\n return score;\n }\n (Array.isArray(devices)?devices:[]).forEach(function(device){\n deviceById[device.id] = device;\n if(device.name){\n var key=norm(device.name), current=deviceByName[key];\n if(!current || deviceScore(device)>deviceScore(current)) deviceByName[key] = device;\n }\n });\n schedulerDevicesById = deviceById;\n schedulerDevicesByName = deviceByName;\n var merged = (nodes||[]).slice();\n var candidates = (Array.isArray(rows)?rows:[]).slice();\n // Enrollment creates a devices row first; include it even when the\n // legacy robots table has not been populated for that device yet.\n (Array.isArray(devices)?devices:[]).forEach(function(device){\n var already = candidates.some(function(row){\n return norm(row.id||row.robot_id)===norm(device.id);\n });\n if(!already) candidates.push({id:device.id, name:device.name});\n });\n candidates.forEach(function(row){\n var id = row.id || row.robot_id;\n if(!id) return;\n var name = row.name || id;\n var device = deviceById[id] || deviceByName[norm(name)] || {};\n var status = String(device.status || row.status || '').toLowerCase();\n var heartbeat = device.last_heartbeat || row.last_heartbeat;\n var heartbeatAt = heartbeat ? Date.parse(heartbeat) : NaN;\n var heartbeatFresh = Number.isFinite(heartbeatAt) && (Date.now()-heartbeatAt) < 45_000;\n var connected = heartbeatFresh || ['online','detecting','connecting','running'].indexOf(status)>=0;\n var keys = [String(id).toLowerCase(), String(name).toLowerCase()];\n var existing = merged.find(function(n){\n return norm(n.schedulerRobotId)===norm(id)\n || norm(n.nodeId)===norm(id)\n || norm(n.displayName)===norm(name);\n });\n if(existing){\n existing.schedulerRobotId = id;\n existing.schedulerDeviceId = device.id || id;\n existing.displayName = existing.displayName || name;\n existing.connected = !!existing.connected || connected;\n existing.lastSeenAt = heartbeatAt && Number.isFinite(heartbeatAt) ? heartbeatAt : existing.lastSeenAt;\n existing.telemKeys = (existing.telemKeys||[]).concat(keys).filter(function(v,i,a){return a.indexOf(v)===i;});\n return;\n }\n merged.push({\n nodeId: 'scheduler-robot-'+id,\n schedulerRobotId: id,\n schedulerDeviceId: device.id || id,\n displayName: name,\n role: 'robot',\n platform: 'RoboPark robot',\n version: 'scheduler',\n capabilities: ['camera','microphone','voice'],\n connected: connected,\n load: 0,\n lastSeenAt: heartbeatAt && Number.isFinite(heartbeatAt) ? heartbeatAt : null,\n telemKeys: keys,\n });\n });\n return merged;\n }).catch(function(){ return nodes||[]; });\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 return mergeSchedulerRobots(s.nodes||[]).then(function(nodes){\n s.nodes = nodes;\n everOk = true; setLive(true); lastStatus = s; lastFedStatusAt = Date.now();\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 // C4: keep the incidents sub-tab's KPI + count fresh even when\n // the user isn't actively on it. The full incident list is\n // re-rendered only when the tab is open (cheap, since the\n // active-poll already pulls /api/telemetry at 3s).\n if(typeof renderLivenessKpis === 'function') renderLivenessKpis();\n if(rpSubTab === 'incidents' && typeof renderIncidents === 'function') renderIncidents();\n });\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 var currentHash=location.hash.replace('#','');\n var keepRpSubroute=name==='robopark' && currentHash.indexOf('robopark/')===0;\n if (!keepRpSubroute && currentHash !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark'){\n pollRobopark();\n initRpSubTabs();\n }\n if (name==='park') Park.activate(); else Park.deactivate();\n }\n var fleetParkBtn=$('fleet-open-park'); if(fleetParkBtn) fleetParkBtn.addEventListener('click',function(){selectTab('park');});\n var fleetSessionsBtn=$('fleet-open-sessions'); if(fleetSessionsBtn) fleetSessionsBtn.addEventListener('click',function(){selectTab('robopark');setRpSubTab('live');});\n var fleetIncidentsBtn=$('fleet-open-incidents'); if(fleetIncidentsBtn) fleetIncidentsBtn.addEventListener('click',function(){selectTab('robopark');setRpSubTab('incidents');});\n var fleetRefreshBtn=$('fleet-refresh'); if(fleetRefreshBtn) fleetRefreshBtn.addEventListener('click',function(){poll();pollRobopark();});\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.indexOf('robopark/')===0) selectTab('robopark');\n else if(initHash==='park') selectTab('park');\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 var headers = Object.assign({}, body?{'content-type':'application/json'}:{}, operatorHeaders());\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: headers,\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 var providerStatus=$('rp-elevenlabs-status');\n if(providerStatus) providerStatus.textContent=s&&s.elevenlabs_configured\n ? 'configured \u00B7 source: '+(s.elevenlabs_source==='environment'?'scheduler environment':'persistent scheduler store')\n : 'not configured \u00B7 cached voice speaker tests require this key';\n }).catch(function(){});\n }\n\n var elevenlabsSave=$('rp-elevenlabs-save');\n if(elevenlabsSave) elevenlabsSave.addEventListener('click',function(){\n var input=$('rp-elevenlabs-key'), key=input?input.value.trim():'';\n if(key.length<8){ showOut('Enter a valid ElevenLabs API key'); return; }\n elevenlabsSave.disabled=true;\n rpAction('POST','/robopark/api/settings/provider-secret',{provider:'elevenlabs',api_key:key}).then(function(){\n input.value=''; showOut('ElevenLabs secret saved on scheduler'); pollSettings();\n }).catch(function(e){ showOut('ElevenLabs secret: '+e.message); }).then(function(){elevenlabsSave.disabled=false;});\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\n function runtimeDockerAction(action, container){\n if(!container) return;\n if(!confirm(action+' '+container+'?')) return;\n rpAction('POST','/robopark/runtime/docker',{action:action,container:container}).then(function(res){ if(action==='logs'){ showOut(container+' logs (latest 100 lines):\\n'+(res&&res.output||'(no logs)')); } else { showOut(action+' queued for '+container); pollRuntimeHealth(); } }).catch(function(e){ showOut('docker '+action+' ('+container+'): '+e.message); });\n }\n function pollRuntimeHealth(){\n var summary=$('rp-runtime-summary'), tbody=$('rp-runtime-containers'); if(!summary||!tbody) return;\n getJson('/robopark/runtime/health').then(function(h){\n var docker=h.docker||{}, scheduler=h.scheduler||{};\n var containers=Array.isArray(h.containers&&h.containers.data)?h.containers.data:[];\n summary.textContent=(docker.ok?'Docker connected':'Docker unavailable')+' \u00B7 '+(scheduler.ok?'scheduler reachable':'scheduler unavailable')+' \u00B7 '+containers.filter(function(c){return /^(up|running)/i.test(String(c.State||''));}).length+'/'+containers.length+' containers running';\n summary.style.color=(docker.ok&&scheduler.ok)?'var(--ok)':'var(--bad)';\n tbody.innerHTML=containers.length?containers.map(function(c){\n var name=c.Names||c.Name||'container', state=c.State||'unknown', running=/^(up|running)/i.test(state);\n return '<tr><td class=\"mono\">'+esc(name)+'</td><td>'+esc(state)+'</td><td class=\"mono\">'+esc(c.Image||'\u2014')+'</td><td class=\"space-x-2\">'+['start','stop','restart','logs'].map(function(a){return '<button class=\"rp-btn '+(a==='stop'?'danger':'')+'\" data-runtime-action=\"'+a+'\" data-runtime-container=\"'+esc(name)+'\"'+((a==='start'&&running)||(a==='stop'&&!running)?' disabled':'')+'>'+a+'</button>';}).join(' ')+'</td></tr>';\n }).join(''):'<tr><td colspan=\"4\">No Docker containers found.</td></tr>';\n if(!docker.ok) $('rp-runtime-note').textContent='Docker unavailable: '+(docker.error||'check Docker Desktop');\n }).catch(function(e){ summary.textContent='runtime health unavailable'; summary.style.color='var(--bad)'; });\n }\n var runtimeRefresh=$('rp-runtime-refresh'); if(runtimeRefresh) runtimeRefresh.addEventListener('click', pollRuntimeHealth);\n var runtimeTable=$('rp-runtime-containers'); if(runtimeTable) runtimeTable.addEventListener('click', function(e){ var b=e.target.closest('[data-runtime-action]'); if(b&&!b.disabled) runtimeDockerAction(b.dataset.runtimeAction,b.dataset.runtimeContainer); });\n function recoverRobot(id){\n if(!confirm('Recover '+id+'? This closes any stale LiveKit session and returns the robot to idle.')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/recover',{}).then(function(res){\n showOut('recovered '+id+' \u2014 queued triggers cleared and robot returned to idle');\n pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('recover ('+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==='recover') recoverRobot(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 return 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(); refreshDrawerPresetOptions(); refreshVoiceCallOptions(); return characterPresets; }).catch(function(){ return []; });\n }\n function loadVoiceStacks(){\n return 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(); refreshDrawerPresetOptions(); refreshVoiceCallOptions(); return voiceStacks; }).catch(function(){ return []; });\n }\n\n // \u2500\u2500 TTS voice catalog (per provider) \u2500\u2500\n // Holds the dropdown options for whichever TTS provider is currently\n // selected in the voice-stack editor. We cache the catalog per-provider\n // in ttsVoiceCache so flipping back and forth between kokoro/elevenlabs\n // is instant. Each entry is { provider, source, count, voices, warning }.\n var ttsVoiceCache = {}; // provider -> TtsVoiceList payload\n var ttsVoiceLoading = {}; // provider -> in-flight Promise (de-dupes concurrent loads)\n function loadTtsVoiceCatalog(provider, opts){\n if(!provider) return Promise.resolve(null);\n opts = opts || {};\n var force = !!opts.force;\n if(ttsVoiceCache[provider] && !force) return Promise.resolve(ttsVoiceCache[provider]);\n if(ttsVoiceLoading[provider] && !force) return ttsVoiceLoading[provider];\n var p = fetch('/robopark/api/voices/'+encodeURIComponent(provider)+QS,{cache:'no-store'})\n .then(function(r){ return r.json(); })\n .then(function(d){\n ttsVoiceCache[provider] = d || {provider:provider, source:'static', count:0, voices:[], warning:'Empty response'};\n return ttsVoiceCache[provider];\n })\n .catch(function(e){\n var fallback = {provider:provider, source:'static', count:0, voices:[], warning:'Could not reach scheduler: '+(e&&e.message||e)};\n ttsVoiceCache[provider] = fallback;\n return fallback;\n })\n .then(function(v){ ttsVoiceLoading[provider]=null; return v; });\n ttsVoiceLoading[provider] = p;\n return p;\n }\n // Build the option list for the inline TTS-voice <select>. The current\n // value (s.tts_voice) is always included as a synthetic option if it\n // isn't in the catalog \u2014 that way editing a stack that was saved with a\n // legacy / unknown voice still shows what the user actually had.\n function fillTtsVoiceSelect(provider, currentVoiceId){\n var sel = $('rpve-tts-voice');\n var note = $('rpve-tts-voice-note');\n if(!sel) return;\n sel.innerHTML = '';\n var cache = ttsVoiceCache[provider];\n if(!cache){\n sel.innerHTML = '<option value=\"\">\u2014 loading voices\u2026 \u2014</option>';\n if(note){ note.className='rp-autonote busy'; note.textContent = 'Loading '+provider+' voice catalog\u2026'; }\n return;\n }\n var voices = Array.isArray(cache.voices) ? cache.voices : [];\n if(!voices.length){\n var ph = currentVoiceId\n ? '<option value=\"'+esc(currentVoiceId)+'\" selected>'+esc(currentVoiceId)+' (current)</option>'\n : '<option value=\"\">\u2014 no voices available \u2014</option>';\n sel.innerHTML = ph;\n } else {\n var html = '';\n var seen = {};\n for(var i=0;i<voices.length;i++){\n var v = voices[i];\n var id = v.id || v.voice_id || '';\n if(!id || seen[id]) continue;\n seen[id] = 1;\n var label = v.name || id;\n var subbits = [];\n if(v.language) subbits.push(v.language);\n if(v.gender) subbits.push(v.gender);\n if(v.category) subbits.push(v.category);\n if(subbits.length) label = label + ' \u00B7 ' + subbits.join(' \u00B7 ');\n html += '<option value=\"'+esc(id)+'\"'+(id===currentVoiceId?' selected':'')+'>'+esc(label)+'</option>';\n }\n // Preserve an out-of-catalog current value so the user can still see\n // what was previously saved.\n if(currentVoiceId && !seen[currentVoiceId]){\n html += '<option value=\"'+esc(currentVoiceId)+'\" selected>'+esc(currentVoiceId)+' (saved value, not in catalog)</option>';\n }\n sel.innerHTML = html;\n }\n // status note (source + warning)\n if(note){\n var src = cache.source || 'static';\n var cnt = cache.count != null ? cache.count : voices.length;\n if(cache.warning){\n note.className='rp-autonote bad';\n note.textContent = '\u26A0 '+cache.warning+(cnt?(' \u2014 showing '+cnt+' voices.'):'');\n } else {\n note.className='rp-autonote';\n note.textContent = cnt+' '+provider+' voices \u00B7 source: '+src+(currentVoiceId?(' \u00B7 current: '+currentVoiceId):'');\n }\n }\n }\n function refreshTtsVoiceSelect(force){\n var provEl = $('rpve-tts-provider');\n var sel = $('rpve-tts-voice');\n if(!provEl || !sel) return;\n var provider = provEl.value || 'elevenlabs';\n var cur = sel.value || (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].value) || '';\n // Prefer the live value from the input field if the user has been\n // editing it. openVoiceStackEdit sets it before this runs, so .value\n // is the saved voice. The above cur is only used as a fallback.\n var savedVoice = (sel && sel.__savedVoice) || cur;\n if(force || !ttsVoiceCache[provider]){\n fillTtsVoiceSelect(provider, savedVoice);\n loadTtsVoiceCatalog(provider, {force:!!force}).then(function(){\n fillTtsVoiceSelect(provider, savedVoice);\n });\n } else {\n fillTtsVoiceSelect(provider, savedVoice);\n }\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 // Rebuild the option list inside the currently-open Park robot drawer so\n // preset/voice dropdowns populate even if the data finishes loading AFTER\n // the drawer was opened. Idempotent: no-op if the drawer is closed.\n function refreshDrawerPresetOptions(){\n var b=$('pk-body'); if(!b) return;\n var setupChar=$('pk-setup-char');\n if(setupChar){\n var curSetup=setupChar.value;\n setupChar.innerHTML='<option value=\"\">\u2014 auto \u2014</option>'+characterPresets.map(function(p){\n var id=p.id||p.preset_id||p.character_id||'';\n var name=(p.name||p.id||p.preset_id||'preset')+(p.description?(' \u2014 '+p.description):'');\n return '<option value=\"'+esc(id)+'\"'+(id===curSetup?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\n }\n var setupVoice=$('pk-setup-voice');\n if(setupVoice){\n var curVoice=setupVoice.value;\n setupVoice.innerHTML='<option value=\"\">\u2014 from character preset \u2014</option>'+voiceStacks.map(function(s){\n var id=s.id||''; var name=(s.name||s.id||'stack')+(s.tts_voice?(' \u00B7 '+s.tts_voice):'');\n return '<option value=\"'+esc(id)+'\"'+(id===curVoice?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\n }\n var effChar=$('pk-eff-char');\n if(effChar){\n var curEffChar=effChar.value;\n effChar.innerHTML=characterPresets.map(function(p){\n var id=p.id||p.preset_id||p.character_id||''; var name=p.name||p.id||p.preset_id||'';\n return '<option value=\"'+esc(id)+'\"'+(id===curEffChar?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\n }\n var effVoice=$('pk-eff-voice');\n if(effVoice){\n var curEffVoice=effVoice.value;\n effVoice.innerHTML=voiceStacks.map(function(s){\n var id=s.id||''; var name=s.name||s.id||'';\n return '<option value=\"'+esc(id)+'\"'+(id===curEffVoice?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\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.replace(/\\/+$/,'');\n var schedulerUrl=hubUrl+'/robopark';\n var requestedNetwork=$('rb-network')&&$('rb-network').value||'auto';\n var network=requestedNetwork==='auto'?(/^100\\./.test(location.hostname)||/\\.ts\\.net$/i.test(location.hostname)?'tailscale':'lan'):requestedNetwork;\n var argv=['robopark','add-robot',name];\n if(characterId) argv.push('--character', characterId);\n var site=$('rb-site')&&$('rb-site').value.trim();if(site)argv.push('--site',site);\n if(hubUrl) argv.push('--hub-url', hubUrl);\n var meshToken=$('rb-token')&&$('rb-token').value.trim()||dashboardMeshToken()||'<mesh-token>';\n argv.push('--token', meshToken);\n if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);\n argv.push(network==='tailscale'?'--tailscale':'--lan','--start','--auto-start','--yes');\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 rbNetworkEl=$('rb-network'); if(rbNetworkEl) rbNetworkEl.addEventListener('change', refreshRobotCliCommand);\n var rbSiteEl=$('rb-site'); if(rbSiteEl) rbSiteEl.addEventListener('input', refreshRobotCliCommand);\n var rbTokenEl=$('rb-token'); if(rbTokenEl){ rbTokenEl.value=dashboardMeshToken(); rbTokenEl.addEventListener('input', 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)+'/simulate-trigger',{}).then(function(res){\n if(res && res.queued===false) throw new Error(res.reason||'trigger was not queued');\n showOut('motion trigger queued for '+id+' \u2014 waiting for robot heartbeat'); pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('motion trigger ('+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(){ openVoiceStackManager(); });\n $('rp-addchar').addEventListener('click', function(){ openCharacterManager(); });\n var newVoiceStackBtn=$('rp-new-voice-stack'); if(newVoiceStackBtn) newVoiceStackBtn.addEventListener('click',function(){ openVoiceStackManager(); });\n var newCharacterBtn=$('rp-new-character'); if(newCharacterBtn) newCharacterBtn.addEventListener('click',function(){ openCharacterManager(); });\n\n function configOption(value,label,current){ return '<option value=\"'+esc(value)+'\"'+(String(value)===String(current)?' selected':'')+'>'+esc(label||value)+'</option>'; }\n function configNumber(id,fallback){ var el=$(id), n=el?Number(el.value):NaN; return Number.isFinite(n)?n:fallback; }\n function voiceStackDraft(){\n return {\n id:$('cfg-vs-id').value.trim(),name:$('cfg-vs-name').value.trim(),\n stt_provider:$('cfg-vs-stt-provider').value,stt_model:$('cfg-vs-stt-model').value.trim(),stt_language:$('cfg-vs-stt-language').value.trim()||'en',\n llm_provider:$('cfg-vs-llm-provider').value,llm_model:$('cfg-vs-llm-model').value.trim(),llm_api_key:$('cfg-vs-llm-key').value.trim()||null,\n temperature:configNumber('cfg-vs-temperature',0.7),num_ctx:configNumber('cfg-vs-num-ctx',8192),\n tts_provider:$('cfg-vs-tts-provider').value,tts_voice:$('cfg-vs-tts-voice').value.trim(),tts_language:$('cfg-vs-tts-language').value.trim()||'en',\n max_turns:configNumber('cfg-vs-max-turns',20),min_endpointing_delay:configNumber('cfg-vs-endpointing',0.7),\n allow_interruptions:$('cfg-vs-interruptions').checked,wake_word_enabled:$('cfg-vs-wake-enabled').checked,\n wake_word_model:$('cfg-vs-wake-model').value.trim()||null,wake_word_threshold:configNumber('cfg-vs-wake-threshold',0.5),wake_word_timeout:configNumber('cfg-vs-wake-timeout',3)\n };\n }\n function openVoiceStackManager(id,draft){\n var existing=null;\n for(var i=0;i<voiceStacks.length;i++) if(String(voiceStacks[i].id)===String(id)){ existing=voiceStacks[i]; break; }\n var s=draft||existing||{id:'',name:'',stt_provider:'groq',stt_model:'whisper-large-v3-turbo',stt_language:'en',llm_provider:'ollama',llm_model:'gemma3:27b',temperature:0.7,num_ctx:8192,tts_provider:'elevenlabs',tts_voice:'',tts_language:'en',max_turns:20,min_endpointing_delay:0.7,allow_interruptions:true,wake_word_enabled:false,wake_word_threshold:0.5,wake_word_timeout:3};\n var editing=!!existing;\n var html='<div class=\"rp-config-editor\">'\n +'<div class=\"rp-config-editor-intro\"><span class=\"mark\">VOX</span><div><b>'+(editing?'Edit production voice stack':'Create production voice stack')+'</b><span>A stack is the complete conversational audio pipeline. Robot assignments keep referencing this ID when settings change.</span></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Identity <span>stable assignment key</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Stack ID</label><input id=\"cfg-vs-id\" value=\"'+esc(s.id||'')+'\" placeholder=\"english-fast\"'+(editing?' disabled':'')+'></div><div class=\"rp-field\"><label>Display name</label><input id=\"cfg-vs-name\" value=\"'+esc(s.name||'')+'\" placeholder=\"English fast\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Speech recognition <span>audio to text</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>STT provider</label><select id=\"cfg-vs-stt-provider\">'+configOption('groq','Groq',s.stt_provider)+configOption('speaches','Speaches / local',s.stt_provider)+configOption('vibevoice','VibeVoice',s.stt_provider)+'</select></div><div class=\"rp-field\"><label>STT model</label><input id=\"cfg-vs-stt-model\" value=\"'+esc(s.stt_model||'')+'\"></div><div class=\"rp-field\"><label>Language</label><input id=\"cfg-vs-stt-language\" value=\"'+esc(s.stt_language||'en')+'\" placeholder=\"en or auto\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Intelligence <span>prompt execution and response generation</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>LLM provider</label><select id=\"cfg-vs-llm-provider\">'+configOption('ollama','Ollama / Ollama Cloud',s.llm_provider)+configOption('groq','Groq',s.llm_provider)+configOption('gemini','Gemini',s.llm_provider)+configOption('moltbot','Moltbot',s.llm_provider)+'</select></div><div class=\"rp-field\"><label>LLM model</label><input id=\"cfg-vs-llm-model\" value=\"'+esc(s.llm_model||'')+'\"></div><div class=\"rp-field\"><label>Temperature</label><input id=\"cfg-vs-temperature\" type=\"number\" min=\"0\" max=\"2\" step=\"0.1\" value=\"'+esc(s.temperature!=null?s.temperature:0.7)+'\"></div><div class=\"rp-field\"><label>Context size</label><input id=\"cfg-vs-num-ctx\" type=\"number\" min=\"512\" step=\"512\" value=\"'+esc(s.num_ctx!=null?s.num_ctx:8192)+'\"></div><div class=\"rp-field wide\"><label>Provider API key override</label><input id=\"cfg-vs-llm-key\" type=\"password\" value=\"'+esc(s.llm_api_key||'')+'\" placeholder=\"Leave blank to use the ROBOVOICE agent secret\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Voice synthesis <span>text to robot speech</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>TTS provider</label><select id=\"cfg-vs-tts-provider\">'+configOption('elevenlabs','ElevenLabs',s.tts_provider)+configOption('kokoro','Kokoro',s.tts_provider)+configOption('piper','Piper',s.tts_provider)+'</select></div><div class=\"rp-field\"><label>Language</label><input id=\"cfg-vs-tts-language\" value=\"'+esc(s.tts_language||'en')+'\"></div><div class=\"rp-field wide\"><label>Voice ID</label><div class=\"rp-config-actions\"><input id=\"cfg-vs-tts-voice\" style=\"flex:1\" value=\"'+esc(s.tts_voice||'')+'\" placeholder=\"Choose or paste provider voice ID\"><button type=\"button\" class=\"rp-btn\" id=\"cfg-vs-browse\">Browse catalog</button></div></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Conversation behavior <span>latency and turn-taking</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Max turns</label><input id=\"cfg-vs-max-turns\" type=\"number\" min=\"1\" value=\"'+esc(s.max_turns!=null?s.max_turns:20)+'\"></div><div class=\"rp-field\"><label>Endpointing delay (seconds)</label><input id=\"cfg-vs-endpointing\" type=\"number\" min=\"0.1\" step=\"0.1\" value=\"'+esc(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7)+'\"></div><div class=\"rp-config-checks wide\"><label><input id=\"cfg-vs-interruptions\" type=\"checkbox\"'+(s.allow_interruptions!==false?' checked':'')+'>Allow natural interruptions</label><label><input id=\"cfg-vs-wake-enabled\" type=\"checkbox\"'+(s.wake_word_enabled?' checked':'')+'>Require wake word</label></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Wake word <span>optional local activation gate</span></div><div class=\"rp-config-fields\"><div class=\"rp-field wide\"><label>Model path</label><input id=\"cfg-vs-wake-model\" value=\"'+esc(s.wake_word_model||'')+'\" placeholder=\"models/hey_jarvis.onnx\"></div><div class=\"rp-field\"><label>Threshold</label><input id=\"cfg-vs-wake-threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" value=\"'+esc(s.wake_word_threshold!=null?s.wake_word_threshold:0.5)+'\"></div><div class=\"rp-field\"><label>Timeout (seconds)</label><input id=\"cfg-vs-wake-timeout\" type=\"number\" min=\"0\" step=\"0.5\" value=\"'+esc(s.wake_word_timeout!=null?s.wake_word_timeout:3)+'\"></div></div></div>'\n +'<div class=\"rp-config-editor-foot\"><button class=\"rp-btn\" id=\"cfg-vs-cancel\">Cancel</button><button class=\"rp-btn primary\" id=\"cfg-vs-save\">'+(editing?'Save changes':'Create stack')+'</button></div></div>';\n showModal(editing?'Edit voice stack \u00B7 '+(s.name||s.id):'New voice stack',html); $('rp-modal').classList.add('config-modal');\n $('cfg-vs-cancel').addEventListener('click',closeModal);\n $('cfg-vs-browse').addEventListener('click',function(){ var next=voiceStackDraft(); openTtsVoicePicker(next.tts_provider,function(voiceId){ next.tts_voice=voiceId; openVoiceStackManager(editing?id:null,next); }); });\n $('cfg-vs-save').addEventListener('click',function(){\n var body=voiceStackDraft(); if(!body.name){ showOut('Voice stack name is required'); return; }\n if(!editing && !body.id) body.id=body.name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,48);\n var btn=$('cfg-vs-save'); btn.disabled=true; btn.textContent='Saving\u2026';\n rpAction(editing?'PUT':'POST',editing?'/robopark/api/voice-stacks/'+encodeURIComponent(id):'/robopark/api/voice-stacks',body).then(function(){ closeModal(); showOut((editing?'Updated ':'Created ')+body.name); return loadVoiceStacks(); }).then(function(){ pollRobopark(); if(Park&&Park.refreshDrawer) Park.refreshDrawer(); }).catch(function(e){ btn.disabled=false; btn.textContent=editing?'Save changes':'Create stack'; showOut('voice stack: '+e.message); });\n });\n }\n\n function characterDraft(){\n return {id:$('cfg-cp-id').value.trim(),name:$('cfg-cp-name').value.trim(),description:$('cfg-cp-description').value.trim()||null,system_prompt:$('cfg-cp-prompt').value.trim()||null,voice_stack_id:$('cfg-cp-stack').value||null,motors:$('cfg-cp-motors').value.split(',').map(function(x){return x.trim();}).filter(Boolean),nx:configNumber('cfg-cp-nx',0.5),ny:configNumber('cfg-cp-ny',0.5),img:$('cfg-cp-img').value.trim()||null};\n }\n function openCharacterManager(id,draft){\n var existing=null; for(var i=0;i<characterPresets.length;i++) if(String(characterPresets[i].id)===String(id)){ existing=characterPresets[i]; break; }\n var p=draft||existing||{id:'',name:'',description:'',system_prompt:'',voice_stack_id:'',motors:['drive'],nx:0.5,ny:0.5,img:''}, editing=!!existing, stackOptions='<option value=\"\">No default stack</option>';\n for(var j=0;j<voiceStacks.length;j++) stackOptions+=configOption(voiceStacks[j].id,voiceStacks[j].name||voiceStacks[j].id,p.voice_stack_id);\n var html='<div class=\"rp-config-editor\">'\n +'<div class=\"rp-config-editor-intro\"><span class=\"mark\">AI</span><div><b>'+(editing?'Edit character preset':'Create character preset')+'</b><span>The persona prompt and default voice stack are frozen into each session record for reliable history and replay.</span></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Identity <span>fleet-facing appearance</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Preset ID</label><input id=\"cfg-cp-id\" value=\"'+esc(p.id||'')+'\" placeholder=\"jaguar\"'+(editing?' disabled':'')+'></div><div class=\"rp-field\"><label>Display name</label><input id=\"cfg-cp-name\" value=\"'+esc(p.name||'')+'\" placeholder=\"JAGUAR\"></div><div class=\"rp-field wide\"><label>Description</label><input id=\"cfg-cp-description\" value=\"'+esc(p.description||'')+'\" placeholder=\"Short operator-facing description\"></div><div class=\"rp-field wide\"><label>Avatar image path</label><input id=\"cfg-cp-img\" value=\"'+esc(p.img||'')+'\" placeholder=\"/static/robopark/jaguar.png\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Persona intelligence <span>ROBOVOICE system prompt</span></div><div class=\"rp-config-fields\"><div class=\"rp-field wide\"><label>System prompt</label><textarea id=\"cfg-cp-prompt\" placeholder=\"Define personality, constraints, speaking style, and robot behavior\u2026\">'+esc(p.system_prompt||'')+'</textarea></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Voice &amp; embodiment <span>pipeline and physical capabilities</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Default voice stack</label><select id=\"cfg-cp-stack\">'+stackOptions+'</select></div><div class=\"rp-field\"><label>Motor capabilities</label><input id=\"cfg-cp-motors\" value=\"'+esc((p.motors||[]).join(', '))+'\" placeholder=\"drive, wave, look\"></div><div class=\"rp-field\"><label>Park pad X (0\u20131)</label><input id=\"cfg-cp-nx\" type=\"number\" min=\"0\" max=\"1\" step=\"0.01\" value=\"'+esc(p.nx!=null?p.nx:0.5)+'\"></div><div class=\"rp-field\"><label>Park pad Y (0\u20131)</label><input id=\"cfg-cp-ny\" type=\"number\" min=\"0\" max=\"1\" step=\"0.01\" value=\"'+esc(p.ny!=null?p.ny:0.5)+'\"></div></div></div>'\n +'<div class=\"rp-config-editor-foot\"><button class=\"rp-btn\" id=\"cfg-cp-cancel\">Cancel</button><button class=\"rp-btn primary\" id=\"cfg-cp-save\">'+(editing?'Save changes':'Create character')+'</button></div></div>';\n showModal(editing?'Edit character \u00B7 '+(p.name||p.id):'New character preset',html); $('rp-modal').classList.add('config-modal'); $('cfg-cp-cancel').addEventListener('click',closeModal);\n $('cfg-cp-save').addEventListener('click',function(){ var body=characterDraft(); if(!body.name){ showOut('Character name is required'); return; } if(!editing&&!body.id) body.id=body.name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,48); var btn=$('cfg-cp-save');btn.disabled=true;btn.textContent='Saving\u2026';rpAction(editing?'PUT':'POST',editing?'/robopark/api/character-presets/'+encodeURIComponent(id):'/robopark/api/character-presets',body).then(function(){closeModal();showOut((editing?'Updated ':'Created ')+body.name);return Promise.all([loadCharacterPresets(),loadParkSlots()]);}).then(function(){pollRobopark();if(Park&&Park.refreshDrawer)Park.refreshDrawer();}).catch(function(e){btn.disabled=false;btn.textContent=editing?'Save changes':'Create character';showOut('character preset: '+e.message);}); });\n }\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 query=String(($('rp-voice-search')&&$('rp-voice-search').value)||'').trim().toLowerCase(), html='',shown=0;\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i], hay=[s.id,s.name,s.stt_provider,s.stt_model,s.llm_provider,s.llm_model,s.tts_provider,s.tts_voice].join(' ').toLowerCase(); if(query&&hay.indexOf(query)<0) continue; shown++; html += '<article class=\"rp-config-card\" data-vsid=\"'+esc(s.id)+'\"><div class=\"rp-config-card-head\"><div class=\"rp-config-card-title\"><b>'+esc(s.name||s.id)+'</b><small>'+esc(s.id)+' \u00B7 '+esc(s.tts_voice||'voice not selected')+'</small></div><span class=\"rp-config-badge '+(s.allow_interruptions?'ok':'')+'\">'+(s.allow_interruptions?'interruptible':'fixed turn')+'</span></div><div class=\"rp-config-pipeline\"><span>'+esc(s.stt_provider||'STT')+' / '+esc(s.stt_model||'default')+'</span><i>\u2192</i><span>'+esc(s.llm_provider||'LLM')+' / '+esc(s.llm_model||'default')+'</span><i>\u2192</i><span>'+esc(s.tts_provider||'TTS')+'</span></div><div class=\"rp-config-badges\"><span class=\"rp-config-badge gold\">'+esc(s.stt_language||'auto')+'</span><span class=\"rp-config-badge\">'+esc(String(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7))+'s endpoint</span><span class=\"rp-config-badge\">'+esc(String(s.max_turns||20))+' turns</span>'+(s.wake_word_enabled?'<span class=\"rp-config-badge ok\">wake word</span>':'')+'</div><div class=\"rp-config-actions\"><button class=\"rp-btn primary\" data-config-edit-stack=\"'+esc(s.id)+'\">Edit stack</button><button class=\"rp-btn\" data-config-use-stack=\"'+esc(s.id)+'\">Open in Voice Studio</button></div></article>'; }\n if(!shown) html='<div class=\"rp-voice-picker-empty\">'+(query?'No voice stacks match this search.':'No voice stacks yet. Create the first production pipeline.')+'</div>';\n el.innerHTML=html;\n var count=$('rp-voice-count'); if(count) count.textContent=voiceStacks.length+' stack'+(voiceStacks.length===1?'':'s');\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 query=String(($('rp-char-search')&&$('rp-char-search').value)||'').trim().toLowerCase(), html='',shown=0;\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i], hay=[p.id,p.name,p.description,p.system_prompt,p.voice_stack_id,(p.motors||[]).join(' ')].join(' ').toLowerCase(); if(query&&hay.indexOf(query)<0) continue; shown++; var avatar=p.img||rpAvatarFor(p.id||p.name); html += '<article class=\"rp-config-card\" data-cpid=\"'+esc(p.id)+'\"><div class=\"rp-config-card-head\">'+(avatar?'<img src=\"'+esc(avatar+QS)+'\" alt=\"'+esc(p.name||p.id)+'\">':'')+'<div class=\"rp-config-card-title\"><b>'+esc(p.name||p.id)+'</b><small>'+esc(p.id)+' \u00B7 '+esc(p.description||'No operator description')+'</small></div><span class=\"rp-config-badge '+(p.system_prompt?'ok':'')+'\">'+(p.system_prompt?'prompt ready':'prompt missing')+'</span></div><div class=\"rp-config-summary\">'+esc(p.system_prompt||'Add a system prompt to define this character\u2019s speaking style, constraints, and behavior.')+'</div><div class=\"rp-config-badges\"><span class=\"rp-config-badge gold\">'+esc(p.voice_stack_id||'default stack')+'</span>'+((p.motors||[]).map(function(m){return '<span class=\"rp-config-badge\">'+esc(m)+'</span>';}).join(''))+'<span class=\"rp-config-badge\">pad '+Math.round((p.nx||0)*100)+' / '+Math.round((p.ny||0)*100)+'</span></div><div class=\"rp-config-actions\"><button class=\"rp-btn primary\" data-config-edit-character=\"'+esc(p.id)+'\">Edit character</button><button class=\"rp-btn\" data-config-use-character=\"'+esc(p.id)+'\">Open in Voice Studio</button>'+(p.voice_stack_id?'<button class=\"rp-btn\" data-config-edit-stack=\"'+esc(p.voice_stack_id)+'\">Edit voice</button>':'')+'</div></article>'; }\n if(!shown) html='<div class=\"rp-voice-picker-empty\">'+(query?'No character presets match this search.':'No character presets yet. Create the first park persona.')+'</div>';\n el.innerHTML=html;\n var count=$('rp-char-count'); if(count) count.textContent=characterPresets.length+' preset'+(characterPresets.length===1?'':'s');\n }\n\n // \u2500\u2500 voice-stack edit panel \u2500\u2500\n var editingVoiceStackId=null;\n function openVoiceStackEdit(id){\n openVoiceStackManager(id); return;\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 // Stash the saved voice so refreshTtsVoiceSelect can keep it selected\n // when the catalog hasn't loaded yet. fillTtsVoiceSelect reads this\n // and includes it as a synthetic option if the catalog is missing it.\n var sel = $('rpve-tts-voice'); if(sel) sel.__savedVoice = s.tts_voice || '';\n refreshTtsVoiceSelect(true);\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-config-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id&&!e.target.closest('[data-config-use-stack]')) openVoiceStackManager(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 // TTS provider change: re-populate the voice <select> for the new provider.\n // We pass force=true so the catalog is refetched if we haven't seen this\n // provider yet; otherwise we use the cached list to repopulate instantly.\n var ttsProvEl = $('rpve-tts-provider');\n if(ttsProvEl) ttsProvEl.addEventListener('change', function(){\n var sel = $('rpve-tts-voice');\n if(sel) sel.__savedVoice = sel.value || sel.__savedVoice || '';\n refreshTtsVoiceSelect(true);\n });\n // \"Browse\u2026\" button: open the voice picker modal for the current provider.\n var ttsBrowseBtn = $('rpve-tts-voice-browse');\n if(ttsBrowseBtn) ttsBrowseBtn.addEventListener('click', function(){\n var provEl = $('rpve-tts-provider');\n var provider = (provEl && provEl.value) || 'elevenlabs';\n openTtsVoicePicker(provider, function(voiceId){\n var sel = $('rpve-tts-voice');\n if(!sel) return;\n // Make sure the chosen id exists as an option (it may have been\n // absent from the inline dropdown because the catalog hadn't loaded\n // yet). If it isn't there, append a synthetic option and select it.\n var exists = false;\n for(var i=0;i<sel.options.length;i++){ if(sel.options[i].value === voiceId){ exists=true; break; } }\n if(!exists){\n var o = document.createElement('option'); o.value=voiceId; o.textContent=voiceId+' (selected via Browse)';\n sel.appendChild(o);\n }\n sel.value = voiceId;\n sel.__savedVoice = voiceId;\n var note = $('rpve-tts-voice-note');\n if(note){ note.className='rp-autonote ok'; note.textContent='\u2713 selected '+voiceId+' from the '+provider+' catalog.'; }\n });\n });\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 openCharacterManager(id); return;\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-config-card'):null; if(!c) return; var id=c.getAttribute('data-cpid'); if(id&&!e.target.closest('[data-config-use-character],[data-config-edit-stack]')) openCharacterManager(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 var voiceSearch=$('rp-voice-search'); if(voiceSearch) voiceSearch.addEventListener('input',fillTemplateVoiceList);\n var charSearch=$('rp-char-search'); if(charSearch) charSearch.addEventListener('input',fillTemplateCharacterList);\n document.addEventListener('click',function(e){\n var stackEdit=e.target.closest&&e.target.closest('[data-config-edit-stack]'); if(stackEdit){ e.preventDefault();e.stopPropagation();openVoiceStackManager(stackEdit.getAttribute('data-config-edit-stack'));return; }\n var charEdit=e.target.closest&&e.target.closest('[data-config-edit-character]'); if(charEdit){ e.preventDefault();e.stopPropagation();openCharacterManager(charEdit.getAttribute('data-config-edit-character'));return; }\n var stackUse=e.target.closest&&e.target.closest('[data-config-use-stack]'); if(stackUse){ e.preventDefault();e.stopPropagation();setRpSubTab('voice');setTimeout(function(){var s=$('rp-call-stack-select');if(s){s.value=stackUse.getAttribute('data-config-use-stack');renderVoiceServices();}},0);return; }\n var charUse=e.target.closest&&e.target.closest('[data-config-use-character]'); if(charUse){ e.preventDefault();e.stopPropagation();setRpSubTab('voice');setTimeout(function(){var s=$('rp-call-character-select');if(s){s.value=charUse.getAttribute('data-config-use-character');renderVoiceCharacter();renderVoiceServices();}},0); }\n });\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', headers: operatorHeaders()}).then(function(r){ return r.json(); }).then(function(d){\n lastSchedulerPollAt = Date.now();\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[String(robotName(x)).toLowerCase()] = x; if(x.name) roboparkByName[String(x.name).toLowerCase()] = x; });\n // C4: also feed lastRobots for the Incidents sub-tab\n lastRobots = robotsFrom(d) || [];\n // push the same map into the Park so the canvas can color robots\n // by live/talking/waiting/ended status. updateTelemetry also\n // re-derives the readiness \u2192 status mapping for every node.\n if(Park && Park.updateTelemetry) Park.updateTelemetry(roboparkByName);\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://&lt;scheduler&gt;:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n renderFleetOperations([], 'rp-fleet', robots);\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 = rpAvatarFor(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||x.current_session_id||x.status==='connecting'||x.status==='running';\n var readiness = x.readiness_code||'unknown';\n var readinessBad = readiness!=='ready' && readiness!=='in_session';\n var readinessClass = readiness==='ready'?'ok':(readiness==='in_session'?'':'bad');\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 + ((x.character_id||x.character_preset_id)?'<button class=\"rp-btn\" data-config-edit-character=\"'+esc(x.character_id||x.character_preset_id)+'\">Edit character</button>':'')\n + (x.voice_stack_id?'<button class=\"rp-btn\" data-config-edit-stack=\"'+esc(x.voice_stack_id)+'\">Edit voice</button>':'')\n + '</div>'\n + '<div class=\"pk-note '+readinessClass+'\" style=\"margin-top:0.5rem;color:'+(readinessBad?'var(--bad)':'var(--ok)')+'\">'+esc(readiness.replace(/_/g,' '))+' \u00B7 '+esc(x.readiness_message||'no readiness detail')+'</div>'\n + (readiness==='stuck_session'||readiness==='stale_session'||readiness==='join_timeout'?'<div class=\"rp-actions\"><button class=\"rp-btn danger\" data-act=\"recover\" data-robot=\"'+idAttr+'\"'+(rpReadOnly?' disabled':'')+'>Recover robot</button></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 // Sessions & Telemetry \u2014 sub-tab control room\n // Six sub-tabs share a single pane-robopark pane:\n // Overview \u00B7 Live Sessions \u00B7 History \u00B7 Audit Log \u00B7 Fleet \u00B7 Setup\n // Each sub-tab owns a poll function + a setInterval handle; the handles\n // are cleared on switch so the browser only fetches when its pane is\n // actually visible.\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\n // \u2500\u2500 shared time / formatting helpers \u2500\u2500\n function rpFmtRelative(ts){\n if(!ts) return '\u2014';\n var t=typeof ts==='number'?ts:Date.parse(ts);\n if(!t||isNaN(t)) return '\u2014';\n var diff=Math.floor((Date.now()-t)/1000);\n if(diff<0) diff=0;\n if(diff<5) return 'just now';\n if(diff<60) return diff+'s ago';\n if(diff<3600) return Math.floor(diff/60)+'m ago';\n if(diff<86400) return Math.floor(diff/3600)+'h ago';\n return Math.floor(diff/86400)+'d ago';\n }\n function rpFmtAbs(ts){\n if(!ts) return '\u2014';\n var t=typeof ts==='number'?ts:Date.parse(ts);\n if(!t||isNaN(t)) return '\u2014';\n var d=new Date(t);\n function p(n){return (n<10?'0':'')+n;}\n return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());\n }\n function rpFmtDuration(sec){\n if(sec==null||isNaN(sec)) return '\u2014';\n sec=Math.max(0,Math.floor(sec));\n var h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;\n if(h) return h+'h '+m+'m';\n if(m) return m+'m '+s+'s';\n return s+'s';\n }\n function rpEndReasonClass(r){\n var x=String(r||'').toLowerCase();\n if(x==='operator'||x==='end_session'||x==='manual') return 'operator';\n if(x==='error'||x==='abnormal'||x==='failed') return 'error';\n if(x==='timeout'||x==='silence_timeout') return 'timeout';\n if(x==='silence') return 'silence';\n if(x==='completed'||x==='normal'||x==='done') return 'completed';\n return '';\n }\n function rpCategoryIcon(cat){\n var c=String(cat||'').toLowerCase();\n if(c==='robot') return '\uD83E\uDD16';\n if(c==='device') return '\uD83D\uDCE1';\n if(c==='session') return '\uD83C\uDF99';\n if(c==='server') return '\uD83D\uDDA5';\n if(c==='voice') return '\uD83D\uDDE3';\n if(c==='character') return '\uD83C\uDFAD';\n if(c==='preview') return '\uD83D\uDC41';\n if(c==='settings') return '\u2699';\n return '\u2726';\n }\n\n // \u2500\u2500 Sub-tab routing \u2500\u2500\n // Command sheet: generate production commands from one saved environment.\n var RP_COMMAND_ENV_KEY='rp-command-env-v1';\n var RP_CUSTOM_COMMANDS_KEY='rp-custom-commands-v1';\n var rpVisibleCommands=[];\n function rpCommandDefaults(){return {lan:'192.168.1.12',tail:'100.84.147.24',site:'Tel Aviv',robot:'bmw',enrollment:'',token:dashboardMeshToken()};}\n function rpLoadStoredJson(key,fallback){try{var value=JSON.parse(localStorage.getItem(key)||'null');return value&&typeof value==='object'?value:fallback;}catch(e){return fallback;}}\n function rpCommandEnv(){\n var d=rpCommandDefaults(),s=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});\n function value(name){var input=$('rp-command-'+name),raw=input?input.value:(s[name]!=null?s[name]:d[name]);return String(raw==null?'':raw).trim();}\n return {lan:value('lan'),tail:value('tail'),site:value('site'),robot:value('robot'),enrollment:value('enrollment'),token:value('token')};\n }\n function rpSaveCommandEnv(){try{localStorage.setItem(RP_COMMAND_ENV_KEY,JSON.stringify(rpCommandEnv()));}catch(e){}}\n function rpShellArg(value){var text=String(value||'');return /^[A-Za-z0-9._:/-]+$/.test(text)?text:'\"'+text.replace(/\"/g,'\\\"')+'\"';}\n function rpCommandCatalog(){\n var e=rpCommandEnv(),lan='http://'+e.lan,tail='http://'+e.tail,enroll=e.enrollment?' --enrollment-token '+rpShellArg(e.enrollment):'';\n return [\n {id:'hub-up',category:'Provision',title:'Start production hub and full dashboard',description:'Run elevated on livekit-1. Starts the mesh hub, full Park control center, scheduler proxy, LAN discovery, supervision, and updates.',command:'infinicode serve --hub --name livekit-1 --port 47913 --token '+rpShellArg(e.token)+' --lan --dashboard --scheduler-url http://127.0.0.1:8080 --auto-update --supervised'},\n {id:'robot-lan',category:'Provision',title:'Provision LAN robot',description:'One command for a Linux robot on the hub LAN. Installs the supervised mesh, RoboVision, and preview runtime.',command:'sudo robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+lan+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --lan --start --auto-start --yes'},\n {id:'robot-tail',category:'Provision',title:'Provision Tailscale robot',description:'One command for a Windows or Linux robot reaching the hub through Tailscale.',command:'robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+tail+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --tailscale --start --auto-start --yes'},\n {id:'operator-tail',category:'Provision',title:'Connect operator MCP over Tailscale',description:'Connects an operator workstation and coding agents to the production mesh.',command:'infinicode mcp --lan --tailscale --seed '+tail+':47913 --name dev-laptop --token '+rpShellArg(e.token)+' --mesh-port 47913'},\n {id:'hub-ui',category:'Health',title:'Open full production control center',description:'Opens the full Park dashboard through the hub Tailscale address.',command:'Start-Process \"'+tail+':47913/?token='+encodeURIComponent(e.token)+'#park\"'},\n {id:'hub-build',category:'Health',title:'Verify current hub UI',description:'Shows the installed version and dashboard build marker after npm update or restart.',command:'infinicode --version; Invoke-RestMethod \"http://127.0.0.1:47913/fed/ui-build?token='+encodeURIComponent(e.token)+'\"'},\n {id:'hub-ports',category:'Health',title:'Inspect hub ports on Windows',description:'Lists processes owning the control center, scheduler, and LiveKit ports without changing them.',command:'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -in 47913,8080,7880,7881,7882,7883 } | Sort-Object LocalPort | Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize'},\n {id:'scheduler-health',category:'Health',title:'Check scheduler through hub',description:'Confirms the hub proxy can reach the scheduler and return robot inventory.',command:'Invoke-RestMethod \"'+tail+':47913/robopark/api/robots?token='+encodeURIComponent(e.token)+'\" | ConvertTo-Json -Depth 6'},\n {id:'mesh-status',category:'Health',title:'Check mesh nodes',description:'Lists connected hub, robot, and operator nodes.',command:'Invoke-RestMethod \"'+tail+':47913/fed/status?token='+encodeURIComponent(e.token)+'\" | ConvertTo-Json -Depth 6'},\n {id:'pi-runtime',category:'Health',title:'Follow robot runtime logs',description:'Live systemd logs for the selected robot unified runtime.',command:'sudo journalctl -u robopark-robot-runtime-'+e.robot+'.service -f'},\n {id:'pi-ports',category:'Health',title:'Inspect robot service ports',description:'Checks mesh, camera, motion webhook, and audio listeners on a Linux robot.',command:\"sudo ss -ltnp | grep -E ':(47913|5000|5057|8000)'\"},\n {id:'camera-inventory',category:'Media',title:'Read RoboVision media inventory',description:'Returns the real cameras, microphones, and speakers detected by RoboVision.',command:'curl -s http://127.0.0.1:5000/api/media/inventory'},\n {id:'audio-inventory',category:'Media',title:'Read RoboVision audio devices',description:'Returns the microphone and speaker inventory used by the robot audio server.',command:'curl -s http://127.0.0.1:8000/devices'},\n {id:'camera-devices',category:'Media',title:'List valid Linux capture devices',description:'Shows V4L2 devices and capabilities so metadata-only nodes are not selected as cameras.',command:'v4l2-ctl --list-devices; for d in /dev/video*; do echo \"--- $d\"; v4l2-ctl -d \"$d\" --all 2>/dev/null | grep -E \"Card type|Video Capture|Device Caps\"; done'},\n {id:'camera-preview',category:'Media',title:'Open direct RoboVision camera feed',description:'Opens the robot MJPEG feed locally for camera-only diagnosis.',command:'xdg-open http://127.0.0.1:5000/video_feed'},\n {id:'robot-restart',category:'Recovery',title:'Clean restart selected robot',description:'Stops stale supervised processes, then recreates the complete LAN runtime.',command:'sudo robopark stop && sudo robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+lan+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --lan --start --auto-start --yes'},\n {id:'robot-status',category:'Recovery',title:'Inspect robot service failure',description:'Shows service state and the latest 150 log lines after a failed start.',command:'sudo systemctl status robopark-robot-runtime-'+e.robot+'.service --no-pager; sudo journalctl -u robopark-robot-runtime-'+e.robot+'.service -n 150 --no-pager'},\n {id:'docker-health',category:'Health',title:'Inspect Docker and LiveKit containers',description:'Shows container state, health, ports, and names on the hub.',command:'docker ps -a --format \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Image}}\"'},\n {id:'docker-restart',category:'Recovery',title:'Restart Docker Desktop service',description:'Run from elevated PowerShell only when Docker Desktop is stuck starting.',command:'Restart-Service com.docker.service; Start-Process -FilePath \"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe\"'},\n {id:'tailscale-status',category:'Health',title:'Check Tailscale routes and Serve',description:'Confirms the node is connected and shows any active Serve proxy.',command:'tailscale status; tailscale serve status'},\n {id:'npm-version',category:'Packages',title:'Check installed and published versions',description:'Compares this device with the npm latest tag.',command:'infinicode --version; npm view infinicode version'},\n {id:'npm-update',category:'Packages',title:'Update InfiniCode and RoboPark',description:'Installs the current published production package globally.',command:'npm install -g infinicode@latest --force'},\n {id:'npm-cache',category:'Packages',title:'Repair npm metadata cache',description:'Use when a new version returns ETARGET. Verify metadata before clearing anything.',command:'npm cache verify; npm view infinicode dist-tags --json'}\n ];\n }\n function rpCustomCommands(){var list=rpLoadStoredJson(RP_CUSTOM_COMMANDS_KEY,[]);return Array.isArray(list)?list:[];}\n function rpSaveCustomCommands(list){try{localStorage.setItem(RP_CUSTOM_COMMANDS_KEY,JSON.stringify(list));}catch(e){showOut('Could not save custom command: '+e.message);}}\n function rpLegacyCopy(value){var area=document.createElement('textarea');area.value=value;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();try{document.execCommand('copy');showOut('command copied');}catch(e){showOut('Copy failed: select the command manually');}area.remove();}\n function rpCopyCommand(text){var value=String(text||'');if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(value).then(function(){showOut('command copied');}).catch(function(){rpLegacyCopy(value);});}else rpLegacyCopy(value);}\n function renderCommandSheet(){\n var grid=$('rp-command-grid');if(!grid)return;\n var search=String(($('rp-command-search')||{}).value||'').toLowerCase().trim(),category=String(($('rp-command-category')||{}).value||'').toLowerCase();\n var custom=rpCustomCommands().map(function(x){return {id:String(x.id),category:String(x.category||'Custom'),title:String(x.title||'Custom command'),description:String(x.description||''),command:String(x.command||''),custom:true};});\n rpVisibleCommands=rpCommandCatalog().concat(custom).filter(function(x){if(category&&x.category.toLowerCase()!==category)return false;return !search||(x.title+' '+x.category+' '+x.description+' '+x.command).toLowerCase().indexOf(search)>=0;});\n var count=$('rp-command-count');if(count)count.textContent=rpVisibleCommands.length+' command'+(rpVisibleCommands.length===1?'':'s');\n if(!rpVisibleCommands.length){grid.innerHTML='<div class=\"rp-command-empty\">No commands match this filter.</div>';return;}\n grid.innerHTML=rpVisibleCommands.map(function(x){return '<article class=\"rp-command-card\"><div class=\"rp-command-head\"><b>'+esc(x.title)+'</b><span class=\"rp-command-tag\">'+esc(x.category)+'</span></div><p>'+esc(x.description)+'</p><pre class=\"rp-command-code\"><code>'+esc(x.command)+'</code></pre><div class=\"rp-command-actions\"><button class=\"rp-btn primary\" data-command-copy=\"'+esc(x.id)+'\">Copy command</button>'+(x.custom?'<button class=\"rp-btn danger\" data-command-delete=\"'+esc(x.id)+'\">Delete</button>':'')+'</div></article>';}).join('');\n }\n function initCommandSheet(){\n var fields=['lan','tail','site','robot','enrollment','token'],defaults=rpCommandDefaults(),saved=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});\n fields.forEach(function(name){var input=$('rp-command-'+name);if(!input)return;input.value=name==='token'&&TOKEN?TOKEN:(saved[name]!=null?saved[name]:defaults[name]);input.addEventListener('input',function(){rpSaveCommandEnv();renderCommandSheet();});});\n var search=$('rp-command-search'),category=$('rp-command-category'),grid=$('rp-command-grid'),reset=$('rp-command-reset'),copyAll=$('rp-command-copy-all'),form=$('rp-command-custom-form');\n if(search)search.addEventListener('input',renderCommandSheet);if(category)category.addEventListener('change',renderCommandSheet);\n if(reset)reset.addEventListener('click',function(){try{localStorage.removeItem(RP_COMMAND_ENV_KEY);}catch(e){}fields.forEach(function(name){var input=$('rp-command-'+name);if(input)input.value=defaults[name];});renderCommandSheet();showOut('command environment reset');});\n if(copyAll)copyAll.addEventListener('click',function(){rpCopyCommand(rpVisibleCommands.map(function(x){return '# '+x.title+'\\n'+x.command;}).join('\\n\\n'));});\n if(grid)grid.addEventListener('click',function(ev){var copy=ev.target.closest('[data-command-copy]'),del=ev.target.closest('[data-command-delete]');if(copy){var id=copy.getAttribute('data-command-copy'),item=rpVisibleCommands.find(function(x){return x.id===id;});if(item)rpCopyCommand(item.command);}if(del){var removeId=del.getAttribute('data-command-delete');rpSaveCustomCommands(rpCustomCommands().filter(function(x){return String(x.id)!==removeId;}));renderCommandSheet();showOut('custom command deleted');}});\n if(form)form.addEventListener('submit',function(ev){ev.preventDefault();var title=$('rp-command-custom-title').value.trim(),command=$('rp-command-custom-command').value.trim();if(!title||!command)return;var list=rpCustomCommands();list.push({id:'custom-'+Date.now(),title:title,category:$('rp-command-custom-category').value.trim()||'Custom',description:$('rp-command-custom-description').value.trim(),command:command});rpSaveCustomCommands(list);$('rp-command-custom-title').value='';$('rp-command-custom-description').value='';$('rp-command-custom-command').value='';renderCommandSheet();showOut('custom command saved in this browser');});\n renderCommandSheet();\n }\n initCommandSheet();\n\n var rpSubTab = 'overview';\n var rpIntervals = {}; // sub-tab name -> setInterval handle\n var rpHistoryAll = []; // full recent-session list (we paginate client-side)\n function setRpSubTab(name){\n if(!name) return;\n rpSubTab = name;\n var btns = document.querySelectorAll('.rp-subtab');\n for(var i=0;i<btns.length;i++){\n var b=btns[i], sub=b.getAttribute('data-sub');\n var isActive = sub===name;\n b.classList.toggle('active', isActive);\n b.setAttribute('aria-selected', isActive?'true':'false');\n }\n var subs = document.querySelectorAll('.rp-sub');\n for(var j=0;j<subs.length;j++){\n var s=subs[j];\n s.classList.toggle('active', s.id==='rp-sub-'+name);\n }\n // stop every sub-tab interval, then start only the active one\n var names = ['overview','incidents','live','voice','history','audit','fleet','config','commands','setup'];\n for(var k=0;k<names.length;k++){\n var n=names[k];\n if(rpIntervals[n]){ clearInterval(rpIntervals[n]); rpIntervals[n]=null; }\n }\n if(name==='overview'){ pollOverview(); rpIntervals.overview = setInterval(pollOverview, 4000); }\n if(name==='incidents'){ pollIncidents(); rpIntervals.incidents = setInterval(pollIncidents, 4000); }\n if(name==='live'){ pollLiveSessions(); startLiveAutoRefresh(); }\n if(name==='voice'){ refreshVoiceCallOptions(); }\n if(name==='history'){ rpHistoryPage=0; pollHistory(); }\n if(name==='audit'){ pollAuditLog(); rpIntervals.audit = setInterval(pollAuditLog, 6000); }\n if(name==='fleet'){ pollFleet(); rpIntervals.fleet = setInterval(pollFleet, 5000); }\n if(name==='commands'){ renderCommandSheet(); }\n if(name==='setup'){ pollSettings(); loadCharacterPresets(); loadVoiceStacks(); pollRuntimeHealth(); }\n // remember last sub-tab in URL hash\n try{ history.replaceState(null,'','#robopark/'+name); }catch(e){}\n }\n function buildRpSubTabs(){\n var btns = document.querySelectorAll('.rp-subtab');\n for(var i=0;i<btns.length;i++){\n (function(btn){\n btn.addEventListener('click', function(){ setRpSubTab(btn.getAttribute('data-sub')); });\n })(btns[i]);\n }\n // restore from hash like #robopark/history\n var h=String(location.hash||'').replace('#','');\n var m = h.match(/^robopark\\/(.+)$/);\n var initial = (m && m[1]) ? m[1] : 'overview';\n if(['overview','incidents','live','voice','history','audit','fleet','config','commands','setup'].indexOf(initial)<0) initial='overview';\n setRpSubTab(initial);\n }\n\n // \u2500\u2500 Modal helper \u2500\u2500\n function showModal(title, bodyHtml){\n var back = $('rp-modal-back');\n if(!back) return;\n $('rp-modal-title').textContent = title || 'Details';\n $('rp-modal-body').innerHTML = bodyHtml || '';\n back.classList.add('open');\n }\n function closeModal(){\n var back = $('rp-modal-back');\n if(!back) return;\n back.classList.remove('open');\n var modal=$('rp-modal'); if(modal) modal.classList.remove('config-modal');\n $('rp-modal-body').innerHTML = '';\n }\n // wire the close handlers (once)\n (function(){\n var closeBtn = $('rp-modal-close');\n if(closeBtn) closeBtn.addEventListener('click', closeModal);\n var back = $('rp-modal-back');\n if(back) back.addEventListener('click', function(e){ if(e.target===back) closeModal(); });\n document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeModal(); });\n })();\n\n // \u2500\u2500 TTS voice picker modal \u2500\u2500\n // A robust, searchable, debounced-fetched picker for any TTS provider that\n // exposes /api/voices/<provider> on the scheduler. Designed specifically\n // for ElevenLabs (hundreds of voices, needs search + loading + error\n // states) but works for any provider \u2014 Kokoro, Piper, etc.\n //\n // Usage: openTtsVoicePicker('elevenlabs', function(voiceId){ ... });\n // The callback receives the picked voice_id and the modal closes itself.\n // If the user dismisses the modal (Esc, backdrop, \u2715) the callback is NOT\n // called. The catalog is cached in ttsVoiceCache[provider] and re-used\n // on subsequent opens; a manual \"Refresh\" button forces a refetch.\n var ttsPickerState = {\n provider: null,\n voices: [],\n filter: '',\n loading: false,\n error: null,\n onPick: null,\n };\n function openTtsVoicePicker(provider, onPick){\n ttsPickerState.provider = provider || 'elevenlabs';\n ttsPickerState.onPick = (typeof onPick === 'function') ? onPick : null;\n ttsPickerState.filter = '';\n showModal('Browse '+ttsPickerState.provider+' voices', buildTtsPickerBody());\n // Focus the search box on the next paint so the modal animation has\n // started and the input is visible.\n setTimeout(function(){\n var inp = $('rp-voice-picker-search');\n if(inp){ try{ inp.focus(); inp.select && inp.select(); }catch(e){} }\n }, 30);\n // Re-render after the catalog is loaded (may already be cached).\n renderTtsPicker();\n loadTtsVoiceCatalog(ttsPickerState.provider, {force:false}).then(function(){\n // The modal may have been closed while the fetch was in flight.\n if(ttsPickerState.provider !== provider) return;\n renderTtsPicker();\n });\n }\n function buildTtsPickerBody(){\n // Toolbar (search + refresh) + scrollable results area. The two\n // containers (#rp-voice-picker-results + #rp-voice-picker-status) are\n // re-rendered in place by renderTtsPicker(), so opening the modal\n // repeatedly is cheap and we don't leak listeners.\n return ''\n + '<div class=\"rp-voice-picker\">'\n + '<div class=\"rp-voice-picker-toolbar\">'\n + '<input type=\"search\" id=\"rp-voice-picker-search\" placeholder=\"Search by name, id, language, or category\u2026\" autocomplete=\"off\" spellcheck=\"false\">'\n + '<button type=\"button\" class=\"rp-btn\" id=\"rp-voice-picker-refresh\" title=\"Refetch the voice catalog from the scheduler\">\u21BB Refresh</button>'\n + '</div>'\n + '<div class=\"rp-voice-picker-status\" id=\"rp-voice-picker-status\"></div>'\n + '<div class=\"rp-voice-picker-results\" id=\"rp-voice-picker-results\"></div>'\n + '<div class=\"rp-voice-picker-foot\">'\n + '<span class=\"rp-muted\">Click a voice to select it \u00B7 Esc to cancel</span>'\n + '</div>'\n + '</div>';\n }\n function renderTtsPicker(){\n var status = $('rp-voice-picker-status');\n var results = $('rp-voice-picker-results');\n if(!status || !results) return; // modal was closed\n var cache = ttsVoiceCache[ttsPickerState.provider];\n var voices = (cache && Array.isArray(cache.voices)) ? cache.voices : [];\n var warning = cache && cache.warning;\n var source = cache && cache.source;\n\n // \u2500\u2500 status row (loading / error / counts / warning) \u2500\u2500\n if(ttsPickerState.loading && !cache){\n status.className = 'rp-voice-picker-status busy';\n status.innerHTML = '<span class=\"rp-spinner\"></span> Loading '+esc(ttsPickerState.provider)+' voice catalog\u2026';\n results.innerHTML = '';\n return;\n }\n if(!cache){\n status.className = 'rp-voice-picker-status bad';\n status.innerHTML = '\u26A0 No catalog data for '+esc(ttsPickerState.provider)+'. Click Refresh to retry.';\n results.innerHTML = '';\n return;\n }\n if(warning && !voices.length){\n status.className = 'rp-voice-picker-status bad';\n status.innerHTML = '\u26A0 '+esc(warning);\n results.innerHTML = '';\n return;\n }\n var bits = [];\n bits.push('<b>'+voices.length+'</b> '+esc(ttsPickerState.provider)+' voice'+(voices.length===1?'':'s'));\n if(source) bits.push('\u00B7 source: <span class=\"rp-mono\">'+esc(source)+'</span>');\n if(warning) bits.push('<span style=\"color:var(--bad)\">\u26A0 '+esc(warning)+'</span>');\n status.className = warning ? 'rp-voice-picker-status warn' : 'rp-voice-picker-status';\n status.innerHTML = bits.join(' ');\n\n // \u2500\u2500 filtered results \u2500\u2500\n var q = String(ttsPickerState.filter || '').toLowerCase().trim();\n var filtered = voices;\n if(q){\n filtered = voices.filter(function(v){\n var blob = [\n v.id, v.name, v.language, v.gender, v.category, v.description\n ].map(function(x){return x==null?'':String(x);}).join(' ').toLowerCase();\n return blob.indexOf(q) >= 0;\n });\n }\n if(!filtered.length){\n results.innerHTML = '<div class=\"rp-voice-picker-empty\">'\n + (q ? ('No voices match \u201C'+esc(q)+'\u201D.')\n : ('No voices available for this provider.'))\n + '</div>';\n return;\n }\n // Group by category for a tidy scannable list. Providers that don't\n // set a category (Kokoro static list, etc.) all land in \"Other\".\n var groups = {};\n for(var i=0;i<filtered.length;i++){\n var v = filtered[i];\n var key = v.category || (v.language ? ('Language: '+v.language) : 'Other');\n if(!groups[key]) groups[key] = [];\n groups[key].push(v);\n }\n var groupNames = Object.keys(groups).sort(function(a,b){\n if(a==='Other') return 1;\n if(b==='Other') return -1;\n return a.localeCompare(b);\n });\n var html = '';\n for(var g=0;g<groupNames.length;g++){\n var name = groupNames[g];\n html += '<div class=\"rp-vp-group\"><div class=\"rp-vp-group-head\">'+esc(name)+' \u00B7 '+groups[name].length+'</div>';\n for(var j=0;j<groups[name].length;j++){\n var voice = groups[name][j];\n var id = voice.id || voice.voice_id || '';\n if(!id) continue;\n var meta = [];\n if(voice.language) meta.push(voice.language);\n if(voice.gender) meta.push(voice.gender);\n var sub = meta.length ? ('<span class=\"rp-vp-meta\">'+esc(meta.join(' \u00B7 '))+'</span>') : '';\n var desc = voice.description ? ('<div class=\"rp-vp-desc\">'+esc(voice.description)+'</div>') : '';\n html += '<button type=\"button\" class=\"rp-vp-item\" data-voice-id=\"'+esc(id)+'\">'\n + '<div class=\"rp-vp-row1\"><span class=\"rp-vp-name\">'+esc(voice.name||id)+'</span>'+sub+'</div>'\n + '<div class=\"rp-vp-id\">'+esc(id)+'</div>'\n + desc\n + '</button>';\n }\n html += '</div>';\n }\n results.innerHTML = html;\n }\n // Click delegation for picker items + the refresh button + the search\n // input. Bound once on the document so re-opening the modal doesn't\n // stack listeners.\n document.addEventListener('click', function(e){\n var btn = e.target.closest && e.target.closest('.rp-vp-item');\n if(btn){\n var vid = btn.getAttribute('data-voice-id');\n closeModal();\n if(vid && ttsPickerState.onPick){ ttsPickerState.onPick(vid); }\n return;\n }\n var r = e.target.closest && e.target.closest('#rp-voice-picker-refresh');\n if(r){\n // Re-fetch the catalog, ignore cache, then re-render.\n loadTtsVoiceCatalog(ttsPickerState.provider, {force:true}).then(function(){ renderTtsPicker(); });\n return;\n }\n });\n document.addEventListener('input', function(e){\n if(e.target && e.target.id === 'rp-voice-picker-search'){\n ttsPickerState.filter = e.target.value;\n renderTtsPicker();\n }\n });\n\n // \u2500\u2500 Live Sessions sub-tab \u2500\u2500\n var rpLiveAuto = true;\n var rpLiveClockTimer = null;\n function startLiveAutoRefresh(){\n if(rpIntervals.live){ clearInterval(rpIntervals.live); rpIntervals.live=null; }\n if(rpLiveClockTimer){ clearInterval(rpLiveClockTimer); rpLiveClockTimer=null; }\n if(rpLiveAuto){\n rpIntervals.live = setInterval(pollLiveSessions, 3000);\n rpLiveClockTimer = setInterval(updateLiveDurations, 1000);\n }\n }\n function updateLiveDurations(){\n var tbody = $('rp-live-tbody'); if(!tbody) return;\n var cells = tbody.querySelectorAll('td[data-started]');\n for(var i=0;i<cells.length;i++){\n var t = parseInt(cells[i].getAttribute('data-started'),10);\n if(!t) continue;\n cells[i].textContent = rpFmtDuration(Math.floor((Date.now()-t)/1000));\n }\n }\n function pollLiveSessions(){\n getJson('/robopark/api/sessions?active_only=true').then(function(rows){\n renderLiveSessions(Array.isArray(rows)?rows:[]);\n }).catch(function(e){ /* soft fail */ });\n }\n function renderLiveSessions(rows){\n var tbody = $('rp-live-tbody'), empty = $('rp-live-empty'), wrap = $('rp-live-wrap');\n var searchEl=$('rp-live-search'), robotEl=$('rp-live-robot');\n var search = searchEl ? String(searchEl.value||'').toLowerCase() : '';\n var robotF = robotEl ? String(robotEl.value||'') : '';\n // refresh the robot dropdown whenever the set changes\n if(robotEl){\n var known={}; for(var i=0;i<rows.length;i++){ var rid=String(rows[i].robot_id||''); if(rid) known[rid]=1; }\n var present = robotEl.querySelectorAll('option');\n var have={};\n for(var j=1;j<present.length;j++){ have[present[j].value]=1; if(!known[present[j].value]){ robotEl.removeChild(present[j]); } }\n for(var rid in known){ if(!have[rid]){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; robotEl.appendChild(o); } }\n }\n var filtered = rows.filter(function(s){\n if(robotF && String(s.robot_id||'')!==robotF) return false;\n if(search){\n var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();\n if(blob.indexOf(search)<0) return false;\n }\n return true;\n });\n if(!filtered.length){ tbody.innerHTML=''; wrap.style.display='none'; empty.style.display='block'; }\n else { empty.style.display='none'; wrap.style.display='block'; }\n var html='';\n for(var i=0;i<filtered.length;i++){\n var s = filtered[i];\n var rid = s.robot_id || '';\n var sid = s.session_id || '';\n var started = s.started_at ? Date.parse(s.started_at) : null;\n var elapsed = started ? Math.floor((Date.now()-started)/1000) : null;\n html += '<tr>'\n + '<td><b>'+esc(rid)+'</b></td>'\n + '<td><span class=\"sid\" title=\"'+esc(sid)+'\">'+esc(sid)+'</span></td>'\n + '<td><span class=\"t-rel\"><span class=\"live-dot\"></span>'+esc(rpFmtRelative(started))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(started))+'</span></td>'\n + '<td data-started=\"'+started+'\">'+esc(rpFmtDuration(elapsed))+'</td>'\n + '<td><span class=\"rp-end-badge active\">active</span></td>'\n + '<td style=\"display:flex;gap:0.3rem;\">'\n + '<button class=\"rp-btn\" data-act=\"open-robot\" data-robot=\"'+esc(rid)+'\">Open robot</button>'\n + '<button class=\"rp-btn danger\" data-act=\"end-session\" data-sid=\"'+esc(sid)+'\" data-robot=\"'+esc(rid)+'\">End</button>'\n + '</td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n // update the tab badge\n var badge = $('rps-live-badge'); if(badge) badge.textContent = filtered.length;\n }\n function endSessionFromTable(sessionId, robotId){\n if(!confirm('End session '+sessionId+' for '+robotId+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+robotId);\n pollLiveSessions();\n pollRobopark();\n pollRuntimeHealth();\n }).catch(function(e){ showOut('end-session: '+e.message); });\n }\n\n // wire live tab interactions\n (function(){\n var s=$('rp-live-search'); if(s) s.addEventListener('input', function(){ pollLiveSessions(); });\n var r=$('rp-live-robot'); if(r) r.addEventListener('change', function(){ pollLiveSessions(); });\n var a=$('rp-live-autoref'); if(a) a.addEventListener('change', function(){ rpLiveAuto = !!a.checked; startLiveAutoRefresh(); });\n var rb=$('rp-live-refresh'); if(rb) rb.addEventListener('click', pollLiveSessions);\n var tb=$('rp-live-tbody'); if(tb) tb.addEventListener('click', function(e){\n var b = e.target.closest ? e.target.closest('.rp-btn') : null;\n if(!b) return;\n var act = b.getAttribute('data-act');\n if(act==='end-session'){ endSessionFromTable(b.getAttribute('data-sid'), b.getAttribute('data-robot')); }\n if(act==='open-robot'){ var rid=b.getAttribute('data-robot'); if(rid){ setRpSubTab('fleet'); } }\n });\n })();\n\n // \u2500\u2500 History sub-tab \u2500\u2500\n var rpVoiceCall={room:null,LK:null,session:null,eventToken:null,muted:false,camera:false,screen:false,ending:false,seen:{},messages:0,timer:null,startedAt:0,audioContext:null,analyser:null,audioRaf:0,transcriptCollapsed:false};\n function refreshVoiceCallOptions(){\n var cs=$('rp-call-character-select'), ss=$('rp-call-stack-select');\n if(!cs||!ss) return;\n var cv=cs.value, sv=ss.value;\n cs.innerHTML='<option value=\"\">select character...</option>'+(characterPresets||[]).map(function(c){return '<option value=\"'+esc(c.id)+'\">'+esc(c.name||c.id)+'</option>';}).join('');\n ss.innerHTML='<option value=\"\">character default</option>'+(voiceStacks||[]).map(function(s){return '<option value=\"'+esc(s.id)+'\">'+esc(s.name||s.id)+' - '+esc(s.stt_provider)+' / '+esc(s.llm_model)+' / '+esc(s.tts_provider)+'</option>';}).join('');\n if(cv) cs.value=cv; if(sv) ss.value=sv;\n paintVoiceCallCharacter(); paintVoiceCallStack();\n }\n function paintVoiceCallCharacter(){\n var sel=$('rp-call-character-select'), box=$('rp-call-character'); if(!sel||!box) return;\n var chosen=null; for(var i=0;i<characterPresets.length;i++) if(characterPresets[i].id===sel.value){chosen=characterPresets[i];break;}\n if(!chosen){ box.innerHTML='<div><div class=\"name\">Choose a character</div><div class=\"meta\">Preset and voice stack are frozen into session history.</div></div>'; $('rp-call-avatar').removeAttribute('src'); $('rp-call-avatar-fallback').textContent='RP'; return; }\n var img=chosen.img||rpAvatarFor(chosen.id||chosen.name);\n box.innerHTML=(img?'<img alt=\"'+esc(chosen.name)+'\" src=\"'+esc(img+QS)+'\">':'')+'<div><div class=\"name\">'+esc(chosen.name||chosen.id)+'</div><div class=\"meta\">'+esc(chosen.description||'Ready for an operator voice call')+'</div></div>';\n $('rp-call-avatar-fallback').textContent=String(chosen.name||chosen.id||'RP').slice(0,2).toUpperCase();\n if(img)$('rp-call-avatar').src=img+QS;else $('rp-call-avatar').removeAttribute('src');\n var stack=$('rp-call-stack-select'); if(stack && !stack.value && chosen.voice_stack_id) stack.value=chosen.voice_stack_id;\n paintVoiceCallStack();\n }\n function selectedVoiceStack(){\n var sid=$('rp-call-stack-select').value, chosen=null;\n if(!sid){var cid=$('rp-call-character-select').value;for(var i=0;i<characterPresets.length;i++)if(characterPresets[i].id===cid){sid=characterPresets[i].voice_stack_id||'';break;}}\n for(var j=0;j<voiceStacks.length;j++)if(voiceStacks[j].id===sid){chosen=voiceStacks[j];break;} return chosen;\n }\n function paintVoiceCallStack(){\n var wrap=$('rp-call-services'); if(!wrap)return; var s=selectedVoiceStack();\n if(!s){wrap.innerHTML='<div class=\"rp-call-service\"><span class=\"ico\">STT</span><div><b>Speech recognition</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div><div class=\"rp-call-service\"><span class=\"ico\">LLM</span><div><b>Language model</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div><div class=\"rp-call-service\"><span class=\"ico\">TTS</span><div><b>Voice synthesis</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>';return;}\n function svc(code,name,provider,model){return '<div class=\"rp-call-service\"><span class=\"ico\">'+code+'</span><div><b>'+esc(name)+'</b><small>'+esc((provider||'default')+' / '+(model||'default'))+'</small></div><span class=\"ready\">ready</span></div>';}\n wrap.innerHTML=svc('STT','Speech recognition',s.stt_provider,s.stt_model)+svc('LLM','Language model',s.llm_provider,s.llm_model)+svc('TTS','Voice synthesis',s.tts_provider,s.tts_voice);\n }\n function setVoiceCallStatus(text,state){\n var s=$('rp-call-status'), o=$('rp-call-orb'); if(s)s.textContent=text; if(o)o.className='rp-call-orb '+(state||'');\n }\n function setVoiceAgentState(title,detail){if($('rp-call-agent-state'))$('rp-call-agent-state').textContent=title;if($('rp-call-agent-detail'))$('rp-call-agent-detail').textContent=detail||'';}\n function setVoiceEvent(title,detail){var e=$('rp-call-event');if(e)e.innerHTML='<b>'+esc(title)+'</b> '+esc(detail||'');}\n function ensureVoiceLiveKit(){\n if(window.LivekitClient) return Promise.resolve(window.LivekitClient);\n return new Promise(function(resolve,reject){\n var old=document.querySelector('script[data-rp-voice-livekit]');\n if(old){ old.addEventListener('load',function(){resolve(window.LivekitClient);}); old.addEventListener('error',reject); return; }\n var script=document.createElement('script'); script.dataset.rpVoiceLivekit='1'; script.src='/vendor/livekit-client.umd.js'+QS;\n script.onload=function(){resolve(window.LivekitClient);}; script.onerror=function(){reject(new Error('LiveKit browser library failed to load'));}; document.head.appendChild(script);\n });\n }\n function appendVoiceTurn(role,text){\n var wrap=$('rp-call-transcript'); if(!wrap)return;\n if(wrap.querySelector('.rp-tbl-empty')) wrap.innerHTML='';\n var div=document.createElement('div'); div.className='rp-turn '+role;\n div.innerHTML='<div class=\"who\">'+esc(role==='user'?'operator':'character')+'</div><div>'+esc(text)+'</div>';\n wrap.appendChild(div); wrap.scrollTop=wrap.scrollHeight;\n rpVoiceCall.messages++; if($('rp-call-message-count'))$('rp-call-message-count').textContent=rpVoiceCall.messages+' message'+(rpVoiceCall.messages===1?'':'s');\n }\n var VOICE_DEVICE_PREFS_KEY='rp-voice-device-prefs-v1';\n function voiceDevicePrefs(){try{return JSON.parse(localStorage.getItem(VOICE_DEVICE_PREFS_KEY)||'{}')||{};}catch(_){return {};}}\n function saveVoiceDevicePrefs(){try{localStorage.setItem(VOICE_DEVICE_PREFS_KEY,JSON.stringify({audioinput:$('rp-call-mic-device').value||'',videoinput:$('rp-call-cam-device').value||'',audiooutput:$('rp-call-speaker-device').value||''}));}catch(_){}}\n function voiceDeviceMessage(text,state){var note=$('rp-call-device-note');if(note){note.textContent=text;note.className='rp-call-device-note '+(state||'');}}\n function voiceMediaSupportError(){\n if(!window.isSecureContext && location.hostname!=='localhost' && location.hostname!=='127.0.0.1') return 'Browser microphone access is blocked on insecure HTTP. Open this control center over HTTPS (or localhost), then scan again.';\n if(!navigator.mediaDevices) return 'This browser does not expose media devices. Use current Edge or Chrome over HTTPS.';\n if(!navigator.mediaDevices.getUserMedia) return 'Microphone capture is unavailable in this browser context.';\n return '';\n }\n function fillDeviceSelect(id,devices,kind){\n var sel=$(id);if(!sel)return 0;var prefs=voiceDevicePrefs(),current=sel.value||prefs[kind]||'',rows=devices.filter(function(d){return d.kind===kind;});\n var fallback=kind==='audioinput'?'System default microphone':kind==='videoinput'?'System default camera':'System default speaker';\n sel.innerHTML='<option value=\"\">'+fallback+'</option>'+rows.filter(function(d){return d.deviceId&&d.deviceId!=='default';}).map(function(d,i){var label=d.label||((kind==='audioinput'?'Microphone ':kind==='videoinput'?'Camera ':'Speaker ')+(i+1));return '<option value=\"'+esc(d.deviceId)+'\">'+esc(label)+'</option>';}).join('');\n if(current){sel.value=current;if(sel.value!==current)sel.value='';} return rows.length;\n }\n function refreshBrowserDevices(requestPermission,includeCamera){\n var unsupported=voiceMediaSupportError();\n if(unsupported){$('rp-call-device-status').textContent='blocked';voiceDeviceMessage(unsupported,'bad');return requestPermission?Promise.reject(new Error(unsupported)):Promise.resolve([]);}\n $('rp-call-device-status').textContent=requestPermission?'requesting access\u2026':'scanning\u2026';\n var permission=Promise.resolve();\n if(requestPermission){\n var selected=$('rp-call-mic-device').value, audio=selected?{deviceId:{exact:selected}}:true;\n permission=navigator.mediaDevices.getUserMedia({audio:audio}).then(function(stream){stream.getTracks().forEach(function(t){t.stop();});if(includeCamera)return navigator.mediaDevices.getUserMedia({video:true}).then(function(cam){cam.getTracks().forEach(function(t){t.stop();});}).catch(function(){return null;});});\n }\n return permission.then(function(){return navigator.mediaDevices.enumerateDevices();}).then(function(devices){\n var mic=fillDeviceSelect('rp-call-mic-device',devices,'audioinput'),cam=fillDeviceSelect('rp-call-cam-device',devices,'videoinput'),speaker=fillDeviceSelect('rp-call-speaker-device',devices,'audiooutput');\n saveVoiceDevicePrefs();$('rp-call-device-status').textContent=mic+' mic \u00B7 '+cam+' cam \u00B7 '+speaker+' out';\n voiceDeviceMessage('Ready: '+mic+' microphone'+(mic===1?'':'s')+', '+cam+' camera'+(cam===1?'':'s')+', and '+speaker+' speaker output'+(speaker===1?'':'s')+' detected. Device choices are saved in this browser.','ok');return devices;\n }).catch(function(e){var message=e&&e.name==='NotAllowedError'?'Media permission was denied. Allow microphone access in the browser address bar and scan again.':(e&&e.message||'Media device scan failed');$('rp-call-device-status').textContent='permission failed';voiceDeviceMessage(message,'bad');setVoiceEvent('Browser media',message);throw new Error(message);});\n }\n function switchVoiceDevice(kind,id){saveVoiceDevicePrefs();if(!rpVoiceCall.room||!id)return Promise.resolve();return rpVoiceCall.room.switchActiveDevice(kind,id).catch(function(e){setVoiceEvent('Device switch failed',e.message);voiceDeviceMessage('Could not switch device: '+e.message,'bad');});}\n function applyVoiceOutputDevice(){saveVoiceDevicePrefs();var id=$('rp-call-speaker-device').value;if(!id)return;var els=$('rp-call-audio').querySelectorAll('audio'),supported=false;for(var i=0;i<els.length;i++)if(typeof els[i].setSinkId==='function'){supported=true;els[i].setSinkId(id).catch(function(e){voiceDeviceMessage('Speaker selection failed: '+e.message,'bad');});}if(!supported&&els.length)voiceDeviceMessage('This browser cannot route call audio to a selected speaker. System default output is active.','warn');}\n function testVoiceMicrophone(){\n var btn=$('rp-call-test-mic');btn.disabled=true;btn.textContent='Listening\u2026';\n refreshBrowserDevices(true,false).then(function(){var id=$('rp-call-mic-device').value,constraints=id?{deviceId:{exact:id}}:true;return navigator.mediaDevices.getUserMedia({audio:constraints});}).then(function(stream){\n var C=window.AudioContext||window.webkitAudioContext,ctx=new C(),src=ctx.createMediaStreamSource(stream),an=ctx.createAnalyser(),data=new Uint8Array(128),peak=0;an.fftSize=256;src.connect(an);var started=Date.now();\n return new Promise(function(resolve){function sample(){an.getByteTimeDomainData(data);var sum=0;for(var i=0;i<data.length;i++){var v=(data[i]-128)/128;sum+=v*v;}peak=Math.max(peak,Math.sqrt(sum/data.length));voiceDeviceMessage('Microphone test live \u00B7 level '+Math.round(peak*300)+'%','ok');if(Date.now()-started<2500)requestAnimationFrame(sample);else{stream.getTracks().forEach(function(t){t.stop();});ctx.close().catch(function(){});resolve(peak);}}sample();});\n }).then(function(peak){voiceDeviceMessage(peak>.01?'Microphone test passed \u00B7 signal detected.':'Microphone opened but no signal was detected. Check the selected input and gain.',peak>.01?'ok':'warn');}).catch(function(e){voiceDeviceMessage(e.message||'Microphone test failed','bad');}).finally(function(){btn.disabled=false;btn.textContent='Test microphone';});\n }\n function startVoiceVisualizer(track){\n stopVoiceVisualizer();try{var C=window.AudioContext||window.webkitAudioContext;if(!C||!track||!track.mediaStreamTrack)return;var ctx=new C(),an=ctx.createAnalyser(),source=ctx.createMediaStreamSource(new MediaStream([track.mediaStreamTrack]));an.fftSize=128;source.connect(an);rpVoiceCall.audioContext=ctx;rpVoiceCall.analyser=an;var data=new Uint8Array(an.frequencyBinCount),bars=$('rp-call-bars').querySelectorAll('i');\n function draw(){an.getByteFrequencyData(data);var sum=0;for(var x=0;x<data.length;x++)sum+=data[x];var avg=sum/data.length;for(var i=0;i<bars.length;i++){var idx=Math.floor(i*data.length/bars.length);bars[i].style.height=(12+Math.min(70,data[idx]*.28))+'px';}$('rp-call-visual').classList.toggle('speaking',avg>10);rpVoiceCall.audioRaf=requestAnimationFrame(draw);}draw();\n }catch(_){}\n }\n function stopVoiceVisualizer(){if(rpVoiceCall.audioRaf)cancelAnimationFrame(rpVoiceCall.audioRaf);rpVoiceCall.audioRaf=0;if(rpVoiceCall.audioContext)try{rpVoiceCall.audioContext.close();}catch(_){}rpVoiceCall.audioContext=null;rpVoiceCall.analyser=null;var bars=$('rp-call-bars');if(bars)Array.prototype.forEach.call(bars.querySelectorAll('i'),function(b){b.style.height='16px';});}\n function updateVoiceTimer(){if(!rpVoiceCall.startedAt)return;var sec=Math.floor((Date.now()-rpVoiceCall.startedAt)/1000),m=Math.floor(sec/60),s=sec%60;$('rp-call-timer').textContent=(m<10?'0':'')+m+':'+(s<10?'0':'')+s;}\n function updateAgentFromParticipant(participant){if(!participant)return;var a=participant.attributes||{},state=a['lk.agent.state']||a['agent.state']||'';if(state==='speaking')setVoiceAgentState('Speaking','character audio is streaming');else if(state==='thinking')setVoiceAgentState('Thinking','language model is composing');else if(state==='listening')setVoiceAgentState('Listening','speak naturally');else if(state==='initializing')setVoiceAgentState('Preparing agent','loading voice pipeline');}\n function handleVoiceData(payload,participant,kind,topic){\n try{var msg=JSON.parse(new TextDecoder().decode(payload));\n if(topic==='response_latency'){var total=Number(msg.total_ms)||0;$('rp-call-lat-total').textContent=total?total+'ms':'--';$('rp-call-lat-stt').textContent=msg.stt_to_llm_ms!=null?Math.round(msg.stt_to_llm_ms)+'ms':'--';$('rp-call-lat-tts').textContent=msg.llm_to_tts_ms!=null?Math.round(msg.llm_to_tts_ms)+'ms':'--';setVoiceEvent('Response complete',total+'ms round trip');return;}\n if(topic==='wakeword_state'){setVoiceEvent('Wake word',msg.state==='active'?'active - listening now':'waiting for wake word');return;}\n if(topic==='model_loading_status'){if(msg.is_loading){setVoiceAgentState('Loading model','cold start in progress');setVoiceEvent('Model status','loading model into memory');}return;}\n if(topic==='tool_status'){var names=msg.tool_names||[];setVoiceEvent('Tool activity',names.length?names.join(', '):'tool cycle complete');return;}\n if(topic && topic!=='lk-chat-topic')return;var text=String(msg.message||msg.text||'').trim();if(!text)return;var role=msg.role==='user'?'user':'assistant',key=role+'|'+text;if(rpVoiceCall.seen[key])return;rpVoiceCall.seen[key]=1;appendVoiceTurn(role,text);saveVoiceTurn(role,text,Object.keys(rpVoiceCall.seen).length);\n }catch(_){}\n }\n function attachVoiceMedia(track,tag){var video=$('rp-call-video'),box=$('rp-call-media');try{track.attach(video);box.classList.add('on');$('rp-call-media-tag').textContent=tag;}catch(_){} }\n function toggleVoiceCamera(){if(!rpVoiceCall.room)return;var next=!rpVoiceCall.camera,opts=$('rp-call-cam-device').value?{deviceId:$('rp-call-cam-device').value}:undefined;$('rp-call-camera').disabled=true;rpVoiceCall.room.localParticipant.setCameraEnabled(next,opts).then(function(){rpVoiceCall.camera=next;$('rp-call-camera').classList.toggle('on',next);$('rp-call-camera').textContent=next?'CAM ON':'CAM';if(next){var pub=rpVoiceCall.room.localParticipant.getTrackPublication(rpVoiceCall.LK.Track.Source.Camera);if(pub&&pub.track)attachVoiceMedia(pub.track,'CAMERA');refreshBrowserDevices(false);}else if(!rpVoiceCall.screen){$('rp-call-media').classList.remove('on');$('rp-call-video').srcObject=null;}}).catch(function(e){setVoiceEvent('Camera',e.message);}).finally(function(){$('rp-call-camera').disabled=false;});}\n function toggleVoiceScreen(){if(!rpVoiceCall.room)return;var next=!rpVoiceCall.screen;$('rp-call-screen').disabled=true;rpVoiceCall.room.localParticipant.setScreenShareEnabled(next).then(function(){rpVoiceCall.screen=next;$('rp-call-screen').classList.toggle('on',next);$('rp-call-screen').textContent=next?'SCR ON':'SCR';if(next){var pub=rpVoiceCall.room.localParticipant.getTrackPublication(rpVoiceCall.LK.Track.Source.ScreenShare);if(pub&&pub.track)attachVoiceMedia(pub.track,'SCREEN');}else if(!rpVoiceCall.camera){$('rp-call-media').classList.remove('on');$('rp-call-video').srcObject=null;}}).catch(function(e){setVoiceEvent('Screen share',e.message);}).finally(function(){$('rp-call-screen').disabled=false;});}\n function sendVoiceMessage(text){if(!rpVoiceCall.room||!text)return Promise.resolve();var payload=new TextEncoder().encode(JSON.stringify({message:text,role:'user',timestamp:Date.now()}));return rpVoiceCall.room.localParticipant.publishData(payload,{reliable:true,topic:'lk-chat-topic'}).then(function(){var key='user|'+text;if(!rpVoiceCall.seen[key]){rpVoiceCall.seen[key]=1;appendVoiceTurn('user',text);saveVoiceTurn('user',text,Object.keys(rpVoiceCall.seen).length);}});}\n function saveVoiceTurn(role,text,sequence){\n if(!rpVoiceCall.session||!rpVoiceCall.eventToken)return;\n fetch('/robopark/api/sessions/'+encodeURIComponent(rpVoiceCall.session)+'/transcript'+QS,{\n method:'POST',headers:Object.assign({'content-type':'application/json','X-RoboPark-Session-Token':rpVoiceCall.eventToken},operatorHeaders()),\n body:JSON.stringify({role:role,text:text,is_final:true,sequence:sequence,source:'control_center'})\n }).catch(function(){});\n }\n function startVoiceCall(){\n var cid=$('rp-call-character-select').value, sid=$('rp-call-stack-select').value;\n if(!cid){setVoiceCallStatus('select a character first','');return;}\n if(rpVoiceCall.room)return;\n setVoiceCallStatus('requesting microphone...','busy');setVoiceAgentState('Opening channel','requesting browser media permission'); $('rp-call-start').disabled=true;\n refreshBrowserDevices(true,false).then(function(devices){if(!devices.some(function(d){return d.kind==='audioinput';}))throw new Error('No browser microphone was detected');return rpAction('POST','/robopark/api/sessions/voice-call',{character_preset_id:cid,voice_stack_id:sid||null,client_name:getOperatorName()||'RoboPark Operator'});})\n .then(function(call){\n if(!call)throw new Error('scheduler returned no call');\n rpVoiceCall.session=call.session_id; rpVoiceCall.eventToken=call.event_token; rpVoiceCall.seen={};rpVoiceCall.messages=0;rpVoiceCall.ending=false;\n $('rp-call-session').textContent=call.session_id; $('rp-call-transcript').innerHTML='<div class=\"rp-tbl-empty\">Connecting to '+esc(call.character&&call.character.name||cid)+'...</div>';\n return ensureVoiceLiveKit().then(function(LK){\n var room=new LK.Room({adaptiveStream:true,dynacast:true}); rpVoiceCall.room=room;rpVoiceCall.LK=LK;\n room.on(LK.RoomEvent.TrackSubscribed,function(track){ if(track.kind==='audio'){var el=track.attach();el.autoplay=true;$('rp-call-audio').appendChild(el);applyVoiceOutputDevice();startVoiceVisualizer(track);setVoiceAgentState('Agent online','voice channel connected');} });\n room.on(LK.RoomEvent.TranscriptionReceived,function(segments,participant){\n for(var i=0;i<(segments||[]).length;i++){\n var seg=segments[i], final=seg.final===true||seg.isFinal===true; if(!final||!String(seg.text||'').trim())continue;\n var key=String(seg.id||'')+'|'+String(seg.text); if(rpVoiceCall.seen[key])continue; rpVoiceCall.seen[key]=1;\n var identity=participant&&participant.identity||''; var role=identity.indexOf('operator:')===0?'user':'assistant';\n appendVoiceTurn(role,String(seg.text)); saveVoiceTurn(role,String(seg.text),Object.keys(rpVoiceCall.seen).length);\n }\n });\n room.on(LK.RoomEvent.DataReceived,handleVoiceData);\n if(LK.RoomEvent.ParticipantAttributesChanged)room.on(LK.RoomEvent.ParticipantAttributesChanged,function(changed,p){updateAgentFromParticipant(p);});\n room.on(LK.RoomEvent.ParticipantConnected,function(p){updateAgentFromParticipant(p);setVoiceEvent('Agent connected',p.name||p.identity||'ROBOVOICE worker');});\n room.on(LK.RoomEvent.Disconnected,function(){ if(!rpVoiceCall.ending) finishVoiceCall('connection_lost'); });\n return room.connect(call.server_url,call.token).then(function(){\n var mic=$('rp-call-mic-device').value,opts=mic?{deviceId:mic}:undefined;\n setVoiceEvent('LiveKit connected','publishing '+(mic?'selected microphone':'system default microphone'));\n return room.localParticipant.setMicrophoneEnabled(true,opts).then(function(){return true;}).catch(function(e){\n var message=e&&e.message||'Browser microphone could not be published';\n rpVoiceCall.muted=true;\n voiceDeviceMessage('Voice channel is connected, but the microphone is blocked: '+message+'. Allow access, then press RETRY MIC.','bad');\n setVoiceEvent('Microphone blocked',message);\n if(window.console)console.error('RoboPark microphone publish failed',e);\n return false;\n });\n });\n }).then(function(micActive){\n setVoiceCallStatus(micActive?'secure channel connected':'channel connected - microphone blocked',micActive?'live':'busy');setVoiceAgentState(micActive?'Listening':'Microphone required',micActive?'speak naturally':'allow browser microphone access, then retry');if(micActive)setVoiceEvent('Connected','waiting for ROBOVOICE agent');rpVoiceCall.startedAt=Date.now();rpVoiceCall.timer=setInterval(updateVoiceTimer,1000);updateVoiceTimer();\n $('rp-call-mute').disabled=false;$('rp-call-camera').disabled=false;$('rp-call-screen').disabled=false;$('rp-call-end').disabled=false;$('rp-call-message').disabled=false;$('rp-call-send').disabled=false;\n $('rp-call-mute').textContent=micActive?'MIC':'RETRY MIC';$('rp-call-mute').classList.toggle('on',!!micActive);\n return fetch('/robopark/api/sessions/'+encodeURIComponent(rpVoiceCall.session)+'/joined'+QS,{method:'POST',headers:{'X-RoboPark-Session-Token':rpVoiceCall.eventToken}}).catch(function(){});\n });\n }).catch(function(e){\n if(window.console)console.error('RoboPark voice call failed',e);\n setVoiceCallStatus('call failed','');setVoiceAgentState('Unable to connect',e&&e.message||String(e));setVoiceEvent('Call failed',e&&e.message||String(e)); $('rp-call-start').disabled=false;\n var failedSession=rpVoiceCall.session,failedRoom=rpVoiceCall.room;rpVoiceCall.ending=true;rpVoiceCall.room=null;rpVoiceCall.session=null;rpVoiceCall.eventToken=null;\n if(failedSession)rpAction('POST','/robopark/api/sessions/'+encodeURIComponent(failedSession)+'/end?reason=connection_failed').catch(function(){});\n if(failedRoom){try{failedRoom.disconnect();}catch(_){}}\n setTimeout(function(){rpVoiceCall.ending=false;},300);\n });\n }\n function toggleVoiceMute(){\n if(!rpVoiceCall.room)return;var enable=rpVoiceCall.muted,mic=$('rp-call-mic-device').value,opts=enable&&mic?{deviceId:mic}:undefined,btn=$('rp-call-mute');btn.disabled=true;\n rpVoiceCall.room.localParticipant.setMicrophoneEnabled(enable,opts).then(function(){rpVoiceCall.muted=!enable;btn.textContent=enable?'MIC':'MIC OFF';btn.classList.toggle('on',enable);setVoiceCallStatus(enable?'secure channel connected':'microphone muted','live');setVoiceAgentState(enable?'Listening':'Microphone muted',enable?'speak naturally':'the call remains connected');voiceDeviceMessage(enable?'Microphone published to the active call.':'Microphone muted; the voice channel remains connected.',enable?'ok':'warn');}).catch(function(e){rpVoiceCall.muted=true;btn.textContent='RETRY MIC';btn.classList.remove('on');setVoiceCallStatus('channel connected - microphone blocked','busy');voiceDeviceMessage('Microphone retry failed: '+(e&&e.message||String(e)),'bad');setVoiceEvent('Microphone blocked',e&&e.message||String(e));}).finally(function(){btn.disabled=false;});\n }\n function finishVoiceCall(reason){\n if(rpVoiceCall.ending)return; rpVoiceCall.ending=true; var session=rpVoiceCall.session, room=rpVoiceCall.room;\n rpVoiceCall.room=null;if(rpVoiceCall.timer)clearInterval(rpVoiceCall.timer);rpVoiceCall.timer=null;stopVoiceVisualizer();if(room){try{room.disconnect();}catch(_){}}\n if(session) rpAction('POST','/robopark/api/sessions/'+encodeURIComponent(session)+'/end?reason='+encodeURIComponent(reason||'operator'));\n setVoiceCallStatus('call ended','');setVoiceAgentState('Call complete','session transcript saved');setVoiceEvent('Session closed',reason||'operator');$('rp-call-start').disabled=false;['rp-call-mute','rp-call-camera','rp-call-screen','rp-call-end'].forEach(function(id){$(id).disabled=true;$(id).classList.remove('on');});$('rp-call-message').disabled=true;$('rp-call-send').disabled=true;$('rp-call-media').classList.remove('on');$('rp-call-video').srcObject=null;$('rp-call-mute').textContent='MIC';$('rp-call-camera').textContent='CAM';$('rp-call-screen').textContent='SCR';$('rp-call-session').textContent='No active session';$('rp-call-timer').textContent='00:00';rpVoiceCall.session=null;rpVoiceCall.eventToken=null;rpVoiceCall.muted=false;rpVoiceCall.camera=false;rpVoiceCall.screen=false;rpVoiceCall.startedAt=0;setTimeout(function(){rpVoiceCall.ending=false;},300);\n }\n (function(){\n var cs=$('rp-call-character-select'),ss=$('rp-call-stack-select'),start=$('rp-call-start'),mute=$('rp-call-mute'),end=$('rp-call-end');\n if(cs)cs.addEventListener('change',paintVoiceCallCharacter); if(start)start.addEventListener('click',startVoiceCall);\n if(ss)ss.addEventListener('change',paintVoiceCallStack);if(mute)mute.addEventListener('click',toggleVoiceMute);if(end)end.addEventListener('click',function(){finishVoiceCall('operator');});\n $('rp-call-camera').addEventListener('click',toggleVoiceCamera);$('rp-call-screen').addEventListener('click',toggleVoiceScreen);$('rp-call-refresh-devices').addEventListener('click',function(){refreshBrowserDevices(true,true).catch(function(){});});$('rp-call-test-mic').addEventListener('click',testVoiceMicrophone);\n $('rp-call-edit-character').addEventListener('click',function(){var id=$('rp-call-character-select').value;if(id)openCharacterManager(id);else showOut('Choose a character to edit');});\n $('rp-call-new-character').addEventListener('click',function(){openCharacterManager();});\n $('rp-call-edit-stack').addEventListener('click',function(){var s=selectedVoiceStack();if(s)openVoiceStackManager(s.id);else showOut('Choose a voice stack to edit');});\n $('rp-call-new-stack').addEventListener('click',function(){openVoiceStackManager();});\n $('rp-call-mic-device').addEventListener('change',function(){switchVoiceDevice('audioinput',this.value);});$('rp-call-cam-device').addEventListener('change',function(){switchVoiceDevice('videoinput',this.value);});$('rp-call-speaker-device').addEventListener('change',applyVoiceOutputDevice);\n function toggleTranscript(){rpVoiceCall.transcriptCollapsed=!rpVoiceCall.transcriptCollapsed;$('rp-call-transcript').classList.toggle('collapsed',rpVoiceCall.transcriptCollapsed);$('rp-call-collapse').textContent=rpVoiceCall.transcriptCollapsed?'expand transcript':'collapse transcript';}\n $('rp-call-chat-toggle').addEventListener('click',toggleTranscript);$('rp-call-collapse').addEventListener('click',toggleTranscript);\n $('rp-call-compose').addEventListener('submit',function(e){e.preventDefault();var input=$('rp-call-message'),text=input.value.trim();if(!text)return;$('rp-call-send').disabled=true;sendVoiceMessage(text).then(function(){input.value='';}).catch(function(err){setVoiceEvent('Message failed',err.message);}).finally(function(){$('rp-call-send').disabled=!rpVoiceCall.room;});});\n refreshBrowserDevices(false);\n if(navigator.mediaDevices&&navigator.mediaDevices.addEventListener)navigator.mediaDevices.addEventListener('devicechange',function(){refreshBrowserDevices(false).catch(function(){});});\n })();\n\n var rpHistoryFilter = { robot: '', endReason: '', range: '7d', search: '' };\n var rpHistoryPage = 0;\n var RP_HISTORY_PAGE_SIZE = 25;\n function pollHistory(){\n getJson('/robopark/api/sessions?active_only=false&limit=500').then(function(rows){\n rpHistoryAll = Array.isArray(rows)?rows:[];\n renderHistory(rpHistoryAll);\n }).catch(function(e){\n var tbody=$('rp-hist-tbody'); if(tbody) tbody.innerHTML='';\n var empty=$('rp-hist-empty'); if(empty){ empty.style.display='block'; empty.textContent='history unreachable: '+(e&&e.message||''); }\n });\n }\n function rpHistoryWithinRange(s, range){\n if(range==='all'||!range) return true;\n if(!s.started_at) return true; // include rows with no timestamp when no filter set\n var t = Date.parse(s.started_at); if(isNaN(t)) return true;\n var now = Date.now();\n if(range==='24h') return (now-t) <= 86400e3;\n if(range==='7d') return (now-t) <= 7*86400e3;\n if(range==='30d') return (now-t) <= 30*86400e3;\n return true;\n }\n function renderHistory(rows){\n rows = rows || rpHistoryAll;\n var tbody = $('rp-hist-tbody'), empty = $('rp-hist-empty'), wrap = $('rp-hist-wrap');\n var info = $('rp-hist-info');\n // refresh robot dropdown from rows\n var rsel = $('rp-hist-robot');\n if(rsel){\n var known={};\n for(var i=0;i<rows.length;i++){ var r=String(rows[i].robot_id||''); if(r) known[r]=1; }\n // remove stale (preserve first \"all\" option)\n var opts=rsel.querySelectorAll('option');\n for(var j=opts.length-1;j>=1;j--){ if(!known[opts[j].value]) rsel.removeChild(opts[j]); }\n for(var rid in known){\n var present=false; for(var k=1;k<opts.length;k++){ if(opts[k].value===rid){ present=true; break; } }\n if(!present){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; rsel.appendChild(o); }\n }\n }\n var f = rpHistoryFilter;\n var filtered = rows.filter(function(s){\n if(f.robot && String(s.robot_id||'')!==f.robot) return false;\n if(f.endReason && String(s.end_reason||'').toLowerCase()!==f.endReason) return false;\n if(f.search){\n var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();\n if(blob.indexOf(f.search)<0) return false;\n }\n if(!rpHistoryWithinRange(s, f.range)) return false;\n return true;\n });\n var totalPages = Math.max(1, Math.ceil(filtered.length / RP_HISTORY_PAGE_SIZE));\n if(rpHistoryPage >= totalPages) rpHistoryPage = totalPages-1;\n if(rpHistoryPage < 0) rpHistoryPage = 0;\n var start = rpHistoryPage * RP_HISTORY_PAGE_SIZE;\n var slice = filtered.slice(start, start+RP_HISTORY_PAGE_SIZE);\n if(!slice.length){\n tbody.innerHTML = '';\n wrap.style.display='none';\n empty.style.display='block';\n empty.textContent = filtered.length ? 'no results on this page' : 'no sessions match the current filters';\n } else {\n empty.style.display='none';\n wrap.style.display='block';\n }\n var html='';\n for(var i=0;i<slice.length;i++){\n var s = slice[i];\n var sid = s.session_id || '';\n var rid = s.robot_id || '';\n var started = s.started_at ? Date.parse(s.started_at) : null;\n var ended = s.ended_at ? Date.parse(s.ended_at) : null;\n var dur = s.duration_seconds!=null ? s.duration_seconds : (started&&ended?Math.floor((ended-started)/1000):null);\n var reason = s.end_reason || '';\n var reasonCls = rpEndReasonClass(reason);\n var turns = s.transcript_count!=null ? s.transcript_count : 0;\n var pipe = s.error_count ? (s.error_count+' errors') : ((s.pipeline_event_count||0)+' events');\n var lat = s.avg_latency_ms!=null ? Math.round(s.avg_latency_ms)+'ms' : '\u2014';\n html += '<tr data-sid=\"'+esc(sid)+'\">'\n + '<td><span class=\"sid\" title=\"'+esc(sid)+'\">'+esc(sid)+'</span></td>'\n + '<td><b>'+esc(rid)+'</b></td>'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(started))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(started))+'</span></td>'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(ended))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(ended))+'</span></td>'\n + '<td>'+esc(rpFmtDuration(dur))+'</td>'\n + '<td><span class=\"rp-end-badge '+(reasonCls||'')+'\">'+esc(reason||'\u2014')+'</span></td>'\n + '<td>'+esc(turns)+'</td>'\n + '<td class=\"'+(s.error_count?'bad':'ok')+'\">'+esc(pipe)+'</td>'\n + '<td>'+esc(lat)+'</td>'\n + '<td><button class=\"rp-btn\" data-act=\"view\" data-sid=\"'+esc(sid)+'\">View</button></td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n if(info) info.textContent = 'showing '+(slice.length?(start+1):0)+'\u2013'+(start+slice.length)+' of '+filtered.length+' \u00B7 page '+(rpHistoryPage+1)+' / '+totalPages;\n var prev=$('rp-hist-prev'), next=$('rp-hist-next');\n if(prev) prev.disabled = rpHistoryPage<=0;\n if(next) next.disabled = rpHistoryPage>=totalPages-1;\n }\n function renderSessionDetail(s){\n function row(k,v){ return '<div class=\"kv\"><div class=\"k\">'+esc(k)+'</div><div class=\"v\">'+esc(v==null?'\u2014':v)+'</div></div>'; }\n var avatar=s.character_img||rpAvatarFor(s.character_id||s.character_name||s.robot_id), summary=s.summary||{};\n var html='<div class=\"rp-detail-hero\">'+(avatar?'<img alt=\"'+esc(s.character_name||s.robot_id)+'\" src=\"'+esc(avatar+QS)+'\">':'')\n +'<div><div style=\"font-size:1rem;color:var(--gold-bright);font-weight:700\">'+esc(s.character_name||s.robot_id||'Session')+'</div>'\n +'<div class=\"meta\">'+esc(summary.turns||0)+' turns \u00B7 '+esc(summary.pipeline_errors||0)+' pipeline errors</div></div></div>';\n html += row('Session ID', s.session_id)+row('Robot / source', s.robot_id)+row('Room', s.room_name);\n html += row('Started', rpFmtAbs(s.started_at)+' ('+rpFmtRelative(s.started_at)+')')+row('Ended',rpFmtAbs(s.ended_at)+' ('+rpFmtRelative(s.ended_at)+')');\n html += row('Duration',rpFmtDuration(s.duration_seconds))+row('End reason',s.end_reason||'active')+row('Join latency',s.latency_ms!=null?s.latency_ms+'ms':'\u2014');\n html += row('Operator',s.initiated_by||'robot motion')+row('Character',s.character_name||s.character_id||'\u2014')+row('Voice stack',s.voice_stack_name||s.voice_stack_id||'\u2014');\n html += '<div class=\"rp-section-h\" style=\"margin-top:1rem\">Transcript <span class=\"meta\">'+esc(summary.user_turns||0)+' user / '+esc(summary.assistant_turns||0)+' assistant</span></div>';\n var turns=(s.transcript||[]).filter(function(t){return t.is_final;});\n html += '<div class=\"rp-call-transcript\" style=\"height:auto;max-height:320px\">'+(turns.length?turns.map(function(t){return '<div class=\"rp-turn '+esc(t.role)+'\"><div class=\"who\">'+esc(t.speaker||t.role)+' \u00B7 '+esc(clock(t.timestamp))+'</div><div>'+esc(t.text)+'</div></div>';}).join(''):'<div class=\"rp-tbl-empty\">No transcript was recorded for this session.</div>')+'</div>';\n html += '<div class=\"rp-section-h\" style=\"margin-top:1rem\">Pipeline timeline <span class=\"meta\">'+esc((s.pipeline_events||[]).length)+' events</span></div>';\n html += '<div class=\"rp-pipeline\">'+((s.pipeline_events||[]).length?s.pipeline_events.map(function(e){return '<div class=\"rp-pipeline-row '+esc(e.status)+'\"><span>'+esc(clock(e.timestamp))+'</span><b>'+esc(e.stage)+'</b><span>'+esc(e.status)+'</span><span>'+esc(e.message||'')+'</span></div>';}).join(''):'<div class=\"rp-tbl-empty\">No pipeline events were recorded.</div>')+'</div>';\n return html;\n }\n function openSessionDetailModal(s){\n if(!s)return; showModal('Session '+(s.session_id||''),'<div class=\"rp-tbl-empty\">Loading transcript and pipeline timeline...</div>');\n getJson('/robopark/api/sessions/'+encodeURIComponent(s.session_id)+'/detail').then(function(detail){$('rp-modal-body').innerHTML=renderSessionDetail(detail);}).catch(function(e){$('rp-modal-body').innerHTML='<div class=\"rp-tbl-empty\">Session detail unavailable: '+esc(e&&e.message||e)+'</div>';});\n }\n (function(){\n var s=$('rp-hist-search'); if(s) s.addEventListener('input', function(){ rpHistoryFilter.search = String(s.value||'').toLowerCase(); rpHistoryPage=0; renderHistory(); });\n var r=$('rp-hist-robot'); if(r) r.addEventListener('change', function(){ rpHistoryFilter.robot = r.value||''; rpHistoryPage=0; renderHistory(); });\n var re=$('rp-hist-reason'); if(re) re.addEventListener('change', function(){ rpHistoryFilter.endReason = re.value||''; rpHistoryPage=0; renderHistory(); });\n var ra=$('rp-hist-range'); if(ra) ra.addEventListener('change', function(){ rpHistoryFilter.range = ra.value||'7d'; rpHistoryPage=0; renderHistory(); });\n var rb=$('rp-hist-refresh'); if(rb) rb.addEventListener('click', pollHistory);\n var prev=$('rp-hist-prev'); if(prev) prev.addEventListener('click', function(){ rpHistoryPage=Math.max(0,rpHistoryPage-1); renderHistory(); });\n var next=$('rp-hist-next'); if(next) next.addEventListener('click', function(){ rpHistoryPage=rpHistoryPage+1; renderHistory(); });\n var tb=$('rp-hist-tbody'); if(tb) tb.addEventListener('click', function(e){\n var b = e.target.closest ? e.target.closest('.rp-btn') : null; if(!b) return;\n if(b.getAttribute('data-act')==='view'){\n var sid = b.getAttribute('data-sid');\n for(var i=0;i<rpHistoryAll.length;i++){ if(String(rpHistoryAll[i].session_id||'')===sid){ openSessionDetailModal(rpHistoryAll[i]); return; } }\n }\n });\n })();\n\n // \u2500\u2500 Audit Log sub-tab \u2500\u2500\n var rpAuditFilter = { category: '', operator: '' };\n var rpAuditLast = [];\n function pollAuditLog(){\n var parts = [];\n if(rpAuditFilter.category) parts.push('category='+encodeURIComponent(rpAuditFilter.category));\n parts.push('limit=200');\n // Note: we filter by operator client-side (the /api/history endpoint\n // doesn't take an actor= filter). The filter chip toggles a\n // post-fetch predicate in renderAuditLog.\n var qs = '?' + parts.join('&');\n getJson('/robopark/api/history'+qs).then(function(rows){\n renderAuditLog(Array.isArray(rows)?rows:[]);\n }).catch(function(){\n var tbody=$('rp-audit-tbody'); if(tbody) tbody.innerHTML='';\n var empty=$('rp-audit-empty'); if(empty){ empty.style.display='block'; empty.textContent='audit log unreachable'; }\n });\n }\n function renderAuditLog(rows){\n // Apply the \"by me\" filter (client-side; the API doesn't take an\n // actor= filter today). The chip toggles rpAuditFilter.operator.\n var myName = (getOperatorName()||'').toLowerCase();\n var meOnly = !!rpAuditFilter.operator;\n if(meOnly && myName){\n rows = rows.filter(function(r){ return String(r.actor||'').toLowerCase() === myName; });\n }\n // keep the chip label in sync with the current operator\n var chip = $('rp-audit-byme');\n if(chip){\n if(!myName){\n chip.classList.add('disabled');\n chip.title = 'Set your operator name (click the badge in the top bar) to use this filter';\n } else {\n chip.classList.remove('disabled');\n chip.title = 'Showing only actions attributed to \"'+myName+'\"';\n chip.textContent = '\uD83D\uDC64 by me ('+myName+')';\n }\n chip.classList.toggle('active', meOnly);\n }\n var tbody=$('rp-audit-tbody'), empty=$('rp-audit-empty'), wrap=$('rp-audit-wrap');\n if(!rows.length){\n tbody.innerHTML='';\n wrap.style.display='none';\n empty.style.display='block';\n if(meOnly) empty.textContent = 'no audit events attributed to '+myName+' in this window';\n else empty.textContent = 'no audit events yet';\n return;\n }\n empty.style.display='none';\n wrap.style.display='block';\n var html='';\n for(var i=0;i<rows.length;i++){\n var ev=rows[i];\n var ts = ev.timestamp;\n var cat = ev.category || '\u2014';\n var act = ev.action || '\u2014';\n var ent = (ev.entity_type||'')+(ev.entity_id?(' \u00B7 '+ev.entity_id):'');\n var det = ev.details || '';\n var actor = ev.actor || '\u2014';\n if(typeof det==='object') det = JSON.stringify(det);\n var detShort = String(det);\n if(detShort.length>80) detShort = detShort.slice(0,77)+'\u2026';\n // Highlight rows attributed to the current operator so they pop\n var isMine = myName && String(actor).toLowerCase() === myName;\n var actorCellCls = isMine ? 'rp-actor-mine' : 'rp-actor-other';\n var rowCls = isMine ? 'rp-audit-row-mine' : '';\n html += '<tr class=\"'+rowCls+'\" data-idx=\"'+i+'\">'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(ts))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(ts))+'</span></td>'\n + '<td><span class=\"rp-end-badge\">'+esc(rpCategoryIcon(cat))+' '+esc(cat)+'</span></td>'\n + '<td><b>'+esc(act)+'</b></td>'\n + '<td>'+esc(ent||'\u2014')+'</td>'\n + '<td style=\"max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"'+esc(String(det).replace(/\"/g,'&quot;'))+'\">'+esc(detShort)+'</td>'\n + '<td class=\"'+actorCellCls+'\" title=\"'+esc(actor)+'\">'\n + (isMine ? '\uD83D\uDC64 ' : '')\n + '<b>'+esc(actor)+'</b>'\n + '</td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n // stash for modal\n rpAuditLast = rows;\n }\n function openAuditDetailModal(ev){\n if(!ev) return;\n function row(k,v){ return '<div class=\"kv\"><div class=\"k\">'+esc(k)+'</div><div class=\"v\">'+esc(v==null?'\u2014':v)+'</div></div>'; }\n var html = '';\n html += row('Time', rpFmtAbs(ev.timestamp) + ' ('+rpFmtRelative(ev.timestamp)+')');\n html += row('Category', rpCategoryIcon(ev.category)+' '+(ev.category||'\u2014'));\n html += row('Action', ev.action);\n html += row('Entity type', ev.entity_type);\n html += row('Entity ID', ev.entity_id);\n html += row('Actor / source', ev.actor);\n if(ev.details){\n var dStr = typeof ev.details==='string' ? ev.details : JSON.stringify(ev.details,null,2);\n html += '<div class=\"kv\"><div class=\"k\">Details</div><div class=\"v\"><pre>'+esc(dStr)+'</pre></div></div>';\n }\n showModal('Audit \u00B7 '+(ev.action||'event')+' \u00B7 '+(ev.entity_id||ev.entity_type||''), html);\n }\n (function(){\n var chips = document.querySelectorAll('.rp-cat-chip');\n for(var i=0;i<chips.length;i++){\n (function(c){\n c.addEventListener('click', function(){\n for(var j=0;j<chips.length;j++) chips[j].classList.remove('active');\n c.classList.add('active');\n rpAuditFilter.category = c.getAttribute('data-cat')||'';\n pollAuditLog();\n });\n })(chips[i]);\n }\n var rb=$('rp-audit-refresh'); if(rb) rb.addEventListener('click', pollAuditLog);\n var byme=$('rp-audit-byme'); if(byme) byme.addEventListener('click', function(){\n if(!getOperatorName()){ openOperatorModal(); return; }\n rpAuditFilter.operator = rpAuditFilter.operator ? '' : getOperatorName();\n pollAuditLog();\n });\n var tb=$('rp-audit-tbody'); if(tb) tb.addEventListener('click', function(e){\n var tr = e.target.closest ? e.target.closest('tr') : null; if(!tr) return;\n var idx = parseInt(tr.getAttribute('data-idx'),10);\n if(!isNaN(idx) && rpAuditLast[idx]) openAuditDetailModal(rpAuditLast[idx]);\n });\n })();\n\n // \u2500\u2500 C4: Incidents sub-tab \u2500\u2500\n // Surfaces every active incident (any robot whose readiness_code is\n // in a non-ready set) and lets an operator acknowledge each one.\n // Acknowledgements live in localStorage, scoped per browser, so\n // multiple operators on different machines each keep their own\n // \"in-progress\" list \u2014 but every Ack also writes an audit event\n // through the operator-actor middleware (C3), so the action shows\n // up in the central history log under the operator's name.\n //\n // Severity map per readiness_code:\n // stuck_session | stale_session | join_timeout | device_missing |\n // server_unavailable | heartbeat_stale \u2192 critical (bad)\n // production_off | robot_production_off | production mode off \u2192 warning\n // not_joined | unknown | anything else that isn't 'ready' / 'in_session' \u2192 info\n var INCIDENT_SEVERITY = {\n stuck_session: 'bad',\n stale_session: 'bad',\n join_timeout: 'bad',\n device_missing: 'bad',\n server_unavailable: 'bad',\n heartbeat_stale: 'bad',\n production_off: 'warn',\n robot_production_off:'warn',\n not_joined: 'info',\n unknown: 'info',\n };\n var INCIDENT_BAD_CODES = ['stuck_session','stale_session','join_timeout','device_missing','server_unavailable','heartbeat_stale'];\n var INCIDENT_ACK_KEY = 'rp-incident-acks-v1';\n // Active incidents: derived live from the most recent telemetry\n // snapshot in lastRobots. Acknowledged list is in localStorage,\n // keyed by robotId|readiness so the same robot entering\n // the same state again reopens the incident.\n var _incidentSevFilter = '';\n var _incidentAcks = {}; // key -> {t, by}\n function _loadIncidentAcks(){\n try{\n var raw = localStorage.getItem(INCIDENT_ACK_KEY);\n _incidentAcks = raw ? JSON.parse(raw) : {};\n }catch(e){ _incidentAcks = {}; }\n }\n function _saveIncidentAcks(){\n try{ localStorage.setItem(INCIDENT_ACK_KEY, JSON.stringify(_incidentAcks)); }catch(e){}\n }\n function _incidentKey(robotId, code){ return robotId + '|' + code; }\n function _ackIncident(robotId, code, readinessMessage){\n var key = _incidentKey(robotId, code);\n _incidentAcks[key] = { t: Date.now(), by: getOperatorName() || 'operator', message: readinessMessage || '' };\n _saveIncidentAcks();\n // Mirror the ack to the audit log so it's visible centrally too.\n // rpAction carries the X-Operator header (C3), so the audit row\n // will be attributed to the current operator.\n rpAction('POST','/robopark/api/incidents/ack',{ robot_id: robotId, code: code, message: readinessMessage || '' })\n .catch(function(e){ /* soft-fail; the local ack still stands */ });\n renderIncidents();\n }\n function _unackIncident(robotId, code){\n var key = _incidentKey(robotId, code);\n delete _incidentAcks[key];\n _saveIncidentAcks();\n renderIncidents();\n }\n // Compute the list of currently-active incidents from the last\n // /api/telemetry snapshot. Includes nodes that aren't even in the\n // fleet (id starts with 'pad:') \u2014 we still want to know if a slot\n // is empty for an extended period.\n function _collectActiveIncidents(){\n var list = [];\n var seen = {};\n for(var i=0;i<(lastRobots||[]).length;i++){\n var r = lastRobots[i];\n var code = r.readiness_code || (r.connected ? 'ready' : 'not_joined');\n if(!code || code === 'ready' || code === 'in_session') continue;\n var sev = INCIDENT_SEVERITY[code] || 'info';\n if(sev === 'info' && code === 'not_joined') continue; // too noisy; skip\n var key = _incidentKey(r.id, code);\n seen[key] = true;\n // find first-seen: persisted across reloads via a parallel map\n var firstSeenKey = 'rp-incident-first-seen-v1';\n var firstSeen = {};\n try{ firstSeen = JSON.parse(localStorage.getItem(firstSeenKey) || '{}'); }catch(e){}\n var fs = firstSeen[key] || Date.now();\n if(!firstSeen[key]){ firstSeen[key] = Date.now(); try{ localStorage.setItem(firstSeenKey, JSON.stringify(firstSeen)); }catch(e){} }\n list.push({\n robotId: r.id,\n robotName: r.name || r.id,\n code: code,\n severity: sev,\n message: r.readiness_message || code,\n firstSeen: fs,\n ack: _incidentAcks[key] || null,\n });\n }\n // prune stale firstSeen entries so localStorage doesn't grow forever\n try{\n var fsAll = JSON.parse(localStorage.getItem('rp-incident-first-seen-v1') || '{}');\n var keys = Object.keys(fsAll);\n var pruned = {};\n for(var k=0;k<keys.length;k++){ if(seen[keys[k]]) pruned[keys[k]] = fsAll[keys[k]]; }\n localStorage.setItem('rp-incident-first-seen-v1', JSON.stringify(pruned));\n }catch(e){}\n return list;\n }\n function renderIncidents(){\n var incidents = _collectActiveIncidents();\n // severity filter\n if(_incidentSevFilter){\n incidents = incidents.filter(function(i){ return i.severity === _incidentSevFilter; });\n }\n // sort: bad \u2192 warn \u2192 info, then by firstSeen desc\n var sevOrder = { bad: 0, warn: 1, info: 2 };\n incidents.sort(function(a,b){\n var so = (sevOrder[a.severity]||9) - (sevOrder[b.severity]||9);\n if(so !== 0) return so;\n return b.firstSeen - a.firstSeen;\n });\n var tbody = $('rp-inc-tbody'), empty = $('rp-inc-empty'), wrap = $('rp-inc-wrap');\n var badge = $('rps-incidents-badge');\n var total = _collectActiveIncidents().length;\n if(badge) badge.textContent = total;\n if(!incidents.length){\n if(tbody) tbody.innerHTML = '';\n if(wrap) wrap.style.display='none';\n if(empty){ empty.style.display='block'; empty.textContent = total ? 'no incidents match this filter' : 'no active incidents \u2014 every robot is ready'; }\n } else {\n if(empty) empty.style.display='none';\n if(wrap) wrap.style.display='block';\n var html = '';\n for(var i=0;i<incidents.length;i++){\n var inc = incidents[i];\n var sevClass = inc.severity === 'bad' ? 'rp-inc-sev-bad' : (inc.severity === 'warn' ? 'rp-inc-sev-warn' : 'rp-inc-sev-info');\n var sevMark = inc.severity === 'bad' ? '\u2717' : (inc.severity === 'warn' ? '\u26A0' : '\u00B7');\n var dur = fmtDur(Date.now() - inc.firstSeen);\n var fsRel = rpFmtRelative(inc.firstSeen);\n var fsAbs = rpFmtAbs(inc.firstSeen);\n var ackTag = inc.ack ? '<span class=\"rp-inc-acked-tag\" title=\"acked by '+esc(inc.ack.by)+' at '+rpFmtAbs(inc.ack.t)+'\">\u2713 acked</span>' : '';\n html += '<tr class=\"'+sevClass+'\">'\n + '<td><span class=\"rp-inc-sev\">'+sevMark+' '+esc(inc.severity)+'</span></td>'\n + '<td><b>'+esc(inc.robotName)+'</b> <span class=\"rp-inc-id\">'+esc(inc.robotId)+'</span></td>'\n + '<td><span class=\"mono\">'+esc(inc.code)+'</span></td>'\n + '<td><span class=\"t-rel\">'+esc(fsRel)+'</span><span class=\"t-abs\">'+esc(fsAbs)+'</span></td>'\n + '<td>'+esc(dur)+'</td>'\n + '<td>'+esc(inc.message)+' '+ackTag+'</td>'\n + '<td style=\"white-space:nowrap;\">';\n if(!inc.ack){\n html += '<button class=\"rp-btn\" data-inc-act=\"ack\" data-robot=\"'+esc(inc.robotId)+'\" data-code=\"'+esc(inc.code)+'\">Acknowledge</button>';\n } else {\n html += '<button class=\"rp-btn ghost\" data-inc-act=\"unack\" data-robot=\"'+esc(inc.robotId)+'\" data-code=\"'+esc(inc.code)+'\" title=\"'+esc('Acked by '+inc.ack.by+' at '+rpFmtAbs(inc.ack.t))+'\">undo</button>';\n }\n html += '</td></tr>';\n }\n tbody.innerHTML = html;\n }\n // also render the acknowledged list\n renderIncidentsAcknowledged();\n }\n function renderIncidentsAcknowledged(){\n var acks = Object.keys(_incidentAcks).map(function(k){\n var parts = k.split('|');\n return { key: k, robotId: parts[0], code: parts.slice(1).join('|'), t: _incidentAcks[k].t, by: _incidentAcks[k].by, message: _incidentAcks[k].message };\n });\n // drop acks whose underlying incident has cleared\n var stillActive = {};\n var active = _collectActiveIncidents();\n for(var i=0;i<active.length;i++){ stillActive[active[i].robotId+'|'+active[i].code] = true; }\n acks = acks.filter(function(a){ return !stillActive[a.key]; });\n // sort newest-first\n acks.sort(function(a,b){ return b.t - a.t; });\n var tbody = $('rp-inc-ack-tbody'), empty = $('rp-inc-ack-empty'), wrap = $('rp-inc-ack-wrap');\n var counter = $('rp-inc-ack-count');\n if(counter) counter.textContent = acks.length;\n if(!acks.length){\n if(tbody) tbody.innerHTML = '';\n if(wrap) wrap.style.display='none';\n if(empty) empty.style.display='block';\n return;\n }\n if(empty) empty.style.display='none';\n if(wrap) wrap.style.display='block';\n var html = '';\n for(var i=0;i<acks.length;i++){\n var a = acks[i];\n html += '<tr class=\"rp-inc-acked-row\">'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(a.t))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(a.t))+'</span></td>'\n + '<td><b>'+esc(a.by)+'</b></td>'\n + '<td>'+esc(a.robotId)+'</td>'\n + '<td><span class=\"mono\">'+esc(a.code)+'</span></td>'\n + '<td>'+esc(a.message||'')+'</td>'\n + '<td style=\"white-space:nowrap;\"><button class=\"rp-btn ghost\" data-inc-act=\"unack\" data-robot=\"'+esc(a.robotId)+'\" data-code=\"'+esc(a.code)+'\">remove</button></td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n }\n function fmtDur(ms){\n if(ms < 0) ms = 0;\n var s = Math.floor(ms/1000);\n if(s < 60) return s+'s';\n var m = Math.floor(s/60);\n if(m < 60) return m+'m '+(s%60)+'s';\n var h = Math.floor(m/60);\n if(h < 24) return h+'h '+(m%60)+'m';\n var d = Math.floor(h/24);\n return d+'d '+(h%24)+'h';\n }\n var lastRobots = [];\n function pollIncidents(){\n // KPI tiles: per-source liveness.\n renderLivenessKpis();\n // Reuse the most recent telemetry snapshot; if the user is on\n // the incidents tab, also kick a fresh fetch.\n if(rpSubTab === 'incidents'){\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store', headers: operatorHeaders()}).then(function(r){ return r.json(); }).then(function(d){\n lastRobots = robotsFrom(d) || [];\n renderIncidents();\n }).catch(function(){ renderIncidents(); });\n } else {\n renderIncidents();\n }\n }\n function renderLivenessKpis(){\n var now = Date.now();\n // 1. mesh (/fed/status) \u2014 lastStatus is set by /fed/status poll;\n // poll() writes to lastStatus whenever the fetch resolves.\n var mesh = (typeof lastStatus === 'object' && lastStatus) ? lastStatus : null;\n var meshT = mesh ? (lastFedStatusAt || null) : null;\n paintKpi('mesh', !!mesh, meshT, meshT ? fmtDur(now - meshT) + ' ago' : 'no successful poll', meshT ? (now - meshT < 15000 ? 'ok' : (now - meshT < 30000 ? 'warn' : 'bad')) : 'bad');\n // 2. scheduler (/robopark/api/telemetry) \u2014 track on a successful fetch\n var schedT = lastSchedulerPollAt || null;\n paintKpi('sched', !!schedT, schedT, schedT ? fmtDur(now - schedT) + ' ago' : 'no successful poll', schedT ? (now - schedT < 8000 ? 'ok' : (now - schedT < 20000 ? 'warn' : 'bad')) : 'bad');\n // 3. cameras \u2014 at least one camera source is \"ok\" across the fleet\n var camOk = 0, camTotal = 0;\n for(var i=0;i<lastRobots.length;i++){ var r=lastRobots[i]; if(r.id && r.id.indexOf('pad:')!==0){ camTotal++; if(r.connected && r.current_session_id) camOk++; } }\n var camDot = camTotal === 0 ? 'warn' : (camOk > 0 ? 'ok' : 'warn');\n paintKpi('cam', camTotal>0, camTotal, camOk + ' of ' + camTotal + ' have a live session', camDot);\n // 4. production mode \u2014 read the global + per-device effective flag\n var pmOn = (typeof settings !== 'undefined' && settings && settings.production_mode === true);\n var pmDot = pmOn ? 'ok' : 'warn';\n paintKpi('prod', pmOn, pmOn ? 'on' : 'off', pmOn ? 'global switch on' : 'global switch OFF \u2014 incidents will be informational only', pmDot);\n }\n function paintKpi(name, ok, value, meta, severity){\n var v = $('rp-inc-kpi-'+name+'-v');\n var m = $('rp-inc-kpi-'+name+'-meta');\n var d = $('rp-inc-kpi-'+name+'-dot');\n if(v) v.textContent = (value === null || value === undefined) ? '\u2014' : String(value);\n if(m) m.textContent = meta || '';\n if(d){\n d.className = 'pk-kpi-dot '+(severity === 'ok' ? 'ok' : severity === 'warn' ? 'warn' : severity === 'bad' ? 'bad' : '');\n }\n }\n // hook the acknowledge / undo buttons (delegated, idempotent)\n document.addEventListener('click', function(e){\n var b = e.target.closest && e.target.closest('[data-inc-act]');\n if(!b) return;\n if(rpReadOnly){ showOut('read-only node'); return; }\n var act = b.getAttribute('data-inc-act');\n var rid = b.getAttribute('data-robot');\n var code = b.getAttribute('data-code');\n if(!rid || !code) return;\n if(act === 'ack'){\n // find the message from the current incidents list\n var msg = '';\n var list = _collectActiveIncidents();\n for(var i=0;i<list.length;i++){ if(list[i].robotId === rid && list[i].code === code){ msg = list[i].message; break; } }\n _ackIncident(rid, code, msg);\n } else if(act === 'unack'){\n _unackIncident(rid, code);\n }\n });\n // severity-filter chips\n (function(){\n var chips = document.querySelectorAll('[data-inc-sev]');\n for(var i=0;i<chips.length;i++){\n (function(c){\n c.addEventListener('click', function(){\n for(var j=0;j<chips.length;j++) chips[j].classList.remove('active');\n c.classList.add('active');\n _incidentSevFilter = c.getAttribute('data-inc-sev') || '';\n renderIncidents();\n });\n })(chips[i]);\n }\n var rb = $('rp-inc-refresh'); if(rb) rb.addEventListener('click', pollIncidents);\n })();\n // Load the ack list from localStorage on first run.\n _loadIncidentAcks();\n\n // \u2500\u2500 Fleet sub-tab (uses the existing #rp-grid) \u2500\u2500\n // The Fleet sub-tab reuses the existing #rp-grid render in pollRobopark().\n // We add sparklines by overlaying on top of the existing cards.\n function pollFleet(){\n pollRobopark();\n }\n function drawRobotSparkline(canvas, sessions){\n if(!canvas || !canvas.getContext) return;\n var ctx = canvas.getContext('2d');\n var w = canvas.width = canvas.clientWidth;\n var h = canvas.height = canvas.clientHeight;\n ctx.clearRect(0,0,w,h);\n if(!sessions || sessions.length<2){\n ctx.fillStyle = 'rgba(158,147,180,0.4)';\n ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';\n ctx.fillText('no history', 6, h/2+3);\n return;\n }\n var counts = sessions.map(function(s){\n return (s.duration_seconds!=null?s.duration_seconds:(s.ended_at&&s.started_at?(Date.parse(s.ended_at)-Date.parse(s.started_at))/1000:0));\n });\n var mx = Math.max.apply(null, counts); if(mx<=0) mx=1;\n var pad = 2;\n var stepX = (w - pad*2) / (counts.length - 1);\n ctx.beginPath();\n for(var i=0;i<counts.length;i++){\n var x = pad + i*stepX;\n var y = h - pad - (counts[i]/mx) * (h - pad*2);\n if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);\n }\n // gold gradient line\n try{\n var grad = ctx.createLinearGradient(0,0,0,h);\n grad.addColorStop(0, '#FFD86B');\n grad.addColorStop(1, '#E6B23C');\n ctx.strokeStyle = grad;\n }catch(e){ ctx.strokeStyle = '#E6B23C'; }\n ctx.lineWidth = 1.4;\n ctx.lineJoin = 'round';\n ctx.stroke();\n // fill underneath\n ctx.lineTo(pad+(counts.length-1)*stepX, h-pad);\n ctx.lineTo(pad, h-pad);\n ctx.closePath();\n ctx.fillStyle = 'rgba(230,178,60,0.18)';\n ctx.fill();\n }\n\n // \u2500\u2500 Overview sub-tab \u2500\u2500\n function pollOverview(){\n Promise.all([\n getJson('/robopark/api/sessions?active_only=false&limit=200').catch(function(){ return []; }),\n getJson('/robopark/api/sessions/stats').catch(function(){ return {}; }),\n getJson('/robopark/api/telemetry').catch(function(){ return {robots:[],totals:{}}; }),\n getJson('/robopark/api/history?limit=20').catch(function(){ return []; })\n ]).then(function(results){\n renderOverview({\n sessions: Array.isArray(results[0])?results[0]:[],\n stats: results[1]||{},\n telemetry: results[2]||{robots:[],totals:{}},\n history: Array.isArray(results[3])?results[3]:[]\n });\n }).catch(function(){ /* soft fail */ });\n }\n function renderOverview(data){\n var sessions = data.sessions||[];\n var stats = data.stats||{};\n var telemetry = data.telemetry||{};\n var totals = telemetry.totals || {};\n var robots = Array.isArray(telemetry.robots)?telemetry.robots:[];\n // active sessions\n var active = 0; for(var i=0;i<sessions.length;i++){ if(!sessions[i].ended_at) active++; }\n var totalTrig = totals.trigger_count!=null?totals.trigger_count:stats.total_sessions;\n var today = stats.today_sessions!=null?stats.today_sessions:0;\n var avgLat = (function(){\n var vals=[];\n for(var i=0;i<robots.length;i++){ if(robots[i].avg_latency_ms!=null) vals.push(robots[i].avg_latency_ms); }\n if(!vals.length) return null;\n return Math.round(vals.reduce(function(a,b){return a+b;},0)/vals.length);\n })();\n var dropRate = totals.drop_rate!=null?totals.drop_rate:null;\n var total = stats.total_sessions!=null?stats.total_sessions:0;\n // uptime: ratio of robots connected \u2014 best effort (we don't have an uptime API,\n // so we fall back to \"robot count online\" derived from telemetry robots)\n // Render the KPI strip\n function kpi(l,v,cls,sub){\n return '<div class=\"rp-kpi\"><div class=\"l\">'+esc(l)+'</div><div class=\"v '+esc(cls||'')+'\">'+esc(v)+'</div>'+(sub?('<div class=\"s\">'+esc(sub)+'</div>'):'')+'</div>';\n }\n var html = '';\n html += kpi('Triggers (total)', totalTrig!=null?totalTrig:'\u2014', 'info', robots.length+' robot'+(robots.length===1?'':'s'));\n html += kpi('Today', today, today>0?'ok':'', 'sessions today');\n html += kpi('Active', active, active>0?'ok':'', 'live right now');\n html += kpi('Avg latency', avgLat!=null?(avgLat+'ms'):'\u2014', avgLat==null?'':avgLat>=500?'bad':avgLat>=200?'warn':'ok', 'across all robots');\n html += kpi('Drop rate', dropRate!=null?Math.round(dropRate*100)+'%':'\u2014', dropRate==null?'':dropRate>=0.2?'bad':dropRate>=0.05?'warn':'ok', 'failed / total');\n html += kpi('Total sessions', total, '', stats.avg_duration_seconds!=null?('avg '+rpFmtDuration(stats.avg_duration_seconds)):'');\n $('rp-overview-kpis').innerHTML = html;\n // mini chart: bucket sessions by hour for the last 60 minutes from started_at\n var buckets = new Array(12).fill(0); // 12 buckets \u00D7 5min = 60min\n var now = Date.now();\n for(var i=0;i<sessions.length;i++){\n var t = sessions[i].started_at ? Date.parse(sessions[i].started_at) : 0;\n if(!t) continue;\n var diff = now - t;\n if(diff<0 || diff>3600e3) continue;\n var idx = 11 - Math.floor(diff/(5*60e3));\n if(idx>=0 && idx<12) buckets[idx]++;\n }\n drawOverviewChart($('rp-overview-chart'), buckets);\n // top 3 robots by session count\n var ranked = robots.slice().sort(function(a,b){ return (b.sessions_total||0)-(a.sessions_total||0); });\n var top3 = ranked.slice(0,3);\n var top = $('rp-overview-top'), topMeta = $('rp-overview-top-meta');\n if(topMeta) topMeta.textContent = ranked.length?('of '+ranked.length+' robots'):'';\n if(!top3.length){ top.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1;\">No robot telemetry yet.</div>'; }\n else {\n var thtml='';\n for(var i=0;i<top3.length;i++){\n var x = top3[i];\n var name = x.name || x.id || 'robot';\n var avatar = rpAvatarFor(x.character_name||x.character_id||name);\n thtml += '<div class=\"rp-card\" style=\"padding:0.55rem 0.7rem;\">'\n + '<div class=\"rp-card-top\">'\n + (avatar?'<div class=\"rp-card-img\"><img alt=\"'+esc(name)+'\" src=\"'+esc(avatar+QS)+'\"></div>':'')\n + '<div style=\"min-width:0;flex:1;\"><h4 style=\"margin:0;font-size:0.78rem;\">'+esc(name)+'</h4>'\n + '<div class=\"rp-metrics\" style=\"grid-template-columns:repeat(3,1fr);gap:0.2rem 0.5rem;margin-top:0.3rem;\">'\n + '<div class=\"rp-m\"><span class=\"v\" style=\"font-size:0.9rem;\">'+(x.sessions_total||0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\" style=\"font-size:0.9rem;\">'+(x.trigger_count||0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\" style=\"font-size:0.9rem;\">'+(x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014')+'</span><span class=\"l\">latency</span></div>'\n + '</div></div></div></div>';\n }\n top.innerHTML = thtml;\n }\n // recent activity stream\n var stream = $('rp-overview-stream'), smeta = $('rp-overview-stream-meta');\n if(smeta) smeta.textContent = 'last '+(data.history?data.history.length:0);\n var hist = data.history || [];\n if(!hist.length){ stream.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">no recent activity</span></div>'; }\n else {\n var hhtml='';\n for(var i=0;i<hist.length;i++){\n var ev = hist[i];\n var ts = ev.timestamp;\n var cat = ev.category || '';\n var act = ev.action || '';\n var ent = (ev.entity_type||'')+(ev.entity_id?(' \u00B7 '+ev.entity_id):'');\n var det = ev.details || '';\n if(typeof det==='object') det = JSON.stringify(det);\n hhtml += '<div class=\"line\"><span class=\"t\">'+esc(clock(ts))+'</span>'\n + '<span class=\"ic\">'+esc(rpCategoryIcon(cat))+'</span>'\n + '<span class=\"n\" title=\"'+esc(cat)+'\">'+esc(String(cat).slice(0,8))+'</span>'\n + '<span class=\"d\"><b>'+esc(act)+'</b> '+esc(ent)+(det?(' \u2014 '+esc(String(det).slice(0,60))):'')+'</span></div>';\n }\n stream.innerHTML = hhtml;\n }\n // update overview \"now\" indicator\n var nowEl = $('rp-overview-now'); if(nowEl) nowEl.textContent = sessions.length+' recent \u00B7 '+active+' active';\n }\n function drawOverviewChart(canvas, buckets){\n if(!canvas || !canvas.getContext) return;\n var ctx = canvas.getContext('2d');\n var dpr = Math.max(1, Math.min(2, window.devicePixelRatio||1));\n var w = canvas.clientWidth, h = canvas.clientHeight;\n canvas.width = w*dpr; canvas.height = h*dpr;\n ctx.setTransform(dpr,0,0,dpr,0,0);\n ctx.clearRect(0,0,w,h);\n var padL=20, padR=8, padT=8, padB=14;\n var plotW = w - padL - padR, plotH = h - padT - padB;\n var mx = Math.max(1, Math.max.apply(null, buckets));\n // y grid (4 lines)\n ctx.strokeStyle = 'rgba(255,255,255,0.07)';\n ctx.lineWidth = 1;\n for(var g=0;g<=4;g++){\n var y = padT + (plotH * g/4);\n ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(padL+plotW,y); ctx.stroke();\n ctx.fillStyle = 'rgba(158,147,180,0.6)';\n ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';\n ctx.fillText(Math.round(mx-(mx*g/4)), 2, y+3);\n }\n // bars\n var n = buckets.length, barW = plotW/n;\n for(var i=0;i<n;i++){\n var v = buckets[i];\n var bh = (v/mx)*plotH;\n var x = padL + i*barW + 1;\n var y = padT + (plotH - bh);\n try{\n var grad = ctx.createLinearGradient(0,y,0,y+bh);\n grad.addColorStop(0, 'rgba(255,216,107,0.95)');\n grad.addColorStop(1, 'rgba(230,178,60,0.55)');\n ctx.fillStyle = grad;\n }catch(e){ ctx.fillStyle = '#E6B23C'; }\n ctx.fillRect(x, y, Math.max(1,barW-2), bh);\n }\n // x axis labels\n ctx.fillStyle = 'rgba(158,147,180,0.7)';\n ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';\n ctx.fillText('-60m', padL, h-2);\n var mid = Math.round(n/2);\n ctx.fillText('-30m', padL + mid*barW, h-2);\n ctx.fillText('now', padL + (n-1)*barW - 16, h-2);\n }\n\n // \u2500\u2500 init sub-tabs when the robopark tab is opened \u2500\u2500\n // We call buildRpSubTabs() the first time the user switches to the\n // Sessions & Telemetry main tab. From then on, setRpSubTab(name) reuses\n // it whenever the user picks a different sub-tab.\n var rpSubTabsInited = false;\n function initRpSubTabs(){\n if(rpSubTabsInited) return;\n rpSubTabsInited = true;\n buildRpSubTabs();\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 _lastScan = {};\n // Latest per-robot scheduler telemetry, keyed by scheduler robot name\n // (e.g. \"jaguar\") or id. Updated by pollRobopark() on every refresh\n // (every 3s) via Park.updateTelemetry(...). The Park node \u2192 telemetry\n // match is done in lookupTelemetry() by name.\n var _telemetryByName = {};\n // Remember the most recent in-session and ended timestamps per robot\n // so the \"ended\" ring can fade out for ~4s after a session ends,\n // and the \"live/talking\" status can persist briefly across polls.\n var _statusMem = {}; // name -> { lastActiveAt, lastEndedAt, prevReadiness }\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, pipelineTimer=null, deviceSelectorsReady=false;\n var previewOn=false, previewTimer=null; // on-demand operator camera preview\n var visionOn=false, visionRetryTimer=null; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)\n var lkRoom=null, micAnalyser=null, micMonitorSource=null, micMonitorGain=null, audioCtx=null, lkAudioEls=[], micMonitorConnecting=false, micMonitorState='no active conversation session', micMonitorAudible=false, micMonitorVolume=0.8;\n var imgs={}; // preloaded robot concept avatars\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // Plaza footprint and parking lot (upper-right corner of the plaza)\n var P_X0=0.2, P_Y0=-0.10, P_X1=PARK_W-0.2, P_Y1=PARK_D-0.45;\n // Plaza outline as a polygon \u2014 top edge stays straight, bottom-left dips up where the pond\n // cuts into the plaza, and the right edge curves slightly inward to match the aerial.\n var PLAZA_POLY=[\n [0.4, -0.10],\n [8.6, -0.10],\n [8.6, 1.7 ],\n [14.6, 1.7 ],\n [14.6, 5.5 ],\n [3.0, 5.5 ],\n [2.5, 5.0 ],\n [0.4, 4.5 ]\n ];\n var LOT_X0=9.0, LOT_Y0=-0.30, LOT_X1=14.8, LOT_Y1=1.6;\n // Beach strip at the very bottom edge of the park\n var BEACH_Y0=PARK_D-0.45, BEACH_Y1=PARK_D+0.3;\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: two staggered rows on the plaza, between the robot pad columns.\n // Robot pads in normalized 0..1: HUB(0.06,0.16), VOLT(0.18,0.26), TESLA(0.38,0.30),\n // JAGUAR(0.58,0.34), VIXEN(0.40,0.68), PANDA(0.82,0.52)\n // Back row of palms at y=0.22, between the robots at the top of the plaza.\n // Front row at y=0.62, between/around the lower robots (VIXEN/PANDA).\n // Plus 2 grass palms near the upper-left for the tent area.\n var PALMS=[\n [0.30,0.18],[0.50,0.18],[0.70,0.18],[0.90,0.18],\n [0.18,0.55],[0.28,0.55],[0.50,0.55],[0.65,0.55],\n [0.05,0.42],[0.05,0.62]\n ];\n // Pond / curved water feature at the lower-left of the plaza.\n var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};\n // Street traffic along the top road. Each car has x, y (world), color,\n // a per-car base speed (world units / second), a direction (+1 = right,\n // -1 = left), and a per-frame jitter range so cars never settle into\n // a stable cluster. y is one of two lane bands (\u2248 -0.65 for outbound,\n // \u2248 -1.18 for inbound) so cars don't overlap.\n //\n // Stability fix: the previous version always re-spawned a wrapped car\n // at x = -0.5 (or PARK_W + 0.5) \u2014 every car in the same lane teleported\n // to the same spot, so the slowest car instantly caught the fastest\n // one and the whole lane collapsed into a clump. We now (a) spawn\n // each car at a random x within the visible road, and (b) add a\n // \u00B15% per-frame speed jitter so cars gradually drift apart. Speeds\n // are also more spread (2.2 \u2192 4.6) so the natural ordering stays\n // stable long enough to look like real traffic.\n var ROAD_Y0=-1.5, ROAD_Y1=-0.35;\n var CARS=[\n {x:1.5, y:-0.65, color:'#f2c14e', speed:2.4, dir:+1},\n {x:6.5, y:-0.65, color:'#e0a23c', speed:3.6, dir:+1},\n {x:11.5, y:-0.65, color:'#d8d8e0', speed:4.4, dir:+1},\n {x:4.0, y:-1.18, color:'#7fb4ff', speed:2.6, dir:-1},\n {x:9.0, y:-1.18, color:'#ff7e6e', speed:3.4, dir:-1},\n {x:0.5, y:-1.18, color:'#5b8bd6', speed:4.2, dir:-1},\n ];\n // Per-car phase offsets so headlights / taillights / wheels don't all\n // blink in lockstep \u2014 gives a natural \"different cars, different\n // speeds\" feel.\n var _carPhase = CARS.map(function(_, i){ return i * 0.7; });\n // \u2500\u2500 Walking people \u2014 looping sprites that stroll along fixed plaza paths.\n // Each walker is a small canvas-drawn figure (head + body + two\n // swinging legs + swinging arms) with a vertical bob and a slight\n // body lean. A path is a list of [wx, wy] world points; the walker\n // eases between them at a per-walker speed, and \"greets\" any robot\n // pad it passes closely by pausing for ~1.4s in a wave pose while\n // the robot's pulse fires. Kept simple on purpose: no canvas\n // pathfinding, no random spawn, no state machine beyond a\n // pauseUntil timer. \u2500\u2500\n // Paths are aligned with the plaza chessboard so walkers appear to\n // step across the tiles. Coordinates are world units; the iso()\n // projection in drawWalker() handles the perspective.\n //\n // Six walkers total, distributed so at any moment at least a few are\n // visible across the plaza. segDurMs is how long a single segment\n // should take at this walker's pace \u2014 converting to a per-frame\n // step value happens in tickWalkers() so speeds are frame-rate\n // independent. ~3-4 seconds per segment reads as a calm stroll;\n // shorter = brisker.\n var WALKER_PATHS = [\n // Top of plaza \u2014 slow stroller between the hub and front row of robots. L\u2192R.\n { segDurMs: 5200, shirt:'#5b9bd5', pants:'#2a3f5c', pauseAt:[[3.5,0.4]] },\n // Lower half \u2014 loops past VIXEN and PANDA pads. R\u2192L.\n { segDurMs: 4200, shirt:'#c95b6f', pants:'#3a2a3a', pauseAt:[[11.0,5.1]] },\n // Mid-plaza \u2014 wanders past TESLA. L\u2192R.\n { segDurMs: 4600, shirt:'#7fb069', pants:'#3d4f2c', pauseAt:[[5.6,1.6]] },\n // Back of plaza \u2014 a quiet walker along the upper tiles. R\u2192L.\n { segDurMs: 6000, shirt:'#d68a3a', pants:'#4a3a26', pauseAt:[[8.0,-0.1]] },\n // Diagonal \u2014 crosses the plaza between JAGUAR and PANDA. L\u2192R.\n { segDurMs: 5000, shirt:'#a26fb0', pants:'#3a2a4a', pauseAt:[[7.0,3.4]] },\n // Slow elder at the front edge of the plaza. R\u2192L.\n { segDurMs: 7000, shirt:'#e8c39a', pants:'#5a4a2a', pauseAt:[[9.5,2.6]] },\n ];\n // Per-walker offsets so they don't all start at the same phase of\n // their loop \u2014 makes the plaza feel populated at all times rather\n // than empty for the first few seconds. Offsets are fractions of\n // total path length (0..1) so they spread evenly.\n var WALKER_PHASE_OFFSETS = [0.00, 0.18, 0.42, 0.66, 0.82, 0.30];\n // Build the world-space path for each walker spec. Path idx chooses\n // the route; all paths live on the plaza chessboard so walkers look\n // like they step across the tiles.\n function buildWalkerPath(spec){\n if(spec.idx === 0){\n return [[1.0,0.3],[3.5,0.4],[7.5,0.4],[9.8,0.3],[9.8,1.6],[1.0,1.6]];\n }\n if(spec.idx === 1){\n return [[13.0,3.6],[11.0,5.1],[8.0,5.4],[5.0,5.2],[3.0,4.6],[1.4,3.4]];\n }\n if(spec.idx === 2){\n return [[0.4,2.2],[3.0,2.4],[5.6,1.6],[8.4,1.6],[11.2,2.0],[13.4,2.2]];\n }\n if(spec.idx === 3){\n return [[13.5,-0.1],[11.0,-0.05],[8.0,-0.1],[5.0,-0.05],[2.4,0.1],[2.4,0.9]];\n }\n if(spec.idx === 4){\n return [[0.4,4.0],[3.0,3.6],[7.0,3.4],[10.0,3.8],[12.6,4.2],[12.6,5.2]];\n }\n // idx 5\n return [[13.4,2.6],[9.5,2.6],[6.0,2.8],[3.0,2.6],[0.4,2.4],[0.4,1.6]];\n }\n // Cumulative arc-length table for each walker's polyline, used to\n // convert a phase offset (0..1) into a (seg, segT) starting position\n // and to drive the per-frame step so all walkers cover ground at a\n // consistent world-space pace regardless of segment length.\n function buildPathLengths(path){\n var lens = [0];\n var total = 0;\n for(var i=0; i<path.length-1; i++){\n var dx = path[i+1][0]-path[i][0], dy = path[i+1][1]-path[i][1];\n total += Math.sqrt(dx*dx + dy*dy);\n lens.push(total);\n }\n return { lens: lens, total: total || 1 };\n }\n function pathPosAt(pathInfo, dist){\n // Returns {seg, t} for the given arc-length distance along the path.\n var d = ((dist % pathInfo.total) + pathInfo.total) % pathInfo.total;\n for(var i=0; i<pathInfo.lens.length-1; i++){\n if(d <= pathInfo.lens[i+1]){\n var segLen = pathInfo.lens[i+1] - pathInfo.lens[i] || 1;\n return { seg: i, t: (d - pathInfo.lens[i]) / segLen };\n }\n }\n return { seg: 0, t: 0 };\n }\n var walkers = WALKER_PATHS.map(function(spec, idx){\n var path = buildWalkerPath({idx:idx});\n var info = buildPathLengths(path);\n var phase = WALKER_PHASE_OFFSETS[idx % WALKER_PHASE_OFFSETS.length] || 0;\n var startPos = pathPosAt(info, phase * info.total);\n return {\n idx: idx,\n spec: spec,\n path: path,\n info: info,\n distance: startPos.seg + startPos.t, // current distance (in segments) along the path\n pauseUntil: 0, // perf.now() ms; walker holds while now < pauseUntil\n nextGreet: 0, // index into spec.pauseAt, for the next pause to fire\n };\n });\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 // \u2500\u2500 Per-robot scheduler telemetry \u2192 status \u2500\u2500\n // pollRobopark() pushes the latest /robopark/api/telemetry rows into\n // Park.updateTelemetry(); we key them by robot name. layout() then\n // looks up each robot node by its slot aliases and attaches a\n // status + telemetry field used by drawRobot() to drive the\n // status animations.\n function updateTelemetry(map){\n _telemetryByName = map || {};\n }\n function lookupTelemetry(node){\n if(!node) return null;\n // The hub is never a per-robot telemetry row \u2014 only robot slots are.\n if(node.kind !== 'robot') return null;\n // Try every alias the slot was built with: slot.label, slot.id,\n // slot.sub, slot.keys, and the matched mesh node's displayName.\n var slot = null;\n for(var i=0;i<SLOTS.length;i++){ if(SLOTS[i].label === node.label){ slot = SLOTS[i]; break; } }\n var aliases = [];\n if(slot) aliases = aliases.concat(slotAliases(slot));\n if(node.name) aliases.push(String(node.name).toLowerCase());\n if(node.telemKeys) for(var tk=0; tk<node.telemKeys.length; tk++) aliases.push(node.telemKeys[tk]);\n // Try each alias as a case-insensitive key against the telemetry map.\n for(var ai=0; ai<aliases.length; ai++){\n var a = aliases[ai];\n if(!a) continue;\n for(var key in _telemetryByName){\n if(!Object.prototype.hasOwnProperty.call(_telemetryByName, key)) continue;\n var kl = String(key).toLowerCase();\n if(kl === a || kl.indexOf(a) >= 0 || a.indexOf(kl) >= 0) return _telemetryByName[key];\n }\n }\n return null;\n }\n // Derive a coarse status string + a few extra flags from a\n // scheduler telemetry row. Status is one of: 'offline' | 'ready'\n // | 'live' | 'talking' | 'ended'. We also expose the raw readiness\n // string so the existing label can still read it.\n function computeStatus(tele, node){\n if(!node || node.kind !== 'robot') return { status:'idle', readiness:null };\n if(!node.connected) return { status:'offline', readiness:null };\n var mem = _statusMem[node.id] || (_statusMem[node.id] = { lastActiveAt:0, lastEndedAt:0, prevReadiness:null });\n var readiness = tele ? (tele.readiness_code || 'unknown') : null;\n var hasSession = !!(tele && (tele.current_session_id || tele.active_session || tele.in_session || tele.session_active));\n var sessionAge = tele ? (tele.session_age_seconds != null ? tele.session_age_seconds : null) : null;\n var activityAge = tele ? (tele.session_activity_age_seconds != null ? tele.session_activity_age_seconds : null) : null;\n var now = performance.now();\n // mark transitions\n if(readiness === 'in_session' && mem.prevReadiness !== 'in_session'){\n mem.lastActiveAt = now;\n }\n if(mem.prevReadiness === 'in_session' && readiness !== 'in_session'){\n mem.lastEndedAt = now;\n }\n mem.prevReadiness = readiness;\n // decide status\n var status = 'ready';\n if(hasSession){\n // active session. \"talking\" if the session has had activity\n // within the last 2.5s; otherwise \"live\" (idle but connected).\n if(activityAge != null && activityAge <= 2.5) status = 'talking';\n else status = 'live';\n } else if(readiness === 'in_session'){\n // server says in_session but we don't have the session id on\n // this row \u2014 treat as live.\n status = 'live';\n } else if(readiness && /stuck_session|stale_session/.test(readiness)){\n status = 'live'; // still a session going on, just unhealthy\n } else if(readiness === 'ready'){\n // Show \"ended\" ring for ~4s after a session ends, then drop back\n // to the calm \"ready\" (waiting) state.\n if(mem.lastEndedAt && (now - mem.lastEndedAt) < 4000) status = 'ended';\n else status = 'ready';\n } else if(readiness && /production_off|production_off_robot|device_missing|server_unavailable|heartbeat_stale/.test(readiness)){\n status = 'ready'; // still ready to talk, just gated\n }\n return {\n status: status,\n readiness: readiness,\n readinessMessage: tele ? tele.readiness_message : null,\n sessionAge: sessionAge,\n activityAge: activityAge,\n triggerCount: tele ? tele.trigger_count : null,\n drops: tele ? tele.drops : null,\n dropRate: tele ? tele.drop_rate : null,\n };\n }\n\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 var node = {\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 schedulerRobotId: match?(match.schedulerRobotId||match.schedulerDeviceId||''):'',\n schedulerDeviceId: match?(match.schedulerDeviceId||match.schedulerRobotId||''):'',\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n // slotId is the stable handle we use to persist operator-\n // chosen positions. slotNx/slotNy are the preset's default\n // coordinates so the user can always reset back.\n slotId: slot.id, slotNx: slot.nx, slotNy: slot.ny,\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n // Attach a status derived from the latest scheduler telemetry\n // (push-poll, so it can lag up to 3s behind /fed/status).\n if(kind === 'robot'){\n var tele = lookupTelemetry(node);\n var st = computeStatus(tele, node);\n node.telemetry = tele;\n node.status = st.status;\n node.readiness = st.readiness;\n node.readinessMessage = st.readinessMessage;\n node.sessionAge = st.sessionAge;\n node.activityAge = st.activityAge;\n }\n return node;\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 // re-apply any persisted Ctrl+drag overrides on top of the\n // fresh layout (the server may have also returned updated\n // slot nx/ny \u2014 those go through the override map too)\n _applyPosOverrides();\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 // State for Ctrl+drag robot repositioning on the park map. When\n // the user holds Ctrl and mousedown's a robot, the canvas stops\n // panning and instead drags that node around. On mouseup, the\n // new (nx, ny) is written to _parkPosOverrides and pushed to\n // the scheduler via a debounced PUT. Survives reloads because\n // the server stores nx/ny in the character_presets table.\n var _parkPosOverrides = {}; // presetId -> {nx, ny} (normalized 0..1)\n var _dragNode = null; // the robot node currently being dragged\n var _dragOffset = {x:0, y:0}; // screen-space offset from node center\n var _posSaveTimer = null; // debounce timer for the server PUT\n var _posSaveQueue = {}; // presetId -> {nx, ny} pending save\n function _isoToWorld(sx, sy){\n // Inverse of iso() in the same coordinate system. Given a\n // screen point, return the (wx, wy) world point. Used by the\n // Ctrl+drag handler to translate a mouse position back into\n // the iso coordinate space so the robot follows the cursor.\n var dx = (sx - (W-300)/2 - cam.x) / (TW*cam.z);\n var dy = (sy - H*0.42 - cam.y) / (TH*cam.z);\n // from iso(): dx = (wx-PARK_W/2) - (wy-PARK_D/2); dy = (wx-PARK_W/2) + (wy-PARK_D/2)\n // so wx = (dx+dy)/2 + PARK_W/2; wy = (dy-dx)/2 + PARK_D/2\n return { x: (dx+dy)*0.5 + PARK_W*0.5, y: (dy-dx)*0.5 + PARK_D*0.5 };\n }\n function _applyPosOverrides(){\n // Walk nodes and override their wx/wy from _parkPosOverrides if\n // we have a stored position. The slot's default nx/ny stays in\n // node.slotNx/slotNy so the user can \"reset\" back to defaults\n // if we ever expose that control.\n for(var i=0; i<nodes.length; i++){\n var n = nodes[i];\n if(!n.slotId) continue;\n var ov = _parkPosOverrides[n.slotId];\n if(ov){\n n.wx = Math.max(0, Math.min(PARK_W, ov.nx * PARK_W));\n n.wy = Math.max(0, Math.min(PARK_D, ov.ny * PARK_D));\n n._posOverridden = true;\n } else if(n._posOverridden){\n // The server just gave us a fresh layout and we don't have\n // a local override \u2014 fall back to the slot's default position.\n n.wx = n.slotNx * PARK_W;\n n.wy = n.slotNy * PARK_D;\n n._posOverridden = false;\n }\n }\n }\n function _schedulePosSave(){\n // Debounce: collapse a flurry of drag-end events into one\n // PUT after 350ms of quiet. Saves queue is merged so the final\n // PUT always reflects the latest position for each preset.\n if(_posSaveTimer) clearTimeout(_posSaveTimer);\n _posSaveTimer = setTimeout(_flushPosSaves, 350);\n }\n function _flushPosSaves(){\n var entries = [];\n for(var id in _posSaveQueue){\n if(Object.prototype.hasOwnProperty.call(_posSaveQueue, id)){\n entries.push({ id: id, nx: _posSaveQueue[id].nx, ny: _posSaveQueue[id].ny });\n }\n }\n _posSaveQueue = {};\n _posSaveTimer = null;\n if(!entries.length) return;\n // Persist to the scheduler. We POST to the dedicated\n // /api/character-presets/positions batch endpoint which clamps\n // nx/ny to [0.04, 0.96] so a stray drag can't park a robot\n // off the island.\n var body = JSON.stringify({ positions: entries });\n try{\n fetch('/api/character-presets/positions'+QS, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: body,\n keepalive: true\n }).then(function(r){\n if(r.ok) return r.json();\n throw new Error('HTTP '+r.status);\n }).then(function(j){\n if(typeof showOut === 'function'){\n showOut('park position saved \u00B7 '+(j.updated||entries.length)+' preset'+(entries.length===1?'':'s'));\n }\n }).catch(function(err){\n if(typeof showOut === 'function') showOut('park position save failed: '+err.message);\n });\n }catch(e){ /* offline \u2014 keep override in memory */ }\n }\n function _hitTestNode(mx, my){\n // Walk all visible nodes and return the first whose hit-box\n // contains (mx, my). Mirrors the existing click hit-test\n // but returns the node directly (and skips early if a drag\n // is already in progress).\n for(var i=0; i<nodes.length; i++){\n var n = nodes[i];\n var hb = n._hit;\n if(hb && mx>=hb.x && mx<=hb.x+hb.w && my>=hb.y && my<=hb.y+hb.h){\n return n;\n }\n }\n return null;\n }\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 // Panning: drag on the canvas (no Ctrl) pans the iso camera.\n // Ctrl+drag on a robot reposition the robot on the park map.\n // Each gesture is mutually exclusive \u2014 we keep one _isPanning\n // flag and one _dragNode at a time.\n var _isPanning = false;\n var _px = 0, _py = 0;\n C.addEventListener('mousedown',function(e){\n var r = C.getBoundingClientRect();\n var mx = e.clientX - r.left;\n var my = e.clientY - r.top;\n // Ctrl+drag: if a robot is under the cursor, pick it up.\n // Only ROBOT nodes are draggable \u2014 the hub, parked cars in the\n // lot, and ghosts for offline nodes stay locked to their slot.\n if(e.ctrlKey){\n var hit = _hitTestNode(mx, my);\n if(hit && hit.slotId && hit.kind === 'robot'){\n _dragNode = hit;\n var p = iso(hit.wx, hit.wy);\n _dragOffset.x = mx - p.x;\n _dragOffset.y = my - p.y;\n // prevent text selection while dragging\n e.preventDefault();\n return;\n }\n }\n // otherwise: start a pan\n _isPanning = true;\n _px = e.clientX; _py = e.clientY;\n });\n window.addEventListener('mouseup',function(){\n if(_dragNode){\n // commit the drag: record override, schedule server save\n var nx = _dragNode.wx / PARK_W;\n var ny = _dragNode.wy / PARK_D;\n var id = _dragNode.slotId;\n if(id){\n _parkPosOverrides[id] = { nx: nx, ny: ny };\n _posSaveQueue[id] = { nx: nx, ny: ny };\n _schedulePosSave();\n }\n _dragNode = null;\n }\n _isPanning = false;\n });\n window.addEventListener('mousemove',function(e){\n if(_dragNode){\n var r = C.getBoundingClientRect();\n var mx = e.clientX - r.left;\n var my = e.clientY - r.top;\n // screen point = mouse minus the original grab offset, so\n // the robot doesn't snap its center to the cursor\n var worldPt = _isoToWorld(mx - _dragOffset.x, my - _dragOffset.y);\n // clamp to the visible grass rectangle (with a small\n // inner margin so the avatar doesn't get clipped by the\n // gold wall or the hedge)\n var MARGIN = 0.4;\n _dragNode.wx = Math.max(MARGIN, Math.min(PARK_W - MARGIN, worldPt.x));\n _dragNode.wy = Math.max(MARGIN, Math.min(PARK_D - MARGIN, worldPt.y));\n return;\n }\n if(!_isPanning) return;\n cam.x += e.clientX - _px;\n cam.y += e.clientY - _py;\n _px = e.clientX; _py = e.clientY;\n });\n // Escape cancels an in-progress drag without saving\n window.addEventListener('keydown',function(e){\n if((e.key === 'Escape' || e.key === 'Esc') && _dragNode){\n // restore to the original override (or slot default)\n _dragNode.wx = _dragNode.slotNx * PARK_W;\n _dragNode.wy = _dragNode.slotNy * PARK_D;\n _dragNode = null;\n }\n });\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 // Suppress the click that fires at the end of a drag \u2014 we\n // don't want a single drag-release to also open the drawer.\n if(_dragNode) return;\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(now){\n // now is optional \u2014 only the road cars and water effects need a\n // wall-clock. We pass performance.now() in from frame() so every\n // animation reads the same time.\n if(typeof now !== 'number') now = performance.now();\n var _now = now;\n var z=cam.z;\n // \u2500\u2500 grass base \u2014 single uniform mid-green, no radial brightening \u2500\u2500\n // Park sits on a floating island now, so we want a flat, calm\n // grass tone across the whole field. The previous radial gradient\n // pushed a lighter green toward the center which read as a \"spot\"\n // \u2014 replaced with a single soft fill so the island feels like a\n // single piece of turf.\n var parkCx=PARK_W/2, parkCy=PARK_D/2;\n var gCent=iso(parkCx,parkCy);\n var gRad=X.createRadialGradient(gCent.x,gCent.y,12*z,gCent.x,gCent.y,Math.max(W,H)*0.62);\n gRad.addColorStop(0,'rgba(94,150,84,0.95)');\n gRad.addColorStop(0.5,'rgba(86,140,76,0.95)');\n gRad.addColorStop(1,'rgba(78,128,68,0.95)');\n // grass field \u2014 tight margin around the plaza + lot, NOT covering the road or the beach\n var GF_X0=0.0, GF_X1=PARK_W, GF_Y0=-0.32, GF_Y1=PARK_D-0.55;\n poly([[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]], gRad, 'rgba(140,200,150,0.10)',1);\n // (mowed-stripe effect removed \u2014 was the lighter green alternating\n // bands on the sides of the park. Keeping the lawn as a single\n // uniform tone so the park reads as a clean floating island.)\n // \u2500\u2500 outer hedge / foliage border just inside the grass field \u2500\u2500\n // Slightly ragged inner edge: extra points along each side to break the straight line.\n // The hedge wraps the grass field, which now hugs the plaza footprint (no side overhang).\n var hedgeW=0.9; // world units thick\n var pTL=[GF_X0,GF_Y0], pTR=[GF_X1,GF_Y0], pBR=[GF_X1,GF_Y1], pBL=[GF_X0,GF_Y1];\n // Build a ragged inner-edge polygon: along the inner edge we add jitter points.\n // We use 4 sides, each with 4 extra points (5 segments per side), jitter \u00B10.18.\n function jx(seed){ return ((Math.sin(seed*12.9898)*43758.5453)%1+1)%1; }\n function hedgeInnerPts(){\n // Returns an inner ring (hedge width hedgeW from outer), starting at top-left and going CW.\n var pts=[];\n // top edge: from pTL toward pTR (jitter along y)\n var segs=6;\n for(var i=0;i<=segs;i++){\n var t=i/segs;\n var x=pTL[0]+(pTR[0]-pTL[0])*t;\n var y=pTL[1]+hedgeW + (jx(i*3+1)-0.5)*0.34;\n pts.push([x,y]);\n }\n // right edge: from pTR toward pBR (jitter along x)\n for(var i2=1;i2<=segs;i2++){\n var t2=i2/segs;\n var x2=pTR[0]-hedgeW + (jx(i2*5+7)-0.5)*0.34;\n var y2=pTR[1]+(pBR[1]-pTR[1])*t2;\n pts.push([x2,y2]);\n }\n // bottom edge: from pBR toward pBL (jitter along y, reversed)\n for(var i3=1;i3<=segs;i3++){\n var t3=i3/segs;\n var x3=pBR[0]+(pBL[0]-pBR[0])*t3;\n var y3=pBR[1]-hedgeW + (jx(i3*7+13)-0.5)*0.34;\n pts.push([x3,y3]);\n }\n // left edge: from pBL toward pTL (jitter along x, reversed)\n for(var i4=1;i4<segs;i4++){\n var t4=i4/segs;\n var x4=pBL[0]+hedgeW + (jx(i4*11+19)-0.5)*0.34;\n var y4=pBL[1]+(pTL[1]-pBL[1])*t4;\n pts.push([x4,y4]);\n }\n return pts;\n }\n var innerRing=hedgeInnerPts();\n // full grass outer polygon = outer rect, then inner ring (use even-odd via closePath)\n X.save();\n X.beginPath();\n var outerRect=[[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]];\n for(var oi=0;oi<outerRect.length;oi++){ var op=iso(outerRect[oi][0],outerRect[oi][1]); if(oi===0)X.moveTo(op.x,op.y); else X.lineTo(op.x,op.y); }\n X.closePath();\n for(var ii=0;ii<innerRing.length;ii++){ var ip=iso(innerRing[ii][0],innerRing[ii][1]); if(ii===0)X.moveTo(ip.x,ip.y); else X.lineTo(ip.x,ip.y); }\n X.closePath();\n X.fillStyle='rgba(28,68,38,0.95)'; X.fill('evenodd');\n X.restore();\n // thin lighter inner rim ~0.15 inside the inner edge\n function innerRimPts(){\n var rim=[];\n var segs=6;\n for(var i=0;i<=segs;i++){ var t=i/segs; rim.push([(pTL[0]+(pTR[0]-pTL[0])*t), (pTL[1]+hedgeW-0.15) + (jx(i*3+1)-0.5)*0.30]); }\n for(var i2=1;i2<=segs;i2++){ var t2=i2/segs; rim.push([(pTR[0]-hedgeW+0.15) + (jx(i2*5+7)-0.5)*0.30, (pTR[1]+(pBR[1]-pTR[1])*t2)]); }\n for(var i3=1;i3<=segs;i3++){ var t3=i3/segs; rim.push([(pBR[0]+(pBL[0]-pBR[0])*t3), (pBR[1]-hedgeW+0.15) + (jx(i3*7+13)-0.5)*0.30]); }\n for(var i4=1;i4<segs;i4++){ var t4=i4/segs; rim.push([(pBL[0]+hedgeW-0.15) + (jx(i4*11+19)-0.5)*0.30, (pBL[1]+(pTL[1]-pBL[1])*t4)]); }\n return rim;\n }\n var rimPts=innerRimPts();\n X.save();\n X.beginPath();\n for(var ri2=0;ri2<rimPts.length;ri2++){ var rp=iso(rimPts[ri2][0],rimPts[ri2][1]); if(ri2===0)X.moveTo(rp.x,rp.y); else X.lineTo(rp.x,rp.y); }\n X.closePath();\n X.strokeStyle='rgba(96,148,86,0.7)'; X.lineWidth=0.8*z; X.stroke();\n X.restore();\n // grass noise \u2014 scattered darker tufts for texture (no lighter\n // tufts, so the field stays a single uniform mid-green)\n X.save();\n for(var ni=0;ni<40;ni++){\n var gnx=GF_X0 + ((ni*37)%100)/100*(GF_X1-GF_X0);\n var gny=GF_Y0 + ((ni*53)%100)/100*(GF_Y1-GF_Y0);\n var gp=iso(gnx,gny);\n X.fillStyle='rgba(56,112,62,0.22)';\n X.beginPath(); X.arc(gp.x+((ni*7)%13-6)*z,gp.y+((ni*11)%9-4)*z,1.6*z,0,7); X.fill();\n }\n X.restore();\n // \u2500\u2500 GOLD BORDER + FLOATING ISLAND DEPTH \u2500\u2500\n // The whole park now reads as a single floating slab of turf in\n // the sky. We render (in world space, around the grass field):\n // 1. a soft drop-shadow ellipse on the sky below the island\n // 2. a tapered rocky underside (darker, hanging from the edge)\n // 3. a thick gold rim tracing the grass perimeter\n // 4. a thin warm-glow outer ring (sells the gold as \"lit by\n // the warm sun\")\n // The underside and drop shadow give the park real depth so it\n // doesn't look like a flat sticker on the sky.\n // Build the grass perimeter polygon once (the same corners the\n // grass field uses, so the gold wall's top edge sits flush\n // against the grass). We use grassPerim(0) \u2014 no offset \u2014 for\n // the walls, and the perimeter itself is used again later for\n // the bottom face of the floating island (see UNDERSIDE_LIFT).\n function grassPerim(offset){\n // Use the same world corners as the grass field, offset outward.\n var o = offset;\n return [\n [GF_X0-o, GF_Y0-o],\n [GF_X1+o, GF_Y0-o],\n [GF_X1+o, GF_Y1+o],\n [GF_X0-o, GF_Y1+o]\n ];\n }\n // 1. drop shadow on the sky \u2014 a wide soft ellipse below the\n // island's bottom edge (in screen space, large + soft).\n var botL = iso(GF_X0, GF_Y1);\n var botR = iso(GF_X1, GF_Y1);\n var botM = { x:(botL.x+botR.x)/2, y:(botL.y+botR.y)/2 };\n var islandWidth = Math.abs(botR.x - botL.x);\n var shadowCx = botM.x;\n var shadowCy = botM.y + 28*z;\n var shadowRX = islandWidth * 0.55;\n var shadowRY = 22 * z;\n var dropShadow = X.createRadialGradient(shadowCx, shadowCy, 0, shadowCx, shadowCy, shadowRX);\n dropShadow.addColorStop(0, 'rgba(20, 24, 36, 0.55)');\n dropShadow.addColorStop(0.5, 'rgba(20, 24, 36, 0.25)');\n dropShadow.addColorStop(1, 'rgba(20, 24, 36, 0)');\n X.fillStyle = dropShadow;\n X.beginPath();\n X.ellipse(shadowCx, shadowCy, shadowRX, shadowRY, 0, 0, Math.PI*2);\n X.fill();\n // 2. rocky underside \u2014 draw the grass field polygon again, but\n // offset down by ~14*z and slightly darker, then close it into\n // a wedge. We do this with two filled paths:\n // - the bottom face: same perimeter polygon shifted down\n // - the side rim: a band that traces from the top of the\n // bottom face up to the top of the island (so the side\n // of the slab reads as dirt/rock)\n var UNDERSIDE_LIFT = 16 * z;\n var topPerim = grassPerim(0);\n var botPerim = topPerim.map(function(p){ return [p[0], p[1] + UNDERSIDE_LIFT / (TH*z) * (1/22)]; });\n // Convert botPerim into screen-space points (slightly down from top).\n var topScreen = topPerim.map(function(p){ return iso(p[0], p[1]); });\n var botScreen = topPerim.map(function(p){ return iso(p[0], p[1] + 1.0); });\n // right side face\n X.save();\n X.fillStyle = 'rgba(38, 50, 36, 0.92)';\n X.beginPath();\n X.moveTo(topScreen[1].x, topScreen[1].y);\n X.lineTo(topScreen[2].x, topScreen[2].y);\n X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);\n X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);\n X.closePath();\n X.fill();\n // bottom-right edge highlight (slight bevel)\n X.strokeStyle = 'rgba(80, 96, 60, 0.55)';\n X.lineWidth = 1.0*z;\n X.beginPath();\n X.moveTo(topScreen[2].x, topScreen[2].y);\n X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);\n X.stroke();\n // left side face\n X.fillStyle = 'rgba(28, 38, 26, 0.95)';\n X.beginPath();\n X.moveTo(topScreen[0].x, topScreen[0].y);\n X.lineTo(topScreen[1].x, topScreen[1].y);\n X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);\n X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);\n X.closePath();\n X.fill();\n // bottom-left edge highlight\n X.strokeStyle = 'rgba(70, 84, 52, 0.45)';\n X.beginPath();\n X.moveTo(topScreen[0].x, topScreen[0].y);\n X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);\n X.stroke();\n // bottom face \u2014 slightly tapered into a rough point so the slab\n // doesn't look like a flat box. We taper the bottom by ~25%.\n var tapered = topScreen.map(function(s, i){\n if(i === 0) return { x: s.x + 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };\n if(i === 1) return { x: s.x - 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };\n if(i === 2) return { x: s.x - 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };\n if(i === 3) return { x: s.x + 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };\n return { x: s.x, y: s.y + UNDERSIDE_LIFT };\n });\n X.fillStyle = 'rgba(20, 28, 20, 0.92)';\n X.beginPath();\n X.moveTo(topScreen[0].x, topScreen[0].y);\n X.lineTo(topScreen[1].x, topScreen[1].y);\n X.lineTo(tapered[1].x, tapered[1].y);\n X.lineTo(tapered[2].x, tapered[2].y);\n X.lineTo(tapered[3].x, tapered[3].y);\n X.lineTo(tapered[0].x, tapered[0].y);\n X.closePath();\n X.fill();\n // a few rocky stalactite bumps hanging from the underside so it\n // doesn't look like a perfect box either\n var bumpSpots = [\n { px: 0.18, len: 0.65 },\n { px: 0.34, len: 0.85 },\n { px: 0.52, len: 0.55 },\n { px: 0.68, len: 0.95 },\n { px: 0.86, len: 0.70 }\n ];\n for(var bpi=0; bpi<bumpSpots.length; bpi++){\n var bx = GF_X0 + bumpSpots[bpi].px * (GF_X1-GF_X0);\n var by = GF_Y1;\n var bp = iso(bx, by);\n var bpLen = bumpSpots[bpi].len * UNDERSIDE_LIFT;\n X.fillStyle = 'rgba(24, 32, 22, 0.88)';\n X.beginPath();\n X.moveTo(bp.x - 14*z, bp.y + 2*z);\n // 2-sided stalactite that tapers to a point\n X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x, bp.y + bpLen);\n X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x + 14*z, bp.y + 2*z);\n X.closePath();\n X.fill();\n }\n X.restore();\n // 3. RAISED GOLD WALLS \u2014 proper 3D parapet around the park.\n // The wall follows the grass perimeter (4-corner rectangle).\n // Each side has a visible front face that extends downward in\n // screen space, plus a thin top-edge highlight that catches\n // the warm sun light. Drawn AFTER the underside so the top\n // edge of the wall sits flush with the top of the grass.\n //\n // Geometry: the wall's TOP edge is the grass perimeter line.\n // Its BOTTOM edge is the same line shifted down by WALL_H\n // pixels. We draw each of the 4 sides as a quad (TL, TR,\n // BR, BL), then trace a thin gold line along the top of\n // each side for the highlight.\n var rimInner = grassPerim(0);\n var rimInnerScreen = rimInner.map(function(p){ return iso(p[0], p[1]); });\n var WALL_H = 26 * z; // wall height in screen pixels\n // Build bottom-edge screen points by shifting each top point\n // down by WALL_H.\n var rimBotScreen = rimInnerScreen.map(function(s){\n return { x: s.x, y: s.y + WALL_H };\n });\n // Front face: a single closed polygon (the grass perimeter top\n // + the grass perimeter bottom, traversed in reverse). Drawn\n // in two passes \u2014 first a darker base, then a lighter overlay\n // stripe near the top to give the wall some \"warm-light from\n // above\" shading.\n X.save();\n // base wall (slightly darker gold)\n X.fillStyle = 'rgba(176, 124, 44, 0.96)';\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);\n X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);\n X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);\n X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);\n X.closePath();\n X.fill();\n // warm-light gradient overlay \u2014 fades from bright gold at the\n // top of the wall to transparent at the bottom, so the wall\n // reads as if the top is catching the sun and the bottom is\n // in shadow.\n var wallGrad = X.createLinearGradient(0, rimInnerScreen[0].y, 0, rimInnerScreen[0].y + WALL_H);\n wallGrad.addColorStop(0, 'rgba(255, 226, 132, 0.85)');\n wallGrad.addColorStop(0.35, 'rgba(255, 196, 92, 0.55)');\n wallGrad.addColorStop(1, 'rgba(176, 124, 44, 0)');\n X.fillStyle = wallGrad;\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);\n X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);\n X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);\n X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);\n X.closePath();\n X.fill();\n // outer dark base line at the bottom of the wall (the bottom\n // edge against the sky) \u2014 sells the \"raised\" effect\n X.strokeStyle = 'rgba(124, 84, 28, 0.95)';\n X.lineWidth = 1.4 * z;\n X.beginPath();\n X.moveTo(rimBotScreen[0].x, rimBotScreen[0].y);\n X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);\n X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);\n X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);\n X.closePath();\n X.stroke();\n // top edge \u2014 gold band right at the grass line, with a thin\n // lighter highlight on top to catch the sun.\n X.strokeStyle = 'rgba(196, 138, 48, 0.95)';\n X.lineWidth = 4.0 * z;\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.closePath();\n X.stroke();\n // bright top highlight (thin, hottest color)\n X.strokeStyle = 'rgba(255, 232, 158, 0.95)';\n X.lineWidth = 1.2 * z;\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.closePath();\n X.stroke();\n // corner studs \u2014 small gold dots at each of the 4 perimeter\n // vertices so the wall reads as constructed panels, not a\n // single continuous block.\n for(var sci=0; sci<rimInnerScreen.length; sci++){\n var sPt = rimInnerScreen[sci];\n X.fillStyle = 'rgba(255, 220, 140, 0.95)';\n X.beginPath(); X.arc(sPt.x, sPt.y, 2.2 * z, 0, Math.PI*2); X.fill();\n X.fillStyle = 'rgba(196, 138, 48, 0.85)';\n X.beginPath(); X.arc(sPt.x, sPt.y, 3.4 * z, 0, Math.PI*2); X.stroke();\n }\n X.restore();\n // \u2500\u2500 flower spots \u2014 deterministic small dots in 3 colors, scattered in the lawn only \u2500\u2500\n X.save();\n var flowerColors=['rgba(232,124,164,0.85)','rgba(248,220,96,0.88)','rgba(248,248,240,0.85)'];\n for(var fi=0;fi<34;fi++){\n var fx=GF_X0 + jx(fi*17+3)*(GF_X1-GF_X0);\n var fy=hedgeW + jx(fi*23+11)*(GF_Y1-hedgeW*1.4);\n // skip plaza area (P_X0..P_X1, P_Y0..P_Y1) and pond area\n if(fx>=P_X0-0.3 && fx<=P_X1+0.3 && fy>=P_Y0-0.3 && fy<=P_Y1+0.3) continue;\n var dxp=fx-POND.cx, dyp=fy-POND.cy;\n if(dxp*dxp/(POND.rx*POND.rx) + dyp*dyp/(POND.ry*POND.ry) < 1.1) continue;\n var fp=iso(fx,fy);\n X.fillStyle=flowerColors[fi%3];\n X.beginPath(); X.arc(fp.x+((fi*5)%7-3)*z, fp.y+((fi*9)%5-2)*z, 1.4*z + (fi%2)*0.5*z, 0, 7); X.fill();\n }\n X.restore();\n // beach strip at the very bottom (sandy)\n poly([[-1,BEACH_Y0],[PARK_W+1,BEACH_Y0],[PARK_W+1,BEACH_Y1],[-1,BEACH_Y1]],\n 'rgba(214,196,150,0.55)', 'rgba(180,160,120,0.5)', 1);\n // darker curb along the paved edge (drawn as a thin offset quad)\n var curbOff=0.12;\n var curbPoly=PLAZA_POLY.map(function(p){\n // expand outward a bit so the curb ring is visible just outside the plaza footprint\n return [p[0] + (p[0]<1?-curbOff:(p[0]>14?curbOff:0)), p[1] + (p[1]<-0.05?-curbOff:(p[1]>5?curbOff:0))];\n });\n poly(curbPoly, 'rgba(20,28,22,0.85)', null, 0);\n // paved plaza base \u2014 neutral stone so the tile pattern reads clearly (polygon outline)\n var pg=X.createLinearGradient(0,0,0,H);\n pg.addColorStop(0,'rgba(170,162,150,0.92)');\n pg.addColorStop(1,'rgba(132,124,114,0.92)');\n poly(PLAZA_POLY, pg, 'rgba(60,52,44,0.55)',1);\n // chessboard stone tile pattern \u2014 iterate a rectangle then clip to the plaza polygon\n X.save();\n X.beginPath();\n for(var pli=0;pli<PLAZA_POLY.length;pli++){\n var plp=iso(PLAZA_POLY[pli][0],PLAZA_POLY[pli][1]);\n if(pli===0) X.moveTo(plp.x,plp.y); else X.lineTo(plp.x,plp.y);\n }\n X.closePath();\n X.clip();\n var tile=1.5;\n var tx0=Math.floor(P_X0/tile)*tile, ty0=Math.floor(P_Y0/tile)*tile;\n var lite='rgba(206,200,188,0.92)', dark='rgba(118,108,98,0.92)';\n var liteBevel='rgba(238,232,218,0.55)', darkShade='rgba(60,52,44,0.45)';\n for(var gy2=ty0;gy2<P_Y1+1;gy2+=tile){\n for(var gx2=tx0;gx2<P_X1+1;gx2+=tile){\n var x0=gx2, y0=gy2, x1=gx2+tile, y1=gy2+tile;\n if(x1>P_X1) x1=P_X1; if(y1>P_Y1) y1=P_Y1;\n var isLite=(((Math.round((x0-P_X0)/tile)+Math.round((y0-P_Y0)/tile))%2)+2)%2===0;\n var top=isLite?lite:dark;\n // top face\n poly([[x0,y0],[x1,y0],[x1,y1],[x0,y1]], top, null, 0);\n // bevel: lighter top-left edge, darker bottom-right edge (use iso projection edge lines)\n var tlA=iso(x0,y0), tlB=iso(x1,y0), tlC=iso(x1,y1), tlD=iso(x0,y1);\n // top edge (lit)\n X.strokeStyle=isLite?liteBevel:'rgba(160,150,140,0.35)'; X.lineWidth=0.9*z;\n X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlB.x,tlB.y); X.stroke();\n // left edge (lit)\n X.strokeStyle=isLite?liteBevel:'rgba(150,140,130,0.30)';\n X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlD.x,tlD.y); X.stroke();\n // bottom edge (shaded)\n X.strokeStyle=darkShade;\n X.beginPath(); X.moveTo(tlD.x,tlD.y); X.lineTo(tlC.x,tlC.y); X.stroke();\n // right edge (shaded)\n X.strokeStyle=darkShade;\n X.beginPath(); X.moveTo(tlB.x,tlB.y); X.lineTo(tlC.x,tlC.y); X.stroke();\n }\n }\n X.restore();\n // outer plaza border for a clean curb (use the polygon)\n poly(PLAZA_POLY, null, 'rgba(50,44,36,0.7)', 1.4*z);\n // \u2500\u2500 sun-shade pavilions on the plaza (4 of them) \u2500\u2500\n // (pavilions removed \u2014 the chessboard plaza reads better without canopies)\n // parking lot \u2014 upper-right corner of the plaza (after the road)\n // curb ring around the lot\n poly([[LOT_X0-0.08,LOT_Y0-0.08],[LOT_X1+0.08,LOT_Y0-0.08],[LOT_X1+0.08,LOT_Y1+0.08],[LOT_X0-0.08,LOT_Y1+0.08]],\n 'rgba(20,20,24,0.95)', null, 0);\n // asphalt surface\n poly([[LOT_X0,LOT_Y0],[LOT_X1,LOT_Y0],[LOT_X1,LOT_Y1],[LOT_X0,LOT_Y1]],\n 'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.9)', 1);\n // access driveway from lot down into the plaza (thin asphalt strip)\n poly([[8.5, 1.5],[10.0, 1.5],[10.6, 2.5],[9.1, 2.5]],\n 'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.85)', 0.8*z);\n // parking bay lines (white) \u2014 2 rows\n X.strokeStyle='rgba(238,238,232,0.85)'; X.lineWidth=1.1*z; X.setLineDash([]);\n var bays=6;\n var lotH=LOT_Y1-LOT_Y0;\n var rowYs=[LOT_Y0+lotH*0.32, LOT_Y0+lotH*0.70];\n for(var ri2=0;ri2<rowYs.length;ri2++){\n for(var bi=1;bi<bays;bi++){\n var bx=LOT_X0+bi*((LOT_X1-LOT_X0)/bays);\n var ba=iso(bx, rowYs[ri2]-0.18), bb=iso(bx, rowYs[ri2]+0.18);\n X.beginPath(); X.moveTo(ba.x,ba.y); X.lineTo(bb.x,bb.y); X.stroke();\n }\n }\n // front edge line\n var feA=iso(LOT_X0+0.12,LOT_Y1-0.22), feB=iso(LOT_X1-0.12,LOT_Y1-0.22);\n X.beginPath(); X.moveTo(feA.x,feA.y); X.lineTo(feB.x,feB.y); X.stroke();\n // back edge line\n var beA=iso(LOT_X0+0.12,LOT_Y0+0.22), beB=iso(LOT_X1-0.12,LOT_Y0+0.22);\n X.beginPath(); X.moveTo(beA.x,beA.y); X.lineTo(beB.x,beB.y); X.stroke();\n // parked cars in 2 rows\n var bayW=(LOT_X1-LOT_X0)/bays;\n var parkedColors=['#d44a4a','#5b8bd6','#e8e6dc','#f2c14e','#7fb4ff','#5fa060','#c87ed4','#e0a23c','#9aa0a8','#ff7e6e'];\n for(var pri=0;pri<rowYs.length;pri++){\n for(var pbi=0;pbi<bays-1;pbi++){\n var pcx=LOT_X0+0.7+pbi*bayW;\n if(pbi===0 || pbi===bays-2) continue; // skip end bays for some breathing room\n drawCar(pcx, rowYs[pri], parkedColors[(pri*bays+pbi)%parkedColors.length]);\n }\n }\n // street band along the far edge + dashed centerline + road cars\n poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.92)', 'rgba(255,255,255,0.08)',1);\n X.strokeStyle='rgba(232,210,120,0.55)'; X.setLineDash([9*z,9*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 // animated road cars \u2014 positions updated each frame by tickCars()\n // draw order: outbound lane first (front), then inbound (back) for natural overlap\n for(var cai=0; cai<CARS.length; cai++){\n var car = CARS[cai];\n drawCar(car.x, car.y, car.color, car.dir, _carPhase[cai]||0, _now);\n }\n // white event tent in the left grass strip (west of the plaza)\n drawTent(-0.15, 0.5);\n // \u2500\u2500 lake \u2014 wider sandy shore with soft radial fade to grass \u2500\u2500\n // shore ring: a wider light sand band outside the water with a soft radial fade.\n X.save();\n var shoreRings=18;\n for(var shr=shoreRings; shr>=0; shr--){\n var t=shr/shoreRings;\n var rx=POND.rx + 0.5*(1-t);\n var ry=POND.ry + 0.5*(1-t);\n var alpha=0.55*(1-t)*(1-t);\n var col='rgba(228,210,168,'+alpha.toFixed(3)+')';\n var ringPts=[];\n for(var sk=0;sk<Math.PI*2;sk+=Math.PI/16){\n ringPts.push([POND.cx+rx*Math.cos(sk), POND.cy+ry*Math.sin(sk)*0.7]);\n }\n poly(ringPts, col, null, 0);\n }\n X.restore();\n // thin outer foam ring (kept for sparkle)\n var foamPts=[];\n for(var tf=0;tf<Math.PI*2;tf+=Math.PI/16){\n foamPts.push([POND.cx+(POND.rx+0.06)*Math.cos(tf), POND.cy+(POND.ry+0.05)*Math.sin(tf)*0.7]);\n }\n poly(foamPts, 'rgba(232,244,248,0.45)', 'rgba(255,255,255,0.5)', 1.0*z);\n // water outline (outer ellipse)\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 // \u2500\u2500 lake bed \u2014 3 concentric ellipses with progressively darker teal/blue, slightly offset toward upper-back \u2500\u2500\n function lakeEllipse(cx,cy,rx,ry,offX,offY){ var a=[]; for(var k=0;k<Math.PI*2;k+=Math.PI/16){ a.push([cx+offX+rx*Math.cos(k), cy+offY+ry*Math.sin(k)*0.7]); } return a; }\n // Outer: teal\n poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.70, POND.ry*0.70, -0.10, -0.06), 'rgba(112,196,212,0.55)', null, 0);\n // Mid: deeper teal\n poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.45, POND.ry*0.45, -0.18, -0.10), 'rgba(56,148,178,0.78)', null, 0);\n // Inner: deep blue\n poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.22, POND.ry*0.22, -0.22, -0.12), 'rgba(20,82,124,0.92)', null, 0);\n // water surface outline (drawn on top of the depth layers for a clean edge)\n poly(pts, null, 'rgba(120,200,222,0.6)',1.6*z);\n // \u2500\u2500 sun-glare streak \u2014 translucent white diagonal ellipse at upper-right of lake \u2500\u2500\n var glareCx=POND.cx+0.55, glareCy=POND.cy-0.55;\n var glareP=iso(glareCx, glareCy);\n var glareGrad=X.createRadialGradient(glareP.x, glareP.y, 2*z, glareP.x, glareP.y, 28*z);\n glareGrad.addColorStop(0,'rgba(255,255,255,0.32)');\n glareGrad.addColorStop(0.6,'rgba(255,255,255,0.10)');\n glareGrad.addColorStop(1,'rgba(255,255,255,0)');\n X.save();\n X.translate(glareP.x, glareP.y);\n X.rotate(-Math.PI/4);\n X.fillStyle=glareGrad;\n X.beginPath(); X.ellipse(0, 0, 26*z, 6*z, 0, 0, Math.PI*2); X.fill();\n X.restore();\n // \u2500\u2500 animated ripple/highlight \u2014 thin white elliptical arcs that drift across the lake \u2500\u2500\n var pondCent=iso(POND.cx, POND.cy);\n var ripplePhase=Math.sin(_now/1500);\n X.save();\n X.strokeStyle='rgba(232,248,252,0.7)'; X.lineWidth=1.0*z; X.lineCap='round';\n for(var rk=0;rk<4;rk++){\n var drift=(rk*0.6 + ripplePhase*0.8) % (POND.rx*1.4) - POND.rx*0.7;\n var rxr2=Math.abs(ripplePhase)*POND.rx*0.35 + POND.rx*0.18 + rk*0.18;\n var ryr2=POND.ry*0.18 + rk*0.10;\n var rCx2=POND.cx + drift*0.4;\n var rCy2=POND.cy - 0.10 + rk*0.12;\n var rA2=iso(rCx2-rxr2, rCy2);\n var rB2=iso(rCx2+rxr2, rCy2);\n X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(_now/900 + rk));\n X.beginPath();\n X.moveTo(rA2.x, rA2.y);\n X.quadraticCurveTo(pondCent.x, rA2.y - 3*z, rB2.x, rB2.y);\n X.stroke();\n }\n X.globalAlpha=1;\n X.restore();\n // \u2500\u2500 lily pads \u2014 small green ellipse clusters at deterministic positions \u2500\u2500\n var lilySpots=[\n {x: POND.cx-1.8, y: POND.cy+0.25, s: 0.95},\n {x: POND.cx+1.5, y: POND.cy+0.45, s: 1.05},\n {x: POND.cx-0.3, y: POND.cy-0.55, s: 0.85},\n {x: POND.cx+2.1, y: POND.cy-0.35, s: 0.9}\n ];\n for(var li=0;li<lilySpots.length;li++){\n var lp=iso(lilySpots[li].x, lilySpots[li].y);\n var lsz=lilySpots[li].s*z;\n // lily pad: ellipse with a notch cut from the right side\n X.save();\n X.fillStyle='rgba(72,140,72,0.85)';\n X.beginPath();\n X.ellipse(lp.x, lp.y, 5*lsz, 3.2*lsz, 0, -0.4, Math.PI*2-0.4);\n X.lineTo(lp.x, lp.y);\n X.closePath(); X.fill();\n // notch (darker wedge) \u2014 small triangle cut-out\n X.fillStyle='rgba(30,90,40,0.7)';\n X.beginPath();\n X.moveTo(lp.x, lp.y);\n X.lineTo(lp.x+5.2*lsz, lp.y-0.6*lsz);\n X.lineTo(lp.x+5.2*lsz, lp.y+0.6*lsz);\n X.closePath(); X.fill();\n // highlight\n X.fillStyle='rgba(150,210,130,0.45)';\n X.beginPath(); X.ellipse(lp.x-1.2*lsz, lp.y-0.8*lsz, 2*lsz, 0.8*lsz, 0, 0, Math.PI*2); X.fill();\n // tiny flower on one of them\n if(li===1){\n X.fillStyle='rgba(252,220,232,0.95)';\n X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 1.2*lsz, 0, Math.PI*2); X.fill();\n X.fillStyle='rgba(248,184,200,0.95)';\n X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 0.6*lsz, 0, Math.PI*2); X.fill();\n }\n X.restore();\n }\n // \u2500\u2500 tiny fountain \u2014 3 concentric rings of decreasing opacity around lake centroid \u2500\u2500\n X.save();\n for(var fk=0;fk<3;fk++){\n var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(_now/600 + fk));\n var falpha=0.55 - fk*0.18;\n var fcol='rgba(220,240,248,'+falpha.toFixed(3)+')';\n var fringPts=[];\n for(var fj=0;fj<Math.PI*2;fj+=Math.PI/14){\n fringPts.push([POND.cx + fr*Math.cos(fj), POND.cy + fr*Math.sin(fj)*0.7]);\n }\n poly(fringPts, null, fcol, 1.0*z);\n }\n X.restore();\n // \u2500\u2500 fully styled walking path \u2014 from plaza bottom edge curving right through the grass \u2500\u2500\n X.lineCap='round'; X.lineJoin='round';\n var pathPts=[\n [3.5, 5.7],\n [4.2, 6.2],\n [5.4, 6.5],\n [6.8, 6.5],\n [8.2, 6.2],\n [9.4, 5.7]\n ];\n function strokePath(strokeW, strokeStyle, dashed, dash){\n X.beginPath();\n for(var spi=0;spi<pathPts.length;spi++){\n var spp=iso(pathPts[spi][0], pathPts[spi][1]);\n if(spi===0) X.moveTo(spp.x, spp.y); else X.lineTo(spp.x, spp.y);\n }\n X.lineWidth=strokeW;\n X.strokeStyle=strokeStyle;\n if(dashed){ X.setLineDash(dash); } else { X.setLineDash([]); }\n X.stroke();\n }\n // edge stroke (grass border) \u2014 drawn first, slightly wider\n strokePath(4.0*z + 1.0*z, 'rgba(140,200,160,0.55)', false, null);\n // base stroke \u2014 light tan compacted dirt\n strokePath(4.0*z, 'rgba(212,190,140,0.92)', false, null);\n // subtle darker speckle along the path for \"compacted dirt\" texture\n X.save();\n X.fillStyle='rgba(168,142,90,0.55)';\n for(var spt=0;spt<14;spt++){\n var sSeg=(spt+0.5)/14;\n var sIdx = Math.min(pathPts.length-2, Math.floor(sSeg*(pathPts.length-1)));\n var sT = sSeg*(pathPts.length-1) - sIdx;\n var sAx=pathPts[sIdx][0]*(1-sT)+pathPts[sIdx+1][0]*sT;\n var sAy=pathPts[sIdx][1]*(1-sT)+pathPts[sIdx+1][1]*sT;\n var sPx=iso(sAx,sAy);\n var sAng=Math.sin(spt*23.7)*1.4;\n X.beginPath(); X.arc(sPx.x+Math.cos(sAng)*1.4*z, sPx.y+Math.sin(sAng)*1.0*z, 0.5*z, 0, 7); X.fill();\n }\n X.restore();\n // a faint lighter highlight stripe down the middle\n strokePath(1.0*z, 'rgba(238,222,178,0.5)', false, null);\n X.setLineDash([]); X.lineCap='butt'; X.lineJoin='miter';\n // \u2500\u2500 path lanterns \u2014 vertical post + warm yellow glow at the top, 3 of them \u2500\u2500\n var lanternSpots=[\n [pathPts[1][0], pathPts[1][1]-0.2],\n [pathPts[3][0], pathPts[3][1]],\n [pathPts[5][0], pathPts[5][1]]\n ];\n for(var ln=0;ln<lanternSpots.length;ln++){\n var lpos=iso(lanternSpots[ln][0], lanternSpots[ln][1]);\n // post\n X.strokeStyle='rgba(36,30,28,0.92)'; X.lineWidth=1.6*z;\n X.beginPath(); X.moveTo(lpos.x, lpos.y); X.lineTo(lpos.x, lpos.y-1.5*z*10); X.stroke();\n // base plate\n X.fillStyle='rgba(48,40,36,0.92)';\n X.beginPath(); X.ellipse(lpos.x, lpos.y, 2.4*z, 0.9*z, 0, 0, Math.PI*2); X.fill();\n // glow at top (shadowBlur for warm glow)\n X.save();\n X.shadowColor='rgba(255,214,112,0.95)'; X.shadowBlur=12*z;\n X.fillStyle='rgba(255,234,140,0.95)';\n X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 1.8*z, 0, Math.PI*2); X.fill();\n X.restore();\n // bright core\n X.fillStyle='rgba(255,250,210,1)';\n X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 0.8*z, 0, Math.PI*2); X.fill();\n }\n // \u2500\u2500 playground equipment in the grass area (below the plaza) \u2500\u2500\n drawPlayground(13.2, 6.4, 'rgba(224,82,52,0.92)');\n drawPlayground(13.8, 7.0, 'rgba(248,176,52,0.92)');\n drawBlueCanopy(0.9, 6.6);\n }\n function drawTent(wx,wy){\n // Compact ridge tent: footprint 0.4 long x 0.28 deep (~ half a plaza tile), height 7*z.\n var z=cam.z, wLong=0.4, wShort=0.28, h=7*z;\n var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);\n var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);\n var rdL={ x:bkL.x, y:bkL.y - h };\n var rdR={ x:bkR.x, y:bkR.y - h };\n var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;\n // soft ground shadow\n X.fillStyle='rgba(0,0,0,0.30)';\n X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();\n // stone apron\n X.fillStyle='rgba(190,186,176,0.55)';\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.stroke();\n // back roof panel (shaded)\n X.fillStyle='rgba(196,200,212,0.97)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(bkR.x,bkR.y); X.lineTo(bkL.x,bkL.y); X.closePath(); X.fill();\n // front roof panel (lit)\n X.fillStyle='rgba(248,248,252,0.98)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n // ridge beam\n X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.9*z;\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 4 corner poles\n X.strokeStyle='rgba(80,60,40,0.92)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n X.strokeStyle='rgba(50,40,30,0.98)'; X.lineWidth=0.7*z;\n X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 2 mid poles on front edge\n var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };\n var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };\n var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };\n var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };\n X.strokeStyle='rgba(50,40,30,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();\n X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();\n // fabric scallops on the front eave\n X.strokeStyle='rgba(220,224,232,0.55)'; X.lineWidth=0.4*z;\n function scallop(x1,y1,x2,y2,sag){\n var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;\n X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();\n }\n scallop(frL.x,frL.y, midLF.x,midLF.y, 0.8*z);\n scallop(midLF.x,midLF.y, midRF.x,midRF.y, 0.8*z);\n scallop(midRF.x,midRF.y, frR.x,frR.y, 0.8*z);\n // small flag at the right ridge end\n X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(rdR.x,rdR.y); X.lineTo(rdR.x, rdR.y - 4*z); X.stroke();\n X.fillStyle='rgba(232,72,72,0.95)';\n X.beginPath(); X.moveTo(rdR.x,rdR.y-4*z); X.lineTo(rdR.x+2.5*z,rdR.y-3*z); X.lineTo(rdR.x,rdR.y-2*z); X.closePath(); X.fill();\n }\n function drawCar(wx,wy,col,dir,phase,now){\n var p=iso(wx,wy), z=cam.z;\n // Optional animation params: dir (+1 right / -1 left), phase (per-car\n // offset in seconds), now (performance.now in ms). Falls back to the\n // static parked-car look when these are undefined.\n var moving = (dir===1 || dir===-1) && typeof now === 'number';\n // gentle vertical bob so a moving car looks like it has suspension\n var bob = moving ? Math.sin((now/180 + (phase||0)*1000)) * 0.5*z : 0;\n // soft drop shadow \u2014 slight motion blur for moving cars\n X.fillStyle='rgba(0,0,0,0.32)';\n X.beginPath(); X.ellipse(p.x,p.y+4*z,13*z,5.5*z,0,0,7); X.fill();\n if(moving){\n // ground shadow streak behind the car (opposite to direction of travel)\n var sx = p.x - dir*8*z;\n X.fillStyle='rgba(0,0,0,0.10)';\n X.beginPath(); X.ellipse(sx, p.y+4.5*z, 9*z, 4*z, 0, 0, 7); X.fill();\n // soft headlight beam projected on the road in front of the car\n var beamFrontX = p.x + dir*16*z;\n var beam = X.createRadialGradient(beamFrontX, p.y+1*z, 1*z, beamFrontX, p.y+1*z, 14*z);\n beam.addColorStop(0, 'rgba(255,242,200,0.32)');\n beam.addColorStop(0.5, 'rgba(255,242,200,0.10)');\n beam.addColorStop(1, 'rgba(255,242,200,0)');\n X.save();\n X.fillStyle = beam;\n X.beginPath(); X.ellipse(beamFrontX, p.y+1*z, 14*z, 6*z, 0, 0, Math.PI*2); X.fill();\n X.restore();\n }\n // chassis (lower body)\n X.fillStyle=col;\n X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z+bob,26*z,11*z,3*z); X.fill();\n // chassis bottom shade\n X.fillStyle='rgba(0,0,0,0.22)';\n X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z+bob,26*z,4*z,2*z); X.fill();\n // roof (darker top)\n X.fillStyle='rgba(0,0,0,0.55)';\n X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();\n // roof tint with car color\n X.fillStyle=col;\n X.globalAlpha=0.6;\n X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();\n X.globalAlpha=1;\n // windshield highlight\n X.fillStyle='rgba(180,220,255,0.65)';\n X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z+bob,12*z,3*z,1.5*z); X.fill();\n // windshield shine\n X.fillStyle='rgba(255,255,255,0.45)';\n X.beginPath();\n X.moveTo(p.x-5*z,p.y-9*z+bob); X.lineTo(p.x-2*z,p.y-9*z+bob); X.lineTo(p.x-4*z,p.y-7*z+bob); X.closePath(); X.fill();\n // wheels \u2014 animated spokes when the car is moving (rotation = distance traveled)\n X.fillStyle='#0c0c10';\n X.beginPath(); X.arc(p.x-8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x+8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x-8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x+8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();\n // wheel rims with a moving spoke mark so wheels visibly spin\n X.fillStyle='rgba(200,200,210,0.75)';\n X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();\n // wheel spoke (rotates with speed) \u2014 gives the impression of motion\n if(moving){\n var spokeAng = (now/40 + (phase||0)*1000) % (Math.PI*2);\n var spokeDx = Math.cos(spokeAng) * 1.6*z;\n var spokeDy = Math.sin(spokeAng) * 1.6*z;\n X.strokeStyle='rgba(220,220,230,0.85)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(p.x-8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x-8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();\n X.beginPath(); X.moveTo(p.x+8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x+8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();\n }\n // headlight (front \u2014 the side the car is driving toward)\n var frontX = p.x + (dir||1)*12*z;\n X.fillStyle='rgba(255,240,180,0.95)';\n X.beginPath(); X.arc(frontX, p.y-1*z+bob, 1.6*z, 0, 7); X.fill();\n X.fillStyle='rgba(255,240,180,0.4)';\n X.beginPath(); X.arc(frontX, p.y-1*z+bob, 3*z, 0, 7); X.fill();\n // taillight (rear \u2014 opposite side, slightly redder)\n var rearX = p.x - (dir||1)*12*z;\n X.fillStyle='rgba(255,80,80,0.85)';\n X.beginPath(); X.arc(rearX, p.y-1*z+bob, 1.2*z, 0, 7); X.fill();\n if(moving){\n // a small red glow on the road behind the car\n X.fillStyle='rgba(255,80,80,0.25)';\n X.beginPath(); X.arc(rearX - dir*3*z, p.y+1*z+bob, 2.6*z, 0, 7); X.fill();\n }\n // body highlight\n X.fillStyle='rgba(255,255,255,0.18)';\n X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z+bob,24*z,1.5*z,1*z); X.fill();\n }\n\n // \u2500\u2500 Atmosphere: sky, sun, distant mountains, clouds, haze \u2500\u2500\n // Painted on top of the cleared canvas and below the rest of the map\n // so the map looks like it sits in an actual world instead of on a\n // blank panel. Cheap to render (a handful of full-canvas fills + a\n // few silhouette paths + a small cloud set) and turns the whole\n // scene from \"miniature on a black velvet\" into \"park at golden hour\".\n //\n // Everything is drawn in screen space, not world space, because the\n // sky doesn't follow the iso projection \u2014 it's a backdrop, not a\n // surface. Distant mountains live just above the road horizon line,\n // the sun is fixed near the upper-right (a warm focal point), and a\n // handful of soft clouds drift slowly across the sky.\n function drawAtmosphere(now){\n if(!W || !H) return;\n var z = cam.z;\n\n // \u2500\u2500 sky gradient \u2014 warm late-afternoon palette (top to bottom) \u2500\u2500\n // Top: deep cobalt \u2192 mid: soft sky \u2192 low: warm gold haze. Anchored\n // to a virtual horizon line ~38% from the top of the stage so the\n // map always sits on the same horizon regardless of zoom.\n var horizon = H * 0.42;\n var sky = X.createLinearGradient(0, 0, 0, H);\n sky.addColorStop(0, 'rgba(46, 78, 132, 0.95)'); // deep sky\n sky.addColorStop(0.30, 'rgba(108, 152, 196, 0.85)'); // mid sky\n sky.addColorStop(0.46, 'rgba(214, 184, 138, 0.78)'); // warm horizon\n sky.addColorStop(0.55, 'rgba(244, 222, 168, 0.62)'); // soft golden glow\n sky.addColorStop(0.75, 'rgba(248, 232, 200, 0.20)'); // fades into the ground area\n sky.addColorStop(1, 'rgba(248, 232, 200, 0)');\n X.fillStyle = sky;\n X.fillRect(0, 0, W, H);\n\n // \u2500\u2500 sun \u2014 a soft warm disc with a wide glow halo \u2500\u2500\n // Anchored at ~78% across, 22% down so it reads as the off-screen\n // light source warming the whole scene. The glow pulses very\n // gently so it doesn't feel like a static sticker.\n var sunX = W * 0.78;\n var sunY = H * 0.22;\n var sunR = 28 * (0.95 + 0.05 * Math.sin(now/2400));\n // outer warm glow\n var glow = X.createRadialGradient(sunX, sunY, sunR*0.6, sunX, sunY, sunR*7);\n glow.addColorStop(0, 'rgba(255, 222, 140, 0.55)');\n glow.addColorStop(0.25,'rgba(255, 210, 130, 0.30)');\n glow.addColorStop(0.6, 'rgba(255, 200, 120, 0.08)');\n glow.addColorStop(1, 'rgba(255, 200, 120, 0)');\n X.fillStyle = glow;\n X.fillRect(0, 0, W, H);\n // sun disc\n var disc = X.createRadialGradient(sunX, sunY, sunR*0.2, sunX, sunY, sunR);\n disc.addColorStop(0, 'rgba(255, 250, 230, 1)');\n disc.addColorStop(0.6, 'rgba(255, 232, 170, 0.95)');\n disc.addColorStop(1, 'rgba(255, 210, 130, 0)');\n X.fillStyle = disc;\n X.beginPath(); X.arc(sunX, sunY, sunR, 0, Math.PI*2); X.fill();\n\n // \u2500\u2500 distant mountain silhouette (3 receding layers) \u2500\u2500\n // Each layer uses a slightly lighter color so the eye reads depth.\n // Anchored just below the horizon so the iso map sits in front of\n // them. Heights/shapes are deterministic so the silhouette doesn't\n // shimmer between frames.\n function mountainLayer(baseY, amp, segments, color, seedOff){\n X.fillStyle = color;\n X.beginPath();\n X.moveTo(0, H);\n // sample the silhouette using a sum of low-frequency sines\n for(var mx=0; mx<=W; mx+=8){\n var t = mx / W;\n var h = (\n Math.sin(t*6.3 + seedOff) * 0.5 +\n Math.sin(t*12.5 + seedOff*2.3) * 0.28 +\n Math.sin(t*22.1 + seedOff*0.7) * 0.14\n ) * amp;\n X.lineTo(mx, baseY - h);\n }\n X.lineTo(W, H);\n X.closePath();\n X.fill();\n }\n // farther layer (lighter haze, lower)\n mountainLayer(H*0.46, 28, 18, 'rgba(120, 132, 158, 0.45)', 1.7);\n // middle layer\n mountainLayer(H*0.48, 36, 16, 'rgba(86, 100, 124, 0.55)', 3.1);\n // closer ridge (darkest, in front of the plaza)\n mountainLayer(H*0.50, 22, 14, 'rgba(54, 68, 86, 0.60)', 0.4);\n\n // \u2500\u2500 soft cloud band \u2014 5 elongated ellipses drifting right \u2500\u2500\n // Drawn as flat white ellipses with low alpha so they look like\n // wisps, not puffs. Each cloud has its own period so they don't\n // march in lockstep.\n var cloudColors = [\n 'rgba(255,255,255,0.55)',\n 'rgba(255,250,240,0.45)',\n 'rgba(248,242,228,0.40)',\n 'rgba(255,250,235,0.35)',\n 'rgba(252,246,232,0.50)',\n ];\n for(var ci=0; ci<cloudColors.length; ci++){\n var period = 90 + ci*22; // seconds for a full pass\n var drift = ((now/1000) / period) % 1; // 0..1\n var cx = (drift * (W + 240)) - 120; // wrap from -120 to W+120\n var cy = H*0.10 + ci*16;\n var rx = 60 + ci*8;\n var ry = 10 + (ci%3)*3;\n X.fillStyle = cloudColors[ci];\n // fluffy edge: 3 overlapping ellipses\n X.beginPath(); X.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2); X.fill();\n X.beginPath(); X.ellipse(cx - rx*0.5, cy + ry*0.4, rx*0.7, ry*0.7, 0, 0, Math.PI*2); X.fill();\n X.beginPath(); X.ellipse(cx + rx*0.5, cy + ry*0.3, rx*0.7, ry*0.6, 0, 0, Math.PI*2); X.fill();\n }\n\n // \u2500\u2500 soft warm light wash on the upper third of the map \u2500\u2500\n // Sits in screen space too: a wide radial glow over the entire\n // map area that makes the grass + plaza read as if lit from the\n // direction of the sun (upper-right). Without this the map looks\n // like it was cut out and pasted in.\n var wash = X.createRadialGradient(sunX, sunY*0.7, 40, sunX, sunY*0.7, Math.max(W, H)*0.85);\n wash.addColorStop(0, 'rgba(255, 220, 150, 0.16)');\n wash.addColorStop(0.35, 'rgba(255, 200, 130, 0.06)');\n wash.addColorStop(1, 'rgba(255, 200, 130, 0)');\n X.fillStyle = wash;\n X.fillRect(0, 0, W, H);\n\n // \u2500\u2500 subtle haze band along the horizon \u2500\u2500\n // Sits just above the road so the cars pop against a soft\n // horizon-glow rather than a hard line.\n var haze = X.createLinearGradient(0, horizon - 30, 0, horizon + 50);\n haze.addColorStop(0, 'rgba(255, 220, 160, 0)');\n haze.addColorStop(0.5, 'rgba(255, 220, 160, 0.22)');\n haze.addColorStop(1, 'rgba(255, 220, 160, 0)');\n X.fillStyle = haze;\n X.fillRect(0, horizon - 30, W, 80);\n\n // \u2500\u2500 ambient particles \u2014 drifting motes / pollen / fireflies \u2500\u2500\n // 40 tiny dots that float across the scene. Each one is held in\n // an array; the array is seeded once (so they don't all start at\n // the same position) and advanced by a tiny per-frame velocity.\n // On first call we lazily seed it; on resize we re-distribute.\n if(!_atmos.particles){\n _atmos.particles = [];\n for(var pi=0; pi<40; pi++){\n _atmos.particles.push({\n x: Math.random()*W,\n y: H*0.20 + Math.random()*H*0.55,\n r: 0.8 + Math.random()*1.6,\n vx: -6 + Math.random()*12, // px/sec, mostly right\n vy: -2 + Math.random()*4, // slight up/down sway\n phase: Math.random()*Math.PI*2,\n twinkle: 0.5 + Math.random()*0.5, // brightness amplitude\n hue: Math.random() < 0.35 ? 'warm' : 'cool', // mix of gold + silver\n });\n }\n }\n var dt = _atmos.lastNow ? Math.min(64, now - _atmos.lastNow)/1000 : 0.016;\n _atmos.lastNow = now;\n for(var pj=0; pj<_atmos.particles.length; pj++){\n var pt = _atmos.particles[pj];\n // simple wrap\n pt.x += pt.vx * dt;\n pt.y += Math.sin(now/1200 + pt.phase) * 0.3;\n if(pt.x > W + 8) pt.x = -8;\n if(pt.x < -8) pt.x = W + 8;\n if(pt.y < H*0.10) pt.y = H*0.75;\n if(pt.y > H*0.80) pt.y = H*0.15;\n // soft twinkle alpha\n var a = 0.25 + 0.45 * Math.abs(Math.sin(now/700 + pt.phase)) * pt.twinkle;\n X.fillStyle = pt.hue === 'warm'\n ? 'rgba(255, 230, 160, '+a.toFixed(3)+')'\n : 'rgba(220, 235, 250, '+a.toFixed(3)+')';\n X.beginPath(); X.arc(pt.x, pt.y, pt.r, 0, Math.PI*2); X.fill();\n // tiny halo\n X.fillStyle = pt.hue === 'warm'\n ? 'rgba(255, 220, 140, '+(a*0.35).toFixed(3)+')'\n : 'rgba(200, 220, 245, '+(a*0.30).toFixed(3)+')';\n X.beginPath(); X.arc(pt.x, pt.y, pt.r*2.5, 0, Math.PI*2); X.fill();\n }\n }\n // Holds the persistent atmospheric state (particle positions + last tick time).\n var _atmos = { particles: null, lastNow: 0 };\n\n // Sun-shade pavilion: 4 thin white poles supporting a flat white square canopy.\n function drawPavilion(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n var canopy=12*z;\n var poleH=6*z;\n // soft ground shadow under the canopy\n X.fillStyle='rgba(0,0,0,0.18)';\n X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, canopy*1.1, canopy*0.42, 0, 0, 7); X.fill();\n // four corner poles (slight isometric offset)\n var poleOff=canopy*0.42;\n function pole(px,py){\n X.strokeStyle='rgba(240,240,244,0.95)';\n X.lineWidth=0.9*z;\n X.beginPath(); X.moveTo(px, py); X.lineTo(px, py-poleH); X.stroke();\n // tiny base dot\n X.fillStyle='rgba(40,40,48,0.6)';\n X.beginPath(); X.arc(px, py, 0.8*z, 0, 7); X.fill();\n }\n pole(p.x-poleOff, p.y+poleOff*0.5);\n pole(p.x+poleOff, p.y+poleOff*0.5);\n pole(p.x-poleOff, p.y-poleOff*0.5);\n pole(p.x+poleOff, p.y-poleOff*0.5);\n // canopy roof \u2014 flat white square (top diamond)\n X.fillStyle='rgba(248,248,252,0.97)';\n X.beginPath();\n X.moveTo(p.x, p.y-poleH-canopy*0.5);\n X.lineTo(p.x+canopy, p.y-poleH);\n X.lineTo(p.x, p.y-poleH+canopy*0.5);\n X.lineTo(p.x-canopy, p.y-poleH);\n X.closePath(); X.fill();\n // canopy rim/edge\n X.strokeStyle='rgba(170,172,180,0.85)';\n X.lineWidth=0.8*z;\n X.beginPath();\n X.moveTo(p.x, p.y-poleH-canopy*0.5);\n X.lineTo(p.x+canopy, p.y-poleH);\n X.lineTo(p.x, p.y-poleH+canopy*0.5);\n X.lineTo(p.x-canopy, p.y-poleH);\n X.closePath(); X.stroke();\n // soft highlight band on the top of the canopy\n X.fillStyle='rgba(255,255,255,0.55)';\n X.beginPath();\n X.moveTo(p.x-canopy*0.8, p.y-poleH-canopy*0.05);\n X.lineTo(p.x+canopy*0.8, p.y-poleH-canopy*0.05);\n X.lineTo(p.x+canopy*0.6, p.y-poleH+canopy*0.25);\n X.lineTo(p.x-canopy*0.6, p.y-poleH+canopy*0.25);\n X.closePath(); X.fill();\n }\n // Abstract playground equipment: a small cluster of colored geometric shapes.\n function drawPlayground(wx,wy,accent){\n var p=iso(wx,wy), z=cam.z;\n // ground shadow\n X.fillStyle='rgba(0,0,0,0.20)';\n X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, 9*z, 4*z, 0, 0, 7); X.fill();\n // square post (red/orange)\n X.fillStyle=accent || 'rgba(224,82,52,0.95)';\n X.fillRect(p.x-3*z, p.y-8*z, 6*z, 6*z);\n X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z;\n X.strokeRect(p.x-3*z, p.y-8*z, 6*z, 6*z);\n // yellow circle\n X.fillStyle='rgba(248,212,72,0.95)';\n X.beginPath(); X.arc(p.x+5*z, p.y-4*z, 2.4*z, 0, Math.PI*2); X.fill();\n X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();\n // blue triangle\n X.fillStyle='rgba(82,142,224,0.95)';\n X.beginPath();\n X.moveTo(p.x-6*z, p.y-2*z);\n X.lineTo(p.x-2*z, p.y-2*z);\n X.lineTo(p.x-4*z, p.y-7*z);\n X.closePath(); X.fill();\n X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();\n // tiny base bar\n X.strokeStyle='rgba(48,48,52,0.85)'; X.lineWidth=1.2*z;\n X.beginPath(); X.moveTo(p.x-5*z, p.y); X.lineTo(p.x+6*z, p.y); X.stroke();\n }\n // Larger blue canopy (event-tent style) for the grass area.\n function drawBlueCanopy(wx,wy){\n // Compact ridge tent (slightly bigger than drawTent, blue, no flag).\n var z=cam.z, wLong=0.55, wShort=0.35, h=10*z;\n var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);\n var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);\n var rdL={ x:bkL.x, y:bkL.y - h };\n var rdR={ x:bkR.x, y:bkR.y - h };\n var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;\n // ground shadow\n X.fillStyle='rgba(0,0,0,0.32)';\n X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();\n // stone apron\n X.fillStyle='rgba(190,186,176,0.55)';\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.stroke();\n // back roof panel (shaded blue)\n X.fillStyle='rgba(58,104,176,0.97)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(bkR.x,bkR.y); X.lineTo(bkL.x,bkL.y); X.closePath(); X.fill();\n // front roof panel (lit blue)\n X.fillStyle='rgba(132,184,240,0.98)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n // ridge beam\n X.strokeStyle='rgba(20,40,76,0.95)'; X.lineWidth=1.0*z;\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 4 corner poles\n X.strokeStyle='rgba(40,60,90,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n X.strokeStyle='rgba(20,36,68,0.98)'; X.lineWidth=0.7*z;\n X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 2 mid poles on front edge\n var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };\n var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };\n var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };\n var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };\n X.strokeStyle='rgba(20,36,68,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();\n X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();\n // fabric scallops on the front eave\n X.strokeStyle='rgba(220,232,248,0.6)'; X.lineWidth=0.4*z;\n function scallop(x1,y1,x2,y2,sag){\n var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;\n X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();\n }\n scallop(frL.x,frL.y, midLF.x,midLF.y, 1.0*z);\n scallop(midLF.x,midLF.y, midRF.x,midRF.y, 1.0*z);\n scallop(midRF.x,midRF.y, frR.x,frR.y, 1.0*z);\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n // soft ground shadow\n X.fillStyle='rgba(0,0,0,0.30)';\n X.beginPath(); X.ellipse(p.x,p.y+3*z,14*z,5.5*z,0,0,7); X.fill();\n // trunk: tapered curved column drawn as a filled quad, with a darker right side\n var trunkH=46*z, trunkBaseW=4*z, trunkTopW=2.2*z;\n // base point and apex (slight curve to the right)\n var baseX=p.x, baseY=p.y;\n var cpx=baseX+5*z, cpy=baseY-trunkH*0.5;\n var apexX=baseX+2*z, apexY=baseY-trunkH;\n // sample two intermediate points to build the curved silhouette\n function curveAt(t){\n // quadratic bezier\n var u=1-t;\n return {\n x:u*u*baseX + 2*u*t*cpx + t*t*apexX,\n y:u*u*baseY + 2*u*t*cpy + t*t*apexY\n };\n }\n var sA=curveAt(0.0), sB=curveAt(0.33), sC=curveAt(0.66), sD=curveAt(1.0);\n // right (shaded) side of trunk \u2014 offset outward at each sample\n function off(px,py,t,w){\n // normal-ish: lean to the right\n return { x:px+w, y:py };\n }\n var rA=off(sA.x,sA.y,0,trunkBaseW), rB=off(sB.x,sB.y,0.33,trunkBaseW*0.78);\n var rC=off(sC.x,sC.y,0.66,trunkBaseW*0.55), rD=off(sD.x,sD.y,1,trunkTopW);\n // left (lit) side\n function offL(px,py,t,w){ return { x:px-w, y:py }; }\n var lA=offL(sA.x,sA.y,0,trunkBaseW), lB=offL(sB.x,sB.y,0.33,trunkBaseW*0.78);\n var lC=offL(sC.x,sC.y,0.66,trunkBaseW*0.55), lD=offL(sD.x,sD.y,1,trunkTopW);\n // shaded right half\n X.fillStyle='rgba(82,52,28,0.95)';\n X.beginPath();\n X.moveTo(rA.x,rA.y);\n X.quadraticCurveTo(cpx+trunkBaseW, cpy, rB.x,rB.y);\n X.quadraticCurveTo(cpx+trunkBaseW*0.55, cpy*0.4+apexY*0.6, rC.x,rC.y);\n X.lineTo(rD.x,rD.y);\n X.lineTo(sD.x,sD.y);\n X.lineTo(sC.x,sC.y);\n X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);\n X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);\n X.closePath(); X.fill();\n // lighter lit left edge of trunk\n X.fillStyle='rgba(148,102,62,0.95)';\n X.beginPath();\n X.moveTo(lA.x,lA.y);\n X.quadraticCurveTo(cpx-trunkBaseW, cpy, lB.x,lB.y);\n X.quadraticCurveTo(cpx-trunkBaseW*0.55, cpy*0.4+apexY*0.6, lC.x,lC.y);\n X.lineTo(sC.x,sC.y);\n X.lineTo(sB.x,sB.y);\n X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);\n X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);\n X.closePath(); X.fill();\n // trunk segment rings (texture)\n X.strokeStyle='rgba(48,28,14,0.55)'; X.lineWidth=0.8*z;\n for(var ri=1;ri<6;ri++){\n var tr=ri/6;\n var tl=curveAt(tr-0.04), trr=curveAt(tr+0.04);\n var tlW=(trunkBaseW*(1-tr)+trunkTopW*tr);\n X.beginPath();\n X.moveTo(tl.x-tlW,tl.y);\n X.lineTo(tl.x+tlW,tl.y);\n X.stroke();\n }\n // crown center\n var tx=apexX, ty=apexY;\n // coconuts near the crown\n X.fillStyle='rgba(60,38,20,0.95)';\n X.beginPath(); X.arc(tx-3*z,ty+2*z,2*z,0,7); X.fill();\n X.beginPath(); X.arc(tx+3.5*z,ty+2.5*z,2.1*z,0,7); X.fill();\n X.beginPath(); X.arc(tx+0.5*z,ty+4*z,1.8*z,0,7); X.fill();\n // coconut highlights\n X.fillStyle='rgba(180,140,90,0.6)';\n X.beginPath(); X.arc(tx-3.6*z,ty+1.4*z,0.7*z,0,7); X.fill();\n X.beginPath(); X.arc(tx+2.9*z,ty+1.9*z,0.7*z,0,7); X.fill();\n // frond layer 1 \u2014 drooping down (8 fronds)\n var frondCount=8;\n for(var a=0;a<frondCount;a++){\n var ang=(-Math.PI/2) + (a-(frondCount-1)/2)*(Math.PI*1.05/frondCount);\n // drooping fronds have a downward bias\n var droop=0.45;\n var tipDx=Math.cos(ang)*22*z;\n var tipDy=Math.sin(ang)*14*z + 6*z; // pull down\n var ctlDx=Math.cos(ang)*8*z + Math.sin(ang)*4*z;\n var ctlDy=Math.sin(ang)*4*z + 8*z;\n // frond gradient from dark base to light tip\n var fGrad=X.createLinearGradient(tx+ctlDx*0.2, ty+ctlDy*0.2, tx+tipDx, ty+tipDy);\n fGrad.addColorStop(0,'rgba(34,90,46,0.95)');\n fGrad.addColorStop(0.6,'rgba(60,140,68,0.92)');\n fGrad.addColorStop(1,'rgba(130,200,110,0.88)');\n X.fillStyle=fGrad;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);\n // tip point then back via a second control point that bows out\n var backCtlX=tx+tipDx*0.7 - Math.sin(ang)*3*z;\n var backCtlY=ty+tipDy*0.7 - Math.cos(ang)*2*z + 1*z;\n X.quadraticCurveTo(backCtlX, backCtlY, tx, ty);\n X.closePath(); X.fill();\n // frond center vein\n X.strokeStyle='rgba(28,70,36,0.6)'; X.lineWidth=0.7*z;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);\n X.stroke();\n }\n // frond layer 2 \u2014 pointing up (7 fronds, offset angles for fullness)\n var frondCount2=7;\n for(var a2=0;a2<frondCount2;a2++){\n var ang2=(-Math.PI/2) + (a2-(frondCount2-1)/2)*(Math.PI*0.85/frondCount2) + 0.2;\n var tipDx2=Math.cos(ang2)*20*z;\n var tipDy2=Math.sin(ang2)*16*z - 6*z; // push up\n var ctlDx2=Math.cos(ang2)*7*z - Math.sin(ang2)*3*z;\n var ctlDy2=Math.sin(ang2)*6*z - 2*z;\n var fGrad2=X.createLinearGradient(tx+ctlDx2*0.2, ty+ctlDy2*0.2, tx+tipDx2, ty+tipDy2);\n fGrad2.addColorStop(0,'rgba(40,100,52,0.95)');\n fGrad2.addColorStop(0.6,'rgba(70,150,76,0.92)');\n fGrad2.addColorStop(1,'rgba(150,210,120,0.88)');\n X.fillStyle=fGrad2;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);\n var backCtlX2=tx+tipDx2*0.7 + Math.sin(ang2)*3*z;\n var backCtlY2=ty+tipDy2*0.7 + Math.cos(ang2)*1*z - 1*z;\n X.quadraticCurveTo(backCtlX2, backCtlY2, tx, ty);\n X.closePath(); X.fill();\n // vein\n X.strokeStyle='rgba(30,76,40,0.55)'; X.lineWidth=0.6*z;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);\n X.stroke();\n }\n // crown core highlight\n X.fillStyle='rgba(200,220,160,0.45)';\n X.beginPath(); X.arc(tx,ty,2.5*z,0,7); X.fill();\n }\n function ring(cx,cy,r,frac,color){\n // No more raised stone base plate \u2014 the plaza chessboard tile shows\n // through underneath. Just the soft gold glow + load arc.\n X.save();\n X.shadowColor=color; X.shadowBlur=14*cam.z;\n X.fillStyle='rgba(232,200,120,0.18)';\n X.beginPath(); X.ellipse(cx,cy, r+3*cam.z, (r+3*cam.z)*0.5, 0, 0, 7); X.fill();\n X.restore();\n X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.14)'; 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){\n // \"Not joined\" marker \u2014 soft crosshair on the bare plaza tile.\n // No more raised stone platform; the chessboard shows through.\n X.strokeStyle='rgba(232,200,120,0.6)'; X.lineWidth=1.4*z;\n X.beginPath();\n X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);\n X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);\n X.stroke();\n // soft outer glow\n X.strokeStyle='rgba(255,210,140,0.4)'; X.lineWidth=3*z;\n X.beginPath();\n X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);\n X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);\n X.stroke();\n }\n // Render a 3D-looking raised tile (top face + right side + front face) centered on (cx,cy)\n function drawTileQuad(cx,cy,w,h,fillTop,fillSide){\n var halfW=w*0.5, halfH=h*0.35;\n var lift=3*cam.z;\n // top face (diamond) \u2014 lit\n X.fillStyle=fillTop;\n X.beginPath();\n X.moveTo(cx,cy-lift-halfH);\n X.lineTo(cx+halfW,cy-lift);\n X.lineTo(cx,cy-lift+halfH);\n X.lineTo(cx-halfW,cy-lift);\n X.closePath(); X.fill();\n // top face bevel highlight (upper-left edges)\n X.strokeStyle='rgba(255,255,255,0.18)'; X.lineWidth=0.9*cam.z;\n X.beginPath();\n X.moveTo(cx,cy-lift-halfH);\n X.lineTo(cx-halfW,cy-lift);\n X.stroke();\n X.beginPath();\n X.moveTo(cx-halfW,cy-lift);\n X.lineTo(cx,cy-lift+halfH);\n X.stroke();\n // right side face (darker)\n X.fillStyle=fillSide;\n X.beginPath();\n X.moveTo(cx,cy-lift+halfH);\n X.lineTo(cx+halfW,cy-lift);\n X.lineTo(cx+halfW,cy);\n X.lineTo(cx,cy+halfH);\n X.closePath(); X.fill();\n // front face (darker)\n X.fillStyle=fillSide;\n X.beginPath();\n X.moveTo(cx-halfW,cy-lift);\n X.lineTo(cx,cy-lift+halfH);\n X.lineTo(cx,cy+halfH);\n X.lineTo(cx-halfW,cy);\n X.closePath(); X.fill();\n // outline\n X.strokeStyle='rgba(0,0,0,0.45)'; X.lineWidth=0.8*cam.z;\n X.beginPath();\n X.moveTo(cx,cy-lift-halfH); X.lineTo(cx+halfW,cy-lift);\n X.lineTo(cx+halfW,cy); X.lineTo(cx,cy+halfH);\n X.lineTo(cx-halfW,cy); X.lineTo(cx-halfW,cy-lift);\n X.closePath(); X.stroke();\n }\n function drawHub(n){\n // Desktop PC / server workstation: a tower on the left, a monitor on the right,\n // a keyboard in front and a mouse to the right. Centered on (n.wx, n.wy) on the iso ground.\n var z=cam.z;\n var bob=Math.sin((performance.now()-t0)/700)*1.2*z;\n // base point on the iso ground (the workstation sits centered here, slightly raised by bob)\n var p=iso(n.wx, n.wy);\n var gx=p.x, gy=p.y - bob; // ground reference\n // soft floor shadow under the whole workstation\n X.fillStyle='rgba(0,0,0,0.32)';\n X.beginPath(); X.ellipse(gx, gy+3*z, 32*z, 12*z, 0, 0, 7); X.fill();\n // (desk-mat removed \u2014 the bare plaza tile now shows under the workstation)\n\n // \u2500\u2500 PC TOWER (left of center, vertical box) \u2500\u2500\n // tower footprint ~14*z wide x 10*z deep on the desk, height 26*z\n var tx=gx-18*z, ty=gy-11*z; // tower base x, y on desk top\n var tw=14*z, td=10*z, th=26*z;\n // right side face (darker)\n X.fillStyle='rgba(36,32,48,0.97)';\n X.beginPath();\n X.moveTo(tx+tw, ty);\n X.lineTo(tx+tw, ty-th);\n X.lineTo(tx+tw+td*0.45, ty-th+td*0.35);\n X.lineTo(tx+tw+td*0.45, ty+td*0.35);\n X.closePath(); X.fill();\n // front face (lit)\n X.fillStyle='rgba(56,50,72,0.97)';\n X.beginPath();\n X.moveTo(tx, ty);\n X.lineTo(tx, ty-th);\n X.lineTo(tx+tw, ty-th);\n X.lineTo(tx+tw, ty);\n X.closePath(); X.fill();\n // top face\n X.fillStyle='rgba(78,70,96,0.97)';\n X.beginPath();\n X.moveTo(tx, ty);\n X.lineTo(tx+tw, ty);\n X.lineTo(tx+tw+td*0.45, ty+td*0.35);\n X.lineTo(tx+td*0.45, ty+td*0.35);\n X.closePath(); X.fill();\n // tower outline\n X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;\n X.beginPath();\n X.moveTo(tx, ty); X.lineTo(tx, ty-th); X.lineTo(tx+tw, ty-th); X.lineTo(tx+tw, ty); X.closePath(); X.stroke();\n X.beginPath();\n X.moveTo(tx, ty); X.lineTo(tx+tw, ty); X.lineTo(tx+tw+td*0.45, ty+td*0.35); X.lineTo(tx+td*0.45, ty+td*0.35); X.closePath(); X.stroke();\n X.beginPath();\n X.moveTo(tx, ty-th); X.lineTo(tx+td*0.45, ty-th+td*0.35); X.stroke();\n X.beginPath();\n X.moveTo(tx+tw, ty-th); X.lineTo(tx+tw+td*0.45, ty-th+td*0.35); X.stroke();\n // front-face details: power button + drive bay slots + LED\n X.fillStyle='rgba(255,214,112,0.95)';\n X.beginPath(); X.arc(tx+3*z, ty-4*z, 1.4*z, 0, 7); X.fill(); // power LED\n // drive bay slots\n X.fillStyle='rgba(20,16,28,0.95)';\n X.fillRect(tx+2*z, ty-9*z, 10*z, 2*z);\n X.fillRect(tx+2*z, ty-13*z, 10*z, 2*z);\n X.fillRect(tx+2*z, ty-17*z, 10*z, 2*z);\n // blinking activity LED (green)\n var blink=Math.abs(Math.sin((performance.now()-t0)/300));\n X.fillStyle='rgba(110,224,166,'+(0.5+0.5*blink).toFixed(3)+')';\n X.beginPath(); X.arc(tx+11*z, ty-4*z, 0.9*z, 0, 7); X.fill();\n // mesh vent lines on right side (darker grooves)\n X.strokeStyle='rgba(12,8,18,0.7)'; X.lineWidth=0.5*z;\n for(var vi=0;vi<5;vi++){ X.beginPath(); X.moveTo(tx+tw+0.6*z, ty-2*z-vi*4*z); X.lineTo(tx+tw+td*0.45-0.6*z, ty-2*z-vi*4*z+td*0.35); X.stroke(); }\n\n // \u2500\u2500 MONITOR (right of center) \u2500\u2500\n // stand base, neck, screen\n var mx=gx+4*z, my=gy-11*z; // monitor base on desk\n var mw=18*z, mh=14*z; // screen width / height\n // monitor neck\n X.fillStyle='rgba(50,44,68,0.95)';\n X.beginPath(); X.moveTo(mx+5*z, my); X.lineTo(mx+13*z, my); X.lineTo(mx+12*z, my-3*z); X.lineTo(mx+6*z, my-3*z); X.closePath(); X.fill();\n // monitor stand base (small ellipse)\n X.fillStyle='rgba(40,34,52,0.95)';\n X.beginPath(); X.ellipse(mx+9*z, my+0.5*z, 6*z, 1.4*z, 0, 0, 7); X.fill();\n // monitor screen (lit, animated content)\n var scrX=mx-2*z, scrY=my-3*z - mh;\n // screen bezel\n X.fillStyle='rgba(20,16,28,0.98)';\n X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.fill();\n // screen surface \u2014 animated dashboard gradient\n var scrGrad=X.createLinearGradient(scrX, scrY, scrX, scrY+mh);\n scrGrad.addColorStop(0, n.connected?'rgba(36,76,108,0.95)':'rgba(48,30,30,0.95)');\n scrGrad.addColorStop(1, n.connected?'rgba(20,40,64,0.95)':'rgba(28,18,18,0.95)');\n X.fillStyle=scrGrad;\n X.beginPath(); X.roundRect(scrX+1*z, scrY+1*z, mw-2*z, mh-3*z, 1*z); X.fill();\n // screen content \u2014 animated bars (looks like a live dashboard)\n X.fillStyle='rgba(110,224,166,0.85)';\n for(var bi=0;bi<5;bi++){\n var bw=2.5*z, bh=(3 + 4*Math.abs(Math.sin((performance.now()-t0)/400 + bi*0.7)))*z;\n X.fillRect(scrX+2.5*z + bi*3*z, scrY+mh-3*z-bh, bw, bh);\n }\n // header bar (gold)\n X.fillStyle='rgba(255,214,112,0.7)';\n X.fillRect(scrX+1.5*z, scrY+2*z, mw-3*z, 1*z);\n // tiny \"REC\" dot\n X.fillStyle='rgba(255,90,90,0.95)';\n X.beginPath(); X.arc(scrX+3*z, scrY+4*z, 0.7*z, 0, 7); X.fill();\n // monitor bezel outline\n X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;\n X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.stroke();\n // brand bar at bottom of monitor\n X.fillStyle='rgba(255,214,112,0.85)';\n X.fillRect(scrX+mw*0.35, scrY+mh-1.4*z, mw*0.3, 0.6*z);\n\n // \u2500\u2500 KEYBOARD (in front of the monitor) \u2500\u2500\n var kbX=mx-1*z, kbY=gy-2*z;\n var kbW=18*z, kbH=4.5*z;\n X.fillStyle='rgba(28,24,36,0.95)';\n X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.fill();\n X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;\n X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.stroke();\n // key rows\n X.fillStyle='rgba(70,62,86,0.95)';\n for(var kr=0;kr<3;kr++){\n for(var kc=0;kc<10;kc++){\n X.fillRect(kbX+1*z + kc*1.7*z, kbY+0.5*z + kr*1.3*z, 1.2*z, 1*z);\n }\n }\n // spacebar\n X.fillRect(kbX+5*z, kbY+0.5*z+3*1.3*z, 6*z, 1*z);\n\n // \u2500\u2500 MOUSE (right of keyboard) \u2500\u2500\n var msX=kbX+kbW+1.5*z, msY=kbY+0.5*z;\n X.fillStyle='rgba(28,24,36,0.95)';\n X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.fill();\n X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;\n X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.stroke();\n // scroll wheel\n X.fillStyle='rgba(255,214,112,0.85)';\n X.fillRect(msX+1.4*z, msY+0.5*z, 0.3*z, 0.8*z);\n\n // \u2500\u2500 glowing signal lines coming from the monitor (radio waves) \u2500\u2500\n for(var ri=0;ri<3;ri++){\n var rphase=((performance.now()-t0)/1100 + ri*0.33) % 1;\n var rr=10*z + rphase*22*z;\n var ralpha=(1-rphase)*0.5;\n X.strokeStyle='rgba(255,214,112,'+ralpha.toFixed(3)+')';\n X.lineWidth=0.8*z;\n X.beginPath();\n X.arc(scrX+mw*0.5, scrY+mh*0.5, rr, Math.PI*0.85, Math.PI*1.15);\n X.stroke();\n }\n\n // \u2500\u2500 label below the workstation \u2500\u2500\n var labelX=gx;\n var labelY=gy+10*z;\n label(labelX, labelY, n.name, getVar('--gold-bright'), (n.connected?'HUB \u00B7 '+Math.round(n.load)+'%':'offline'));\n // hit-box covers the whole workstation footprint\n n._hit={x:gx-32*z, y:scrY-3*z, w:64*z, h:(labelY+8*z)-(scrY-3*z)};\n }\n function drawRobot(n){\n var p=iso(n.wx,n.wy), z=cam.z, now=performance.now();\n // Slight bob varies by status: live/talking robots get a livelier\n // bob, offline/ended ones stay still.\n var bobAmp = (n.status==='live'||n.status==='talking') ? 2.6*z\n : (n.status==='ended') ? 0.4*z\n : (n.connected ? 1.6*z : 0);\n var bob = bobAmp ? Math.sin(now/500 + (n.wx||0)) * bobAmp : 0;\n var bx=p.x, by=p.y-4*z-bob;\n var status = n.status || (n.connected ? 'ready' : 'offline');\n\n // \u2500\u2500 status-driven ground effect (drawn under the avatar) \u2500\u2500\n // Each status has a distinct footprint so you can read what's\n // happening from across the plaza: a green pulse for live, an\n // expanding sound ring for talking, a slow blue breathing\n // halo for ready/waiting, a fading grey ring for ended, and\n // a dim grey halo for offline.\n drawStatusGround(n, status, bx, p.y+3*z, z, now);\n\n // Shadow under the avatar (kept subtle so the new ground effect\n // is the visual focus).\n X.fillStyle='rgba(0,0,0,0.28)';\n X.beginPath(); X.ellipse(bx, p.y+3*z, 13*z, 5.5*z, 0, 0, Math.PI*2); X.fill();\n\n // Status-colored rim under the avatar (thin ring, same hue as\n // the status ground effect \u2014 binds the avatar to its footprint).\n var rimColor = statusRimColor(status);\n X.strokeStyle=rimColor; X.lineWidth=1.4*z;\n X.beginPath(); X.ellipse(bx, p.y+3*z, 18*z, 8*z, 0, 0, Math.PI*2); X.stroke();\n\n // Load arc (still useful \u2014 shows CPU pressure even on a live\n // robot). Drawn at a slightly larger radius than the status rim\n // so the two don't fight visually.\n if(n.connected) ring(bx, p.y+3*z, 18*z, n.load/100, loadColor(n.load));\n else padMark(bx, p.y+3*z, z);\n\n var col = n.connected ? rimColor : '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=40*z, ih=40*z*img.naturalHeight/img.naturalWidth;\n X.save();\n X.beginPath(); X.roundRect(bx-20*z, by-54*z, iw, ih+12*z, 8*z); X.clip();\n X.drawImage(img, bx-20*z, by-60*z, iw, ih+18*z);\n X.restore();\n } else {\n X.fillStyle='rgba(24,20,38,0.92)';\n X.beginPath(); X.roundRect(bx-17*z, by-36*z, 34*z, 36*z, 7*z); X.fill();\n X.fillStyle='rgba(30,24,44,0.95)';\n X.beginPath(); X.roundRect(bx-12*z, by-54*z, 24*z, 20*z, 5*z); X.fill();\n // Mouth/eyes default to the live color; \"talking\" status will\n // animate the mouth in drawStatusOverlays() below.\n X.fillStyle = n.connected ? getVar('--ok') : '#655';\n X.beginPath(); X.arc(bx-5*z, by-44*z, 3*z, 0, 7); X.arc(bx+5*z, by-44*z, 3*z, 0, 7); X.fill();\n // mouth dot \u2014 gets animated when status==='talking'\n X.fillStyle = (status==='talking') ? '#FF5B4A' : ((status==='ended') ? 'rgba(200,180,160,0.5)' : '#FF5B4A');\n X.beginPath(); X.arc(bx, by-65*z, (status==='talking' ? 2.2 + 1.4*Math.abs(Math.sin(now/85)) : 2.4)*z, 0, 7); X.fill();\n }\n X.globalAlpha=1;\n\n // status overlays: speech rings (talking), thought bubble\n // (waiting/ready), a \"\uD83D\uDC4B bye\" mark (ended). Drawn ABOVE the\n // avatar so they read at a glance from the rest of the plaza.\n drawStatusOverlays(n, status, bx, by, z, now);\n\n // sub-text under the avatar \u2014 show readiness + a short status word\n var subBits = [];\n if(n.sub && n.sub!==n.name) subBits.push(n.sub);\n if(n.connected){\n if(status==='talking') subBits.push('talking');\n else if(status==='live') subBits.push('live');\n else if(status==='ended') subBits.push('session ended');\n else if(status==='ready') subBits.push('waiting');\n subBits.push(Math.round(n.load||0)+'%');\n } else {\n subBits.push('not joined');\n }\n label(bx, p.y+17*z, n.label, col, subBits.join(' \u00B7 '));\n // \u2500\u2500 holographic name callout pill above the avatar (status-colored border) \u2500\u2500\n if(n.connected){\n var pillW=Math.max(70*z, (n.label.length*7 + 18) * z);\n var pillH=16*z;\n var pillR=8*z;\n var pillCx=bx;\n var pillCy=by - 8*z - 54*z - pillH*0.5;\n X.save();\n X.fillStyle='rgba(20,16,32,0.85)';\n X.beginPath();\n if(X.roundRect){\n X.roundRect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH, pillR);\n } else {\n X.rect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH);\n }\n X.fill();\n // pill border uses the same status hue as the ground effect\n X.strokeStyle = rimColor; X.lineWidth=1.0*z;\n X.stroke();\n X.fillStyle='rgba(255,232,160,0.95)';\n X.font='700 '+(10*z)+'px '+mono();\n X.textAlign='center'; X.textBaseline='middle';\n X.fillText(n.label, pillCx, pillCy);\n X.restore();\n // \u2500\u2500 pulsing status dot to the right of the pill (faster pulse for live, fastest for talking) \u2500\u2500\n var dotCx=pillCx + pillW*0.5 + 4*z;\n var dotCy=pillCy;\n var dotPeriod = status==='talking' ? 110 : (status==='live' ? 230 : 400);\n var dotAlpha=0.55+0.45*Math.abs(Math.sin(now/dotPeriod + (n.wx||0)));\n X.save();\n X.fillStyle=hexWithAlpha(rimColor, dotAlpha.toFixed(3));\n X.shadowColor=rimColor; X.shadowBlur=6*z;\n X.beginPath(); X.arc(dotCx, dotCy, 3*z, 0, Math.PI*2); X.fill();\n X.restore();\n }\n n._hit={x:bx-25*z,y:by-67*z,w:50*z,h:80*z};\n }\n\n // Map status \u2192 rgb base color (used for ground effect, rim, dot).\n // Kept in a small helper so all the status-driven effects stay in\n // sync visually.\n function statusRimColor(status){\n switch(status){\n case 'talking': return 'rgb(110,224,166)'; // bright green \u2014 voice is active\n case 'live': return 'rgb(255,214,112)'; // gold \u2014 session open, idle\n case 'ended': return 'rgb(180,170,160)'; // grey \u2014 session just ended, fading\n case 'ready': return 'rgb(110,180,255)'; // soft blue \u2014 waiting/ready\n case 'offline': return 'rgba(150,143,120,0.5)';\n default: return 'rgb(255,214,112)';\n }\n }\n // Build an rgba() string from an rgb() base + an alpha. Used so\n // shadowColor / strokeStyle can blend nicely against the gold grid.\n function hexWithAlpha(rgb, a){\n // Accepts \"rgb(r,g,b)\" or \"rgba(r,g,b,x)\"; returns rgba(r,g,b,a).\n // This file is embedded in a TypeScript template string, so regex\n // backslashes must be doubled or the browser receives an invalid regex.\n var m = /rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/.exec(rgb);\n if(!m) return rgb;\n return 'rgba('+m[1]+','+m[2]+','+m[3]+','+a+')';\n }\n\n // \u2500\u2500 status-driven ground effect under the avatar \u2500\u2500\n // Drawn before the avatar so the rim + ring sit on top. Each\n // status reads as a distinct footprint color + animation so the\n // plaza instantly tells you which robots are quiet, waiting,\n // listening, talking, or just stopped.\n function drawStatusGround(n, status, cx, cy, z, now){\n if(status === 'offline'){\n // dim grey aura (kept from the original)\n var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, 22*z);\n g.addColorStop(0,'rgba(140,140,160,0.16)');\n g.addColorStop(1,'rgba(140,140,160,0)');\n X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, 22*z, 11*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'live'){\n // breathing green pulse \u2014 small inner glow that throbs every\n // ~0.7s, plus the load arc's gold ring already drawn above.\n var beat = 0.5 + 0.5*Math.sin(now/350);\n var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, (18 + 6*beat)*z);\n g.addColorStop(0, 'rgba(110,224,166,'+(0.45 + 0.25*beat).toFixed(3)+')');\n g.addColorStop(0.6, 'rgba(110,224,166,0.12)');\n g.addColorStop(1, 'rgba(110,224,166,0)');\n X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, (18+6*beat)*z, (9+3*beat)*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'talking'){\n // 2-3 expanding sound rings (like \u2312) \u2014 color bright green, very\n // distinct from the gold \"live\" pulse so listeners know voice\n // is actually flowing right now.\n var ringCount = 2;\n for(var ti=0; ti<ringCount; ti++){\n var tphase = ((now/420) + ti*0.5) % 1;\n var tr = (10 + tphase*22)*z;\n var ta = (1 - tphase) * 0.7;\n X.strokeStyle='rgba(110,224,166,'+ta.toFixed(3)+')';\n X.lineWidth=1.6*z;\n X.beginPath(); X.ellipse(cx, cy, tr, tr*0.5, 0, 0, Math.PI*2); X.stroke();\n }\n // solid inner glow so the \"voice\" footprint reads even when\n // rings are between expansions.\n var tg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 14*z);\n tg.addColorStop(0, 'rgba(110,224,166,0.45)');\n tg.addColorStop(1, 'rgba(110,224,166,0)');\n X.fillStyle=tg; X.beginPath(); X.ellipse(cx, cy, 14*z, 7*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'ready'){\n // soft blue \"waiting\" halo \u2014 slow breath, calmer than live.\n var breath = 0.5 + 0.5*Math.sin(now/900);\n var rg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 20*z);\n rg.addColorStop(0, 'rgba(110,180,255,'+(0.18 + 0.10*breath).toFixed(3)+')');\n rg.addColorStop(1, 'rgba(110,180,255,0)');\n X.fillStyle=rg; X.beginPath(); X.ellipse(cx, cy, 20*z, 10*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'ended'){\n // session just ended \u2014 fading grey ring expanding outward,\n // like a sound ring dying down. Alpha drops to 0 over 4s.\n var mem = _statusMem[n.id] || {};\n var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;\n if(since > 4000) return;\n var ft = Math.min(1, since / 4000);\n var fr = (16 + ft*30)*z;\n var fa = (1 - ft) * 0.55;\n X.strokeStyle='rgba(180,170,160,'+fa.toFixed(3)+')';\n X.lineWidth=1.4*z;\n X.beginPath(); X.ellipse(cx, cy, fr, fr*0.5, 0, 0, Math.PI*2); X.stroke();\n return;\n }\n }\n\n // \u2500\u2500 status-driven overlays above the avatar \u2500\u2500\n // Speech rings (talking), thought-bubble \"?\" (ready/waiting),\n // fading \"\uD83D\uDC4B\" (ended). Drawn on top of the avatar so they read\n // at a glance.\n function drawStatusOverlays(n, status, bx, by, z, now){\n if(status === 'talking'){\n // Speech ring: 3 expanding arcs above the avatar, like\n // someone is mid-sentence. Color bright green to match the\n // ground pulse.\n for(var si=0; si<3; si++){\n var sphase = ((now/260) + si*0.34) % 1;\n var sa = (1 - sphase) * 0.85;\n var sr = (4 + sphase*9)*z;\n X.strokeStyle='rgba(110,224,166,'+sa.toFixed(3)+')';\n X.lineWidth=1.4*z;\n X.beginPath();\n X.arc(bx, by-58*z - sphase*4*z, sr, Math.PI*1.15, Math.PI*1.85);\n X.stroke();\n }\n return;\n }\n if(status === 'ready'){\n // Thought bubble: small cloud (3 circles) with a \"?\" inside.\n // Bobs gently so it reads as a thought, not a sticker.\n var bob = Math.sin(now/700) * 1.4*z;\n var bubbleX = bx + 20*z;\n var bubbleY = by - 64*z + bob;\n // tail circles\n X.fillStyle='rgba(220,234,255,0.95)';\n X.beginPath(); X.arc(bubbleX-7*z, bubbleY+8*z, 1.6*z, 0, Math.PI*2); X.fill();\n X.beginPath(); X.arc(bubbleX-4*z, bubbleY+4*z, 2.2*z, 0, Math.PI*2); X.fill();\n // main bubble\n X.beginPath(); X.arc(bubbleX, bubbleY, 6*z, 0, Math.PI*2); X.fill();\n X.strokeStyle='rgba(110,180,255,0.85)'; X.lineWidth=1.0*z; X.stroke();\n // \"?\" text\n X.fillStyle='rgba(60,90,140,0.95)';\n X.font='700 '+(7*z)+'px '+mono();\n X.textAlign='center'; X.textBaseline='middle';\n X.fillText('?', bubbleX, bubbleY+0.5*z);\n return;\n }\n if(status === 'ended'){\n // Fading \"bye\" wave \u2014 a small waving-hand glyph that rises and\n // fades out over 4s.\n var mem = _statusMem[n.id] || {};\n var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;\n if(since > 4000) return;\n var ft = since / 4000;\n var a = (1 - ft) * 0.95;\n var lift = ft * 14*z;\n X.save();\n X.globalAlpha = a;\n // small bubble background for legibility\n X.fillStyle='rgba(20,16,32,0.7)';\n X.beginPath(); X.arc(bx, by - 60*z - lift, 8*z, 0, Math.PI*2); X.fill();\n X.strokeStyle='rgba(180,170,160,0.9)'; X.lineWidth=0.8*z;\n X.stroke();\n // hand glyph (canvas-drawn, simpler than emoji so it always renders)\n var hx = bx, hy = by - 60*z - lift;\n X.fillStyle='rgba(255,214,112,0.95)';\n // palm\n X.beginPath(); X.arc(hx, hy + 0.5*z, 3.0*z, 0, Math.PI*2); X.fill();\n // 4 fingers\n for(var fi=-2; fi<=1; fi++){\n X.fillRect(hx + fi*1.2*z - 0.4*z, hy - 3.6*z, 0.8*z, 2.6*z);\n }\n // thumb\n X.beginPath(); X.arc(hx - 2.4*z, hy + 0.5*z, 0.9*z, 0, Math.PI*2); X.fill();\n // tiny side-to-side wave wobble\n var wobble = Math.sin(now/110) * 1.4*z;\n X.translate(wobble, 0);\n X.restore();\n return;\n }\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 // \u2500\u2500 Walker tick + draw \u2500\u2500\n // Advances each walker along its path at a steady world-space speed\n // (one segment in segDurMs ms). Frame-rate independent: every\n // frame we move the walker by the elapsed time, not a per-tick\n // constant. When a walker is within greet-distance of its next\n // pauseAt waypoint, it pauses for 1.4s in a wave pose; the nearest\n // connected robot pad fires a \uD83D\uDC4B pulse while it pauses.\n var _lastTickMs = 0;\n function tickWalkers(now){\n if(!_lastTickMs) _lastTickMs = now;\n var dt = Math.min(64, now - _lastTickMs); // clamp to avoid huge jumps on tab-switch\n _lastTickMs = now;\n for(var wi=0; wi<walkers.length; wi++){\n var w = walkers[wi];\n if(now < w.pauseUntil) continue;\n // Distance per frame for this walker = total path length / (segDurMs / dt) segments.\n // Convert to world units: distance = w.info.total * (dt / (w.path.length-1) / w.spec.segDurMs)\n // Simplified: dDistance = w.info.total * dt / ((w.path.length-1) * segDurMs)\n var stepDist = w.info.total * dt / ((w.path.length - 1) * w.spec.segDurMs);\n w.distance += stepDist;\n // Check whether we've crossed a pauseAt point during this tick.\n // Sample the current and previous position and see if either\n // got close to the next pending pauseAt waypoint. This is\n // cheaper than walking the whole polyline and handles fast\n // frame deltas.\n for(var pi=0; pi<w.spec.pauseAt.length; pi++){\n if(pi !== w.nextGreet % w.spec.pauseAt.length) continue;\n var pp = w.spec.pauseAt[pi];\n // current and previous world positions\n var cur = pathPosAt(w.info, w.distance);\n var prev = pathPosAt(w.info, w.distance - stepDist);\n var ca = w.path[cur.seg], cb = w.path[cur.seg+1] || w.path[0];\n var pa = w.path[prev.seg], pb = w.path[prev.seg+1] || w.path[0];\n var cwx = ca[0] + (cb[0]-ca[0])*cur.t, cwy = ca[1] + (cb[1]-ca[1])*cur.t;\n var pwx = pa[0] + (pb[0]-pa[0])*prev.t, pwy = pa[1] + (pb[1]-pa[1])*prev.t;\n // Closest distance from the line segment prev\u2192cur to the pause point.\n var dx = cwx - pwx, dy = cwy - pwy;\n var segLen2 = dx*dx + dy*dy;\n var t = 0;\n if(segLen2 > 1e-6){\n t = ((pp[0]-pwx)*dx + (pp[1]-pwy)*dy) / segLen2;\n if(t < 0) t = 0; else if(t > 1) t = 1;\n }\n var qx = pwx + dx*t, qy = pwy + dy*t;\n var ddx = qx - pp[0], ddy = qy - pp[1];\n if(ddx*ddx + ddy*ddy < 0.35*0.35){\n w.pauseUntil = now + 1400;\n w.nextGreet++;\n // pulse the nearest connected robot pad so the interaction is visible\n var best = null, bd = 4.0;\n for(var ni=0; ni<nodes.length; ni++){\n var nd = nodes[ni];\n if(nd.kind !== 'robot' || !nd.connected) continue;\n var nx = nd.wx - pp[0], ny = nd.wy - pp[1];\n var dd = nx*nx + ny*ny;\n if(dd < bd){ bd = dd; best = nd; }\n }\n if(best) pulse(best, '\uD83D\uDC4B');\n break;\n }\n }\n }\n // Cars share the same elapsed-dt clock so a tab-switch doesn't\n // catapult every car off the road at once. speed is world units\n // per second, dt is milliseconds. cdt is the already-clamped dt.\n // Per-frame speed jitter (\u00B15%) so the same lane never settles into\n // a stable cluster \u2014 the fastest car gradually pulls away from the\n // slowest one and the spread stays alive even after many wraps.\n var cdt = Math.min(64, dt);\n for(var ci=0; ci<CARS.length; ci++){\n var car = CARS[ci];\n // Deterministic per-car jitter based on wall-clock + index so it\n // changes every frame but isn't pure noise (smooth-ish accel).\n var jitter = 1 + (Math.sin(now/380 + ci*1.7) * 0.05);\n car.x += car.dir * car.speed * jitter * (cdt/1000);\n // Wrap the car around the road. When it leaves one end, it\n // re-enters the opposite end on the other lane \u2014 and the\n // re-entry x is randomized across the visible road so two\n // cars that arrived at the boundary together don't land on\n // top of each other.\n if(car.dir > 0 && car.x > PARK_W + 0.5){\n // Outbound (right-bound) car reached the right edge \u2014\n // re-enter on the left. We swap to the OTHER lane so two\n // lanes of traffic are visible at all times; the wrap dir\n // is explicitly set to +1 so the car continues in its\n // original direction on the new lane. (Previous version\n // computed dir from the *post-wrap* y, which always\n // flipped the car to the opposite direction \u2014 cars would\n // oscillate at the road edge instead of crossing it.)\n car.x = -0.5 - Math.random() * (PARK_W * 0.5);\n car.y = (car.y < -0.9) ? -0.65 : -1.18;\n car.dir = +1;\n } else if(car.dir < 0 && car.x < -0.5){\n // Inbound (left-bound) car reached the left edge \u2014\n // re-enter on the right, swap to the other lane, keep\n // direction at -1. (See note above about the prior bug.)\n car.x = PARK_W + 0.5 + Math.random() * (PARK_W * 0.5);\n car.y = (car.y < -0.9) ? -0.65 : -1.18;\n car.dir = -1;\n }\n }\n }\n function walkerPos(w){\n // Decode w.distance (in units of \"segments\") into a (seg, t) so\n // callers can project a real world point without re-walking the\n // path each time. The 'seg' returned is a continuous float, so a\n // walker mid-stride is at seg=1.6, for example \u2014 used by the\n // depth sort to get a smooth iso-depth from the world point.\n var path = w.path, info = w.info;\n var d = ((w.distance % (path.length-1)) + (path.length-1)) % (path.length-1);\n var segI = Math.floor(d);\n var segT = d - segI;\n var a = path[segI], b = path[segI+1] || path[0];\n return [ a[0] + (b[0]-a[0])*segT, a[1] + (b[1]-a[1])*segT ];\n }\n function drawWalker(w, now){\n var pos = walkerPos(w);\n var p = iso(pos[0], pos[1]);\n var z = cam.z;\n // Direction along the path: average the next 0.5s of motion so the\n // figure doesn't snap on segment turns.\n var lookAhead = walkerPos({path:w.path, info:w.info, distance: w.distance + 0.5});\n var lh = iso(lookAhead[0], lookAhead[1]);\n var facingRight = (lh.x - p.x) >= 0;\n // Gait cycle: tied to distance travelled (not wall clock) so the\n // animation is smooth at any speed. ~1 step per 0.4 world units.\n var phase = w.distance * 2.4;\n // Use a cosine for the step so foot-down poses linger briefly at\n // each extreme \u2014 reads as a real walk rather than a sine wobble.\n var stepC = Math.cos(phase);\n var stepS = Math.sin(phase);\n // Bob: only on the up-phase of the step (plant foot + rise).\n var bob = Math.max(0, stepC) * 1.0 * z;\n // Body lean: very slight forward lean into the direction of travel.\n var lean = 0.6 * z;\n var greeting = now < w.pauseUntil;\n // Anchor on the ground (shadow), body sits above.\n var gx = p.x, gy = p.y + 2*z;\n X.save();\n if(!facingRight){ X.translate(gx, 0); X.scale(-1, 1); X.translate(-gx, 0); }\n // shadow ellipse \u2014 softens and shrinks while in the air\n X.fillStyle='rgba(0,0,0,0.28)';\n X.beginPath(); X.ellipse(gx, gy, (4.6 - bob*0.5)*z, 1.8*z, 0, 0, Math.PI*2); X.fill();\n // legs (two angled lines from the hip, swinging opposite with cosine)\n var hipX = gx + lean, hipY = gy - 9*z + bob;\n var legSpread = 2.6*z;\n var legSwing = stepC * 3.0*z;\n X.strokeStyle = w.spec.pants; X.lineWidth = 1.9*z; X.lineCap = 'round';\n // Back leg (planted)\n X.beginPath();\n X.moveTo(hipX - legSpread*0.5, hipY);\n X.lineTo(hipX - legSpread*0.5 - legSwing, gy);\n X.stroke();\n // Front leg (swinging forward)\n X.beginPath();\n X.moveTo(hipX + legSpread*0.5, hipY);\n X.lineTo(hipX + legSpread*0.5 + legSwing, gy);\n X.stroke();\n // shoes \u2014 small dark caps at the foot so the step reads\n X.fillStyle = '#1a1410';\n X.beginPath(); X.ellipse(hipX - legSpread*0.5 - legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();\n X.beginPath(); X.ellipse(hipX + legSpread*0.5 + legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();\n // torso (shirt) \u2014 slightly tilted into the lean\n X.save();\n X.translate(hipX, hipY - 4*z);\n X.rotate(lean / 10);\n X.fillStyle = w.spec.shirt;\n X.beginPath(); X.roundRect(-3.2*z, -3.6*z, 6.4*z, 7.2*z, 1.6*z); X.fill();\n // belt\n X.fillStyle = 'rgba(0,0,0,0.25)';\n X.fillRect(-3.2*z, 2.4*z, 6.4*z, 0.9*z);\n X.restore();\n // arms (opposite-phase to the legs for a natural gait)\n X.strokeStyle = w.spec.shirt; X.lineWidth = 1.6*z; X.lineCap = 'round';\n var armSwing = stepC * 1.6*z;\n if(greeting){\n // waving arm: raised and gently flicking\n var flick = Math.sin(now/85) * 1.2*z;\n X.beginPath();\n X.moveTo(hipX - 2.8*z, hipY - 6*z);\n X.lineTo(hipX - 5*z + flick, hipY - 12*z);\n X.stroke();\n // resting arm \u2014 small bend so it doesn't look stiff\n X.beginPath();\n X.moveTo(hipX + 2.8*z, hipY - 6*z);\n X.lineTo(hipX + 4.4*z, hipY - 1.4*z);\n X.stroke();\n } else {\n X.beginPath();\n X.moveTo(hipX - 2.8*z, hipY - 6*z);\n X.lineTo(hipX - 4.2*z - armSwing, hipY - 0.6*z);\n X.moveTo(hipX + 2.8*z, hipY - 6*z);\n X.lineTo(hipX + 4.2*z + armSwing, hipY - 0.6*z);\n X.stroke();\n }\n // head (skin tone circle) \u2014 slight up/down nod with the step\n var headY = hipY - 11*z + bob*0.4;\n X.fillStyle = '#e8c39a';\n X.beginPath(); X.arc(hipX + lean*0.3, headY, 2.2*z, 0, Math.PI*2); X.fill();\n // hair cap (slightly different for each walker for visual variety)\n X.fillStyle = w.idx % 3 === 0 ? '#2a2018' : (w.idx % 3 === 1 ? '#4a2a18' : '#1a1810');\n X.beginPath(); X.arc(hipX + lean*0.3, headY - 0.9*z, 2.2*z, Math.PI*1.05, Math.PI*1.95); X.fill();\n // hat / accessory variant for walker 4 & 5 so they read as distinct characters\n if(w.idx === 4 || w.idx === 5){\n X.fillStyle = w.idx === 4 ? '#3a4a6a' : '#7a5a2a';\n X.beginPath(); X.ellipse(hipX + lean*0.3, headY - 1.7*z, 2.6*z, 0.7*z, 0, 0, Math.PI*2); X.fill();\n }\n X.restore();\n }\n\n function frame(){\n if(!running) return;\n var now = performance.now();\n X.clearRect(0,0,W,H);\n // \u2500\u2500 atmosphere: sky + sun + clouds + distant mountains + haze \u2500\u2500\n // Painted first, behind everything else, so the rest of the map\n // sits on top of an actual horizon instead of on the empty page\n // background. Drawn every frame so the sun and clouds drift even\n // when the rest of the scene is static.\n drawAtmosphere(now);\n drawGround(now);\n // step the looping walkers (and road cars) along their paths\n tickWalkers(now);\n // Re-derive status every frame so the \"ended\" countdown (a wall-\n // clock timer) decrements smoothly between telemetry polls (3s).\n for(var sni=0; sni<nodes.length; sni++){\n var sn = nodes[sni];\n if(sn.kind === 'robot'){\n var st = computeStatus(sn.telemetry, sn);\n sn.status = st.status;\n sn.readiness = st.readiness;\n sn.readinessMessage = st.readinessMessage;\n }\n }\n // depth-sort palms + nodes + walkers 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 for(var wii=0; wii<walkers.length; wii++){ var wpos=walkerPos(walkers[wii]); objs.push({wx:wpos[0],wy:wpos[1],walker:walkers[wii]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n objs.forEach(function(o){\n if(o.palm) drawPalm(o.wx,o.wy);\n else if(o.walker) drawWalker(o.walker, now);\n else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); }\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 // Pull the latest operator-saved positions from the scheduler\n // and seed the in-memory override map. Without this step the\n // dashboard would only know about local (this-session) drags\n // and would reset to slot defaults on every reload.\n try{\n getJson('/api/character-presets'+QS).then(function(list){\n if(Array.isArray(list)){\n for(var pi=0; pi<list.length; pi++){\n var p = list[pi];\n if(p && p.id && typeof p.nx === 'number' && typeof p.ny === 'number'){\n _parkPosOverrides[p.id] = { nx: p.nx, ny: p.ny };\n }\n }\n }\n // re-apply on the live node list (if it's already populated\n // by a prior /fed/status frame)\n if(typeof _applyPosOverrides === 'function') _applyPosOverrides();\n }).catch(function(){ /* offline \u2014 use slot defaults */ });\n }catch(e){}\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; deviceSelectorsReady=false;\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 preferred=schedulerDevice(n), preferredId=preferred&&preferred.id;\n var row=(preferredId&&roboparkByName[String(preferredId).toLowerCase()]) || roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase(),String(preferredId||'').toLowerCase()]);\n var seen={}, candidates=[];\n for(var rk in roboparkByName){\n var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);\n if(!candidate||!cid||seen[cid]) continue;\n seen[cid]=true;\n var label=String(candidate.name||'').toLowerCase(), idlabel=String(cid).toLowerCase(), matches=false;\n for(var ki=0;ki<keys.length;ki++){ var key=String(keys[ki]||'').toLowerCase(); if(key&&(label===key||idlabel===key||label.indexOf(key)>-1||key.indexOf(label)>-1)){ matches=true; break; } }\n if(matches) candidates.push(candidate);\n }\n function readyScore(candidate){\n var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;\n if(preferredId && String(candidate.robot_id||candidate.id||'')===String(preferredId)) score+=100000;\n if(candidate.production_mode===true||device.production_mode===true) score+=1000;\n if(status==='online'||status==='running'||status==='detecting') score+=500;\n if(candidate.readiness_code==='ready') score+=250;\n if(candidate===exact) score+=10;\n return score;\n }\n candidates.sort(function(a,b){ return readyScore(b)-readyScore(a); });\n return candidates[0]||row;\n }\n function schedId(n){\n // Device ids are authoritative. A legacy robots row may share the\n // name but have no preview, heartbeat, or hardware inventory.\n var device=schedulerDevice(n); if(device&&device.id) return device.id;\n var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;\n if(n.schedulerRobotId) return n.schedulerRobotId;\n return n.name;\n }\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(pipelineTimer){ clearInterval(pipelineTimer); pipelineTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n lkAudioEls.forEach(function(el){try{el.pause();el.remove();}catch(e){}});lkAudioEls=[];\n if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}micMonitorSource=null;micMonitorGain=null;\n micMonitorAudible=false;micMonitorConnecting=false;micMonitorState='no active conversation session';\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 // Per-robot production mode: PATCHes the device row, then immediately\n // arms/disarms this robot's continuous motion-triggered loop + always-on\n // camera feed rather than waiting for the next poll cycle to notice.\n function setProductionMode(n, on){\n var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }\n rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),{production_mode:!!on}).then(function(){\n showOut('production mode '+(on?'enabled':'disabled')+' for '+(device.name||id));\n if(device) device.production_mode=!!on;\n if(on && !previewOn && !lkRoom) startCam(n);\n if(!on && previewOn) stopPreview(n);\n }).catch(function(e){ var pm=$('pk-prodmode'); if(pm) pm.checked=!on; showOut('production mode ('+id+'): '+e.message); });\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='\u21BB Reconnect camera'; b.classList.remove('danger'); } }\n function goLive(n){ stopVision(); tryVisionOverlay(n,true); }\n function startPreview(n){\n previewOn=true; updateGoLive();\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n idleCam('connecting');\n tryVisionOverlay(n, true);\n return;\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); tryVisionOverlay(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(e){ idleCam('unreachable'); if(note) note.textContent='preview request failed'; if(window.console) console.error('RoboPark preview request failed',e); 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 var directNote=$('pk-golivenote'); if(directNote) directNote.textContent='';\n tryVisionOverlay(n,true);\n return;\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 // LiveKit is the authoritative camera feed. The standalone OpenCV MJPEG\n // server is opt-in because running it beside preview_agent would open the\n // same Windows camera twice and make both feeds unreliable.\n function tryVisionOverlay(n, force){\n var directImg=$('pk-vision-img'); if(!directImg) return;\n // Names stay stable when a robot is re-enrolled and receives a new mesh\n // node id. The hub resolves the name to the currently connected peer.\n var directRef=n.displayName||n.name||n.nodeId||n.id;\n if(!directRef){ visionOn=false; directImg.src=''; directImg.style.display='none'; return; }\n var directCamNote=$('pk-camnote'), directLiveNote=$('pk-golivenote'), directRes=$('pk-camres');\n visionOn=true;\n directImg.style.display='block';\n if(directCamNote)directCamNote.textContent='opening RoboVision camera relay...';\n if(directRes)directRes.textContent='CONNECTING';\n directImg.onerror=function(){ visionOn=false; directImg.style.display='none'; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable; reconnecting...'; if(directLiveNote) directLiveNote.textContent='camera reconnecting'; if(directRes) directRes.textContent='RETRYING'; if(visionRetryTimer)clearTimeout(visionRetryTimer);visionRetryTimer=setTimeout(function(){if(drawerId&&byId[drawerId]===n&&!visionOn)tryVisionOverlay(n,true);},3000); };\n directImg.onload=function(){ visionOn=true;if(visionRetryTimer){clearTimeout(visionRetryTimer);visionRetryTimer=null;}directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };\n directImg.src='/fed/media/robots/'+encodeURIComponent(directRef)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();\n return;\n var img=$('pk-vision-img'); if(!img) return;\n if(!force && String(window.localStorage.getItem('robopark.visionOverlay')||'').toLowerCase()!=='true'){\n visionOn=false; img.src=''; img.style.display='none'; return;\n }\n var row=schedRow(n)||{}, device=schedulerDevice(n)||{};\n var preferTail=/^100./.test(location.hostname);\n var ip=preferTail ? (device.tailscale_ip||row.tailscale_ip||device.lan_ip||row.lan_ip) : (device.lan_ip||row.lan_ip||device.tailscale_ip||row.tailscale_ip);\n if(!ip){ visionOn=false; img.src=''; img.style.display='none'; return; }\n var port=row.vision_port||5000;\n visionOn=true;\n img.onerror=function(){ visionOn=false; img.style.display='none'; img.src=''; };\n img.onload=function(){ img.style.display='block'; };\n img.src='http://'+ip+':'+port+'/video_feed';\n }\n function stopVision(){\n visionOn=false;\n if(visionRetryTimer){clearTimeout(visionRetryTimer);visionRetryTimer=null;}\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[String(x.robot_id||x.id||x.name||'robot').toLowerCase()]=x; if(x.name) roboparkByName[String(x.name).toLowerCase()]=x; });\n refreshDrawer();\n }).catch(function(){});\n }\n function schedulerDevice(n){\n // Deliberately does not call schedRow: telemetry can contain a legacy\n // robot name, while this lookup must choose the current enrolled device.\n var ids=[n.schedulerDeviceId,n.schedulerRobotId,n.id,n.nodeId];\n for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]]) return schedulerDevicesById[ids[i]];\n var names=[n.name,n.displayName,n.label];\n for(var j=0;j<names.length;j++){\n var key=String(names[j]||'').toLowerCase();\n if(key&&schedulerDevicesByName[key]) return schedulerDevicesByName[key];\n }\n return null;\n }\n function deviceList(inv, key, fallback){\n var list=inv&&Array.isArray(inv[key])?inv[key]:[];\n return list.length?list:fallback.map(function(id){ return {id:id,name:id}; });\n }\n function hasRealInventory(inv){ return !!(inv && ((inv.video&&inv.video.length>2) || (inv.audio_input&&inv.audio_input.length>1) || (inv.audio_output&&inv.audio_output.length>1))); }\n function refreshDeviceSelectors(n){\n var device=schedulerDevice(n)||{}, inv=device.device_inventory||{};\n if(hasRealInventory(inv)) deviceSelectorsReady=true;\n var query='';\n // Launch hardware profile shared by every deployed robot. Do not expose\n // unstable PortAudio indexes, HDMI, or Pi codec devices in production.\n var video=[{id:'/dev/video0',name:'H65 USB CAMERA (/dev/video0)'}];\n var input=[{id:'2',name:'USB microphone (hw:3,0)'}];\n var output=[{id:'1',name:'USB speaker (hw:2,0)'}];\n function fill(id, list, selected){\n var el=$(id); if(!el) return;\n // Heartbeat polling rebuilds this panel frequently. Preserve an\n // operator's unsaved choice instead of snapping back to the last\n // scheduler value while they move between camera/mic/speaker fields.\n var effectiveSelected=el.dataset.dirty==='true'?el.value:selected;\n el.innerHTML='';\n list.filter(function(x){ return !query || String(x.name||x.id).toLowerCase().indexOf(query)>-1 || String(x.id).toLowerCase().indexOf(query)>-1; }).forEach(function(x){\n var o=document.createElement('option'); o.value=String(x.id); o.textContent=String(x.name||x.id); if(String(x.id)===String(effectiveSelected||'')) o.selected=true; el.appendChild(o);\n });\n if(!el.value && effectiveSelected){ var o=document.createElement('option'); o.value=String(effectiveSelected); o.textContent=String(effectiveSelected)+(el.dataset.dirty==='true'?' (selected)':' (saved)'); o.selected=true; el.appendChild(o); }\n }\n fill('pk-video-device', video, '/dev/video0');\n fill('pk-audio-input', input, '2');\n fill('pk-audio-output', output, '1');\n var selectedOutput=$('pk-audio-output'),selectedLabel=$('pk-spk-selected');\n if(selectedLabel&&selectedOutput){var selectedOption=selectedOutput.options&&selectedOutput.selectedIndex>=0?selectedOutput.options[selectedOutput.selectedIndex]:null;selectedLabel.textContent=selectedOption?String(selectedOption.textContent||selectedOption.value):String(selectedOutput.value||'default');}\n var greetings=$('pk-greeting-phrases');\n if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));\n var testPhrase=$('pk-spk-phrase');if(testPhrase&&document.activeElement!==testPhrase&&device.greeting_phrases&&device.greeting_phrases.length)testPhrase.value=device.greeting_phrases[0];\n var note=$('pk-device-note');\n if(note){\n if(!device.id && n && n.connected){\n note.textContent='mesh connected; scheduler identity is bootstrapping or failed';\n return;\n }\n var source=inv.source||'robot heartbeat', counts=(inv.video||[]).length+'/'+(inv.audio_input||[]).length+'/'+(inv.audio_output||[]).length;\n note.textContent=hasRealInventory(inv) ? ('inventory from '+source+' \u00B7 camera/mic/speaker '+counts) : (device.id ? 'no real hardware inventory reported yet \u00B7 restart vision-agent + preview-agent on this robot' : 'waiting for robot heartbeat inventory');\n }\n }\n function saveDeviceSelectors(n){\n var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }\n var body={video_device:'/dev/video0', audio_device:'2', audio_output_device:'1',\n greeting_phrases:($('pk-greeting-phrases').value||'').split(String.fromCharCode(10)).map(function(x){return x.trim();}).filter(Boolean)};\n var button=$('pk-device-save'),note=$('pk-device-note');\n if(button){button.disabled=true;button.textContent='Saving...';}\n if(note)note.textContent='saving device selection to scheduler...';\n rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),body).then(function(saved){\n // Keep the drawer's local scheduler record current immediately. The\n // next /devices poll will confirm the same values from persistent DB.\n Object.assign(device,saved||body);\n ['pk-video-device','pk-audio-input','pk-audio-output'].forEach(function(field){var el=$(field);if(el)delete el.dataset.dirty;});\n refreshDeviceSelectors(n);\n if(note)note.textContent='saved \u00B7 robot will apply selection on its next config heartbeat';\n if(button){button.textContent='Saved';setTimeout(function(){if($('pk-device-save')===button)button.textContent='Save device selection + greetings';},1400);}\n showOut('camera, microphone, and speaker selection saved for '+(device.name||id));\n }).catch(function(e){\n if(note)note.textContent='save failed \u00B7 '+e.message;\n if(button)button.textContent='Retry save';\n showOut('device selection: '+e.message);\n }).then(function(){if(button)button.disabled=false;});\n }\n function fmtUptime(secs){\n if(secs==null) return '';\n secs=Math.round(secs);\n if(secs<60) return secs+'s';\n if(secs<3600) return Math.round(secs/60)+'m';\n return Math.round(secs/3600)+'h';\n }\n function restartService(n, svcName, btn){\n var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }\n if(btn){ btn.disabled=true; btn.textContent='queuing\u2026'; }\n rpAction('POST','/robopark/api/devices/'+encodeURIComponent(id)+'/services/'+encodeURIComponent(svcName)+'/restart',{}).then(function(){\n showOut('restart queued for '+svcName+' \u2014 picked up on the next robot check-in, up to about 10s');\n if(btn){ btn.textContent='queued'; }\n }).catch(function(e){\n showOut('restart ('+svcName+'): '+e.message);\n if(btn){ btn.disabled=false; btn.textContent='restart'; }\n });\n }\n function refreshSupervisorPanel(n){\n var list=$('pk-svc-list'), age=$('pk-svc-age'); if(!list) return;\n var device=schedulerDevice(n);\n var services=device&&device.supervisor_status;\n var reportedAt=device&&device.supervisor_status_at;\n if(!services || !services.length){\n list.innerHTML='<div class=\"pk-note\">no supervisor report yet \u2014 either robot_supervisor.py is not running on this robot, or it has not enrolled (no device_token) yet</div>';\n if(age) age.textContent='';\n return;\n }\n list.innerHTML=services.map(function(s){\n var cls=!s.enabled?'disabled':(s.running?'up':'down');\n var meta=!s.enabled?'disabled'\n : s.running?('pid '+s.pid+' \u00B7 up '+fmtUptime(s.uptime_seconds)+(s.failure_count?(' \u00B7 '+s.failure_count+' restart'+(s.failure_count===1?'':'s')+' so far'):''))\n : ('down'+(s.last_exit_code!=null?(' \u00B7 exit '+s.last_exit_code):'')+(s.failure_count?(' \u00B7 retry #'+s.failure_count):''));\n var btn=s.enabled?('<button class=\"rp-btn pk-svc-restart-btn\" data-svc=\"'+esc(s.name)+'\">restart</button>'):'';\n return '<div class=\"pk-svc-row '+cls+'\"><span class=\"pk-svc-dot\"></span><span class=\"pk-svc-name\">'+esc(s.name)+'</span><span class=\"pk-svc-meta\">'+esc(meta)+'</span>'+btn+'</div>';\n }).join('');\n if(age){\n if(reportedAt){\n var ageSec=(Date.now()-new Date(reportedAt+'Z').getTime())/1000;\n age.textContent = ageSec>30 ? ('\u26A0 last report '+fmtUptime(ageSec)+' ago \u2014 supervisor may be down') : ('last report '+fmtUptime(ageSec)+' ago');\n age.style.color = ageSec>30 ? 'var(--bad)' : '';\n } else { age.textContent=''; }\n }\n }\n\n // \u2500\u2500 C1: Per-robot shell section \u2500\u2500\n // Lets an operator tail service logs and run a small allowlist of\n // diagnostic commands (uptime, free -m, ps aux, etc.) on the robot\n // itself, without leaving the dashboard or opening a terminal. The\n // round trip is:\n // 1. dashboard POSTs /robopark/api/robots/{id}/shell/{tail,exec}\n // 2. scheduler queues a row in shell_requests\n // 3. robot supervisor's next status-report poll receives the row\n // in its commands field, runs the request on a background\n // thread, and POSTs the result to /api/devices/{id}/supervisor-output\n // 4. dashboard long-polls /robopark/api/robots/{id}/shell/wait/{rid}\n // with an 8s budget and renders the result.\n //\n // The buffer is per-robot, bounded at 5000 lines, persists across\n // drawer open/close. Service picker is populated from the supervisor\n // report (the same list shown in the section above).\n var SHELL_MAX_LINES = 5000;\n var _shellBuffers = {}; // device_id -> {lines:[{t, kind, text}], lastReqId, lastService}\n\n function _shellBuf(robotId){\n if(!_shellBuffers[robotId]) _shellBuffers[robotId] = { lines:[], lastReqId:null, lastService:'' };\n return _shellBuffers[robotId];\n }\n function _shellAppend(robotId, kind, text){\n var buf = _shellBuf(robotId);\n var now = Date.now();\n var lineText = String(text == null ? '' : text);\n // split on newlines so a multi-line result renders as separate rows\n var parts = lineText.split(/\\r?\\n/);\n for(var i=0;i<parts.length;i++){\n if(!parts[i] && i===parts.length-1) continue; // skip trailing empty\n buf.lines.push({ t: now, kind: kind, text: parts[i] });\n }\n while(buf.lines.length > SHELL_MAX_LINES) buf.lines.shift();\n }\n function _shellStatus(robotId, status, detail){\n _shellAppend(robotId, 'status', status + (detail ? (' \u00B7 '+detail) : ''));\n }\n function _renderShellBuffer(robotId){\n var out = $('pk-shell-out'); if(!out) return;\n var buf = _shellBuf(robotId);\n var html = '';\n for(var i=0;i<buf.lines.length;i++){\n var ln = buf.lines[i];\n var cls = ln.kind === 'status' ? 'pk-shell-status'\n : ln.kind === 'stderr' ? 'pk-shell-stderr'\n : ln.kind === 'header' ? 'pk-shell-header'\n : ln.kind === 'input' ? 'pk-shell-input'\n : 'pk-shell-stdout';\n var hh = (new Date(ln.t)).toTimeString().slice(0,8);\n html += '<div class=\"pk-shell-line '+cls+'\">'\n + '<span class=\"pk-shell-ts\">'+hh+'</span>'\n + '<span class=\"pk-shell-text\">'+esc(ln.text)+'</span>'\n + '</div>';\n }\n out.innerHTML = html;\n // auto-scroll if the user hasn't scrolled up\n if(out.__autoscroll !== false){\n out.scrollTop = out.scrollHeight;\n }\n }\n // Build the markup for the Shell section. Pulls the list of\n // available services from the supervisor_status report (if any),\n // otherwise shows a placeholder.\n function buildShellSection(robotId){\n var device = robotId ? schedulerDevicesByName[String(robotName({name:robotId, id:robotId})||'').toLowerCase()] || null : null;\n // Try to read the device row by id directly\n try{\n for(var k in schedulerDevicesById){\n if(k === robotId){ device = schedulerDevicesById[k]; break; }\n }\n }catch(e){}\n var services = device && device.supervisor_status;\n var svcOptions = '';\n if(services && services.length){\n for(var i=0;i<services.length;i++){\n svcOptions += '<option value=\"'+esc(services[i].name)+'\">'+esc(services[i].name)+'</option>';\n }\n } else {\n svcOptions = '<option value=\"\">\u2014 services will appear once the supervisor reports \u2014</option>';\n }\n return ''\n + '<div class=\"pk-sect\" id=\"pk-shell-sect\">'\n + '<h4>\uD83D\uDCBB shell <span class=\"pk-note\" style=\"font-weight:400;margin-left:0.4rem;\">per-robot logs &amp; diagnostics</span></h4>'\n + '<div class=\"pk-shell-row\">'\n + '<label class=\"pk-shell-lbl\">Service</label>'\n + '<select id=\"pk-shell-svc\" class=\"pk-shell-sel\" data-robot=\"'+esc(robotId||'')+'\">'+svcOptions+'</select>'\n + '<label class=\"pk-shell-lbl\" style=\"margin-left:0.5rem;\">Lines</label>'\n + '<input type=\"number\" id=\"pk-shell-tail\" class=\"pk-shell-num\" value=\"200\" min=\"10\" max=\"2000\" step=\"10\">'\n + '<button type=\"button\" class=\"rp-btn\" id=\"pk-shell-fetch\" data-robot=\"'+esc(robotId||'')+'\" style=\"margin-left:0.4rem;\">Tail logs</button>'\n + '</div>'\n + '<div class=\"pk-shell-out-wrap\">'\n + '<div class=\"pk-shell-out\" id=\"pk-shell-out\" tabindex=\"0\" role=\"log\" aria-label=\"robot shell output\"><div class=\"pk-shell-status\">\u2014 shell idle \u2014</div></div>'\n + '</div>'\n + '<div class=\"pk-shell-row\" style=\"margin-top:0.4rem;\">'\n + '<input type=\"text\" id=\"pk-shell-cmd\" list=\"pk-shell-cmd-list\" placeholder=\"uptime, free, df, ps, systemctl status preview_agent, journalctl -u preview_agent, \u2026\" autocomplete=\"off\" style=\"flex:1;min-width:0;\">'\n + '<datalist id=\"pk-shell-cmd-list\">'\n + '<option value=\"uptime\"><option value=\"free\"><option value=\"df\"><option value=\"ps\"><option value=\"uname\"><option value=\"hostname\"><option value=\"ip\"><option value=\"ss\">'\n + '<option value=\"systemctl-status\"><option value=\"journalctl\"><option value=\"ls-logs\"><option value=\"os-release\">'\n + '</datalist>'\n + '<button type=\"button\" class=\"rp-btn primary\" id=\"pk-shell-run\" data-robot=\"'+esc(robotId||'')+'\" style=\"margin-left:0.4rem;\">Run</button>'\n + '<button type=\"button\" class=\"rp-btn\" id=\"pk-shell-autoscroll\" data-robot=\"'+esc(robotId||'')+'\" title=\"Auto-scroll to the newest line\" style=\"margin-left:0.4rem;\">\u2913 auto</button>'\n + '<button type=\"button\" class=\"rp-btn\" id=\"pk-shell-clear\" data-robot=\"'+esc(robotId||'')+'\" style=\"margin-left:0.4rem;\">Clear</button>'\n + '</div>'\n + '<div class=\"pk-shell-status\" id=\"pk-shell-stateline\" style=\"margin-top:0.35rem;\">idle</div>'\n + '<div class=\"pk-note\" id=\"pk-shell-help\" style=\"margin-top:0.35rem;\">'\n + 'Run a pre-approved diagnostic on this robot. Output is captured by robot_supervisor.py (8s budget, 64KB cap, allowlisted commands only).'\n + '</div>'\n + '</div>';\n }\n // Wire the shell section's events. Bound once per buildDrawer call,\n // but the inputs are re-created each time so we re-bind here.\n function _bindShellSection(n){\n if(n.kind !== 'robot') return;\n var robotId = schedId(n);\n if(!robotId) return;\n var $out = $('pk-shell-out');\n if($out && $out.__autoscroll === undefined) $out.__autoscroll = true;\n // initial render of any previously-captured output for this robot\n _renderShellBuffer(robotId);\n var svcSel = $('pk-shell-svc');\n if(svcSel && !svcSel.value && svcSel.options.length){\n // pick the first enabled-looking service\n for(var i=0;i<svcSel.options.length;i++){\n if(svcSel.options[i].value){ svcSel.selectedIndex = i; break; }\n }\n }\n var fetchBtn = $('pk-shell-fetch');\n if(fetchBtn) fetchBtn.addEventListener('click', function(){\n var svc = ($('pk-shell-svc')||{}).value || '';\n var lines = parseInt(($('pk-shell-tail')||{}).value, 10) || 200;\n _shellTail(robotId, svc, lines);\n });\n var runBtn = $('pk-shell-run');\n if(runBtn) runBtn.addEventListener('click', function(){\n var inp = $('pk-shell-cmd');\n if(!inp) return;\n var raw = (inp.value || '').trim();\n if(!raw) return;\n // parse \"name param1=val1 param2=val2\" into the params object\n var parts = raw.split(/\\s+/);\n var name = parts.shift();\n var params = {};\n for(var i=0;i<parts.length;i++){\n var eq = parts[i].indexOf('=');\n if(eq < 0) continue;\n params[parts[i].slice(0, eq)] = parts[i].slice(eq+1);\n }\n // convenience: if the name is systemctl-status or journalctl,\n // auto-fill <name> from the selected service\n if((name === 'systemctl-status' || name === 'journalctl' || name === 'tail') && !params.name && !params.path){\n var svc = ($('pk-shell-svc')||{}).value || '';\n if(name === 'tail' && svc) params.path = svc + '.log';\n else if(svc) params.name = svc;\n }\n if(name === 'tail' && !params.n) params.n = String(parseInt(($('pk-shell-tail')||{}).value, 10) || 200);\n // log the command itself\n _shellAppend(robotId, 'input', '$ '+raw);\n inp.value = '';\n _shellExec(robotId, name, params);\n });\n var cmdInp = $('pk-shell-cmd');\n if(cmdInp) cmdInp.addEventListener('keydown', function(ev){\n if(ev.key === 'Enter'){ ev.preventDefault(); var b=$('pk-shell-run'); if(b) b.click(); }\n });\n var autoBtn = $('pk-shell-autoscroll');\n if(autoBtn) autoBtn.addEventListener('click', function(){\n var o = $('pk-shell-out'); if(!o) return;\n o.__autoscroll = !o.__autoscroll;\n autoBtn.classList.toggle('primary', !!o.__autoscroll);\n if(o.__autoscroll) o.scrollTop = o.scrollHeight;\n });\n if(autoBtn) autoBtn.classList.add('primary');\n var clearBtn = $('pk-shell-clear');\n if(clearBtn) clearBtn.addEventListener('click', function(){\n _shellBuffers[robotId] = { lines:[], lastReqId:null, lastService:'' };\n _renderShellBuffer(robotId);\n _setShellStatus(robotId, 'cleared');\n });\n // user scrolled up disables autoscroll\n if($out) $out.addEventListener('scroll', function(){\n var atBottom = ($out.scrollHeight - $out.scrollTop - $out.clientHeight) < 8;\n $out.__autoscroll = atBottom;\n var ab = $('pk-shell-autoscroll'); if(ab) ab.classList.toggle('primary', atBottom);\n });\n }\n function _setShellStatus(robotId, text, kind){\n var el = $('pk-shell-stateline'); if(!el) return;\n el.textContent = text;\n el.style.color = kind === 'error' ? 'var(--bad)' : kind === 'ok' ? 'var(--ok)' : 'var(--muted)';\n }\n // POST /api/robots/{id}/shell/tail and long-poll the result.\n function _shellTail(robotId, service, lines){\n _setShellStatus(robotId, 'tailing '+service+' (waiting for robot\u2026)');\n _shellAppend(robotId, 'status', '\u2014\u2014 tail '+service+'.log, last '+lines+' lines \u2014\u2014');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/tail',{service:service, params:{lines:lines}})\n .then(function(q){\n return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, service, 'tail');\n })\n .catch(function(e){ _setShellStatus(robotId, 'tail failed: '+e.message, 'error'); _shellAppend(robotId, 'status', '\u2717 tail failed: '+e.message); });\n }\n function _shellExec(robotId, name, params){\n _setShellStatus(robotId, 'running '+name+' (waiting for robot\u2026)');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/exec',{params:{name:name, params:params}})\n .then(function(q){\n return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, '', name);\n })\n .catch(function(e){ _setShellStatus(robotId, 'exec failed: '+e.message, 'error'); _shellAppend(robotId, 'status', '\u2717 exec failed: '+e.message); });\n }\n // Long-poll the result endpoint. The scheduler blocks up to 8s\n // before returning timed_out: true; we just respect that and\n // give the operator a clear \"still waiting\" line in the output.\n function _shellWaitForResult(robotId, requestId, deviceId, service, label){\n var url = '/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/wait/'+encodeURIComponent(requestId)+'?timeout=8';\n var pollOnce = function(){\n return getJson(url).then(function(r){\n if(r && r.completed){\n var result = r.result || {};\n _renderShellResult(robotId, result, label);\n return result;\n }\n // Not done yet \u2014 fall through to manual polling\n throw new Error('not_ready');\n });\n };\n return pollOnce().catch(function(){\n // Manual poll loop (covers the case where /wait timed out but\n // the robot is still chewing). Cap at 12s of fallback polling.\n var deadline = Date.now() + 12000;\n return new Promise(function(resolve,reject){\n var tick = function(){\n if(Date.now() > deadline){\n _setShellStatus(robotId, '\u26A0 '+label+' did not complete in 20s \u2014 robot may be offline', 'error');\n _shellAppend(robotId, 'status', '\u2717 '+label+' did not complete in 20s');\n reject(new Error(label+' did not complete in 20s'));\n return;\n }\n getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/result/'+encodeURIComponent(requestId))\n .then(function(r){\n if(r && r.completed){var result=r.result||{};_renderShellResult(robotId,result,label);resolve(result);return;}\n setTimeout(tick,500);\n })\n .catch(function(){setTimeout(tick,800);});\n };\n tick();\n });\n });\n }\n function _renderShellResult(robotId, result, label){\n if(!result || !result.ok){\n var err = (result && result.error) || '(no response)';\n _setShellStatus(robotId, '\u2717 '+label+' failed: '+err, 'error');\n _shellAppend(robotId, 'stderr', '\u2717 '+err);\n _renderShellBuffer(robotId);\n return;\n }\n var exit = (result.exit_code === 0) ? '\u2713 exit 0' : ('exit '+result.exit_code);\n _setShellStatus(robotId, exit, result.exit_code === 0 ? 'ok' : 'error');\n if(result.stdout){ _shellAppend(robotId, 'stdout', result.stdout); }\n if(result.stderr){ _shellAppend(robotId, 'stderr', result.stderr); }\n if(result.truncated){ _shellAppend(robotId, 'status', '\u26A0 output truncated to '+SHELL_OUTPUT_LIMIT+' bytes'); }\n if(result.exit_code !== 0){ _shellAppend(robotId, 'status', '\u2014\u2014 '+label+' finished with exit '+result.exit_code+' \u2014\u2014'); }\n else { _shellAppend(robotId, 'status', '\u2014\u2014 '+label+' finished \u2014\u2014'); }\n _renderShellBuffer(robotId);\n }\n // 64KB cap mirrors the robot's SHELL_OUTPUT_MAX_BYTES\n var SHELL_OUTPUT_LIMIT = 64 * 1024;\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 &amp; 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\" 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 var devForPm=schedulerDevice(n)||{};\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 +'<img id=\"pk-vision-img\" style=\"width:100%;height:100%;object-fit:cover;position:absolute;inset:0;display:none;border-radius:inherit\" alt=\"vision feed\"/>'\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 live feed\u2026</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-golive\" data-robot=\"'+esc(n.name)+'\">\u21BB Reconnect camera</button><button class=\"rp-btn primary\" id=\"pk-camera-fullscreen\">Fullscreen operations view</button><span class=\"pk-note\" id=\"pk-golivenote\" style=\"margin:0\">always-on RoboVision relay</span></div>'\n +'<label class=\"pk-prodtoggle\"><input type=\"checkbox\" id=\"pk-prodmode\"'+(devForPm.production_mode?' checked':'')+'><span>Production mode \u2014 continuous motion-triggered loop, camera always live</span></label>')\n + sect('Remote production test','<div id=\"pk-live-stage\" class=\"pk-live-stage-focus waiting\"><span class=\"eyebrow\">Current production stage</span><b>Checking robot state...</b><span>Waiting for scheduler telemetry</span></div><div id=\"pk-pipeline-summary\" class=\"pk-pipeline-summary\">Checking robot, camera, scheduler and LiveKit...</div>'\n + '<div class=\"pk-ctl\" style=\"margin-top:0.55rem\"><button class=\"rp-btn primary\" id=\"pk-unattended-test\">Run unattended readiness</button><button class=\"rp-btn\" id=\"pk-pipeline-start\">Start full test</button><button class=\"rp-btn danger\" id=\"pk-pipeline-stop\">Stop conversation</button></div>'\n + '<div class=\"pk-note\" id=\"pk-unattended-result\">Unattended readiness runs an acoustic speaker/mic round-trip, triggers the production motion path, and requires greeting playback on the robot.</div>'\n + '<div class=\"pk-pipeline\" id=\"pk-pipeline\"></div>')\n + sect('Production run history','<div class=\"pk-pipe-history\" style=\"margin-top:0;padding-top:0;border-top:0\"><div class=\"pk-pipe-history-head\"><b>Persistent sessions</b><div class=\"pk-history-pager\"><button class=\"rp-btn\" id=\"pk-history-prev\">Prev</button><span id=\"pk-history-page\">page 1</span><button class=\"rp-btn\" id=\"pk-history-next\">Next</button></div></div><div class=\"pk-pipe-history-list\" id=\"pk-pipeline-history\"><div class=\"pk-note\">Loading persistent sessions...</div></div></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><div class=\"pk-ctl\" style=\"margin-top:.5rem\"><button class=\"rp-btn\" id=\"pk-mic-listen\">Listen in browser</button><label class=\"pk-note\" style=\"display:flex;align-items:center;gap:.35rem;margin:0\">volume <input id=\"pk-mic-volume\" type=\"range\" min=\"0\" max=\"100\" value=\"80\" style=\"width:90px\"></label></div><div class=\"pk-note\" id=\"pk-mic-listen-note\">Muted by default. Use headphones to prevent acoustic feedback.</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\">TTS plays on the robot speaker \u2014 use \u201CTest speaker\u201D below to round-trip a tone and confirm the audio path</div>'\n + '<div class=\"rp-form\" id=\"pk-spk-test\" style=\"grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);\">'\n + '<div class=\"rp-field\"><label>Test type</label><select id=\"pk-spk-mode\"><option value=\"tts\" selected>Cached character voice</option><option value=\"tone\">Tone + mic round-trip</option></select></div>'\n + '<div class=\"rp-field\"><label>Selected output</label><div class=\"pk-note\" id=\"pk-spk-selected\" style=\"margin:.45rem 0 0\">loading device...</div></div>'\n + '<div class=\"rp-field\" style=\"grid-column:1/-1\"><label>Voice test phrase</label><input id=\"pk-spk-phrase\" value=\"Hello, this is a RoboPark speaker test.\" maxlength=\"240\"></div>'\n + '<div class=\"rp-field\"><label>Frequency</label><select id=\"pk-spk-freq\"><option value=\"500\">500 Hz (low)</option><option value=\"1000\" selected>1000 Hz (1 kHz reference)</option><option value=\"2000\">2000 Hz (presence)</option><option value=\"3000\">3000 Hz (clarity)</option></select></div>'\n + '<div class=\"rp-field\"><label>Duration</label><select id=\"pk-spk-dur\"><option value=\"0.4\">0.4 s</option><option value=\"0.6\" selected>0.6 s</option><option value=\"1.0\">1.0 s</option></select></div>'\n + '<div style=\"grid-column:1/-1;display:flex;gap:0.4rem;align-items:center;\">'\n + '<button class=\"rp-btn primary\" id=\"pk-spk-runtest\" data-robot=\"'+esc(schedId(n))+'\">Play cached voice</button>'\n + '<button class=\"rp-btn\" id=\"pk-spk-tone\">Quick tone</button>'\n + '<span class=\"pk-note\" id=\"pk-spk-test-stateline\" style=\"margin:0\">awaiting test</span>'\n + '</div>'\n + '<div style=\"grid-column:1/-1;\" id=\"pk-spk-test-result\"></div>'\n + '<div style=\"grid-column:1/-1;\" id=\"pk-spk-test-history\"></div>'\n + '</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('\uD83E\uDE7A robot services','<div id=\"pk-svc-list\" class=\"pk-svc-list\"><div class=\"pk-note\">waiting for supervisor report\u2026</div></div><div class=\"pk-note\" id=\"pk-svc-age\" style=\"margin-top:0.35rem;\"></div><div class=\"rp-field\" style=\"margin-top:0.55rem;\"><label>Quick run (on the robot, from packages/robopark/scheduler)</label><input id=\"pk-svc-clicmd\" readonly value=\"python robot_supervisor.py\"></div><button class=\"rp-btn\" id=\"pk-svc-clicmd-copy\" style=\"margin-top:0.35rem;\">\uD83D\uDCCB Copy command</button>')\n + buildShellSection(schedId(n))\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><div class=\"rp-config-actions\" style=\"margin-top:.45rem\"><button class=\"rp-btn primary\" id=\"pk-eff-save\" data-robot=\"'+esc(schedId(n))+'\">Apply to robot</button><button class=\"rp-btn\" id=\"pk-edit-character\">Edit character</button><button class=\"rp-btn\" id=\"pk-edit-stack\">Edit stack</button><button class=\"rp-btn\" id=\"pk-new-stack\">New stack</button></div>');\n b.innerHTML += sect('Microphone setup suite','<div class=\"pk-mic-suite\"><div class=\"pk-mic-health\" id=\"pk-mic-health\"><div class=\"pk-mic-check\"><div class=\"k\">Hardware lock</div><div class=\"v\">USB mic hw:3,0</div></div><div class=\"pk-mic-check\"><div class=\"k\">PCM capture</div><div class=\"v\">not tested</div></div><div class=\"pk-mic-check\"><div class=\"k\">Production track</div><div class=\"v\">waiting</div></div></div><div class=\"rp-form\" style=\"grid-template-columns:1fr 1fr;gap:.4rem\"><div class=\"rp-field\"><label>Ground-truth capture</label><select id=\"pk-mic-duration\"><option value=\"2\">2 seconds</option><option value=\"4\" selected>4 seconds</option><option value=\"8\">8 seconds</option></select></div><div class=\"rp-field\"><label>Locked input</label><div class=\"pk-note\" style=\"margin:.45rem 0 0\">USB microphone (plughw:3,0)</div></div></div><div class=\"pk-ctl\"><button class=\"rp-btn primary\" id=\"pk-mic-groundtruth\">Run raw mic capture</button><button class=\"rp-btn\" id=\"pk-mic-roundtrip\">Run speaker + mic test</button><button class=\"rp-btn\" id=\"pk-mic-apply\">Reapply production profile</button></div><div class=\"pk-note\" id=\"pk-mic-test-state\">Raw capture bypasses LiveKit and tests the exact ALSA path. Round-trip then verifies the physical speaker-to-mic path.</div><div id=\"pk-mic-test-result\"></div></div>');\n b.innerHTML += sect('Motor registry + sequence canvas','<div class=\"rp-field\"><label>Robot-local motor server</label><input id=\"pk-motor-url\" value=\"http://127.0.0.1:8001\" placeholder=\"http://127.0.0.1:8001\"></div><div class=\"pk-motor-registry\" id=\"pk-motor-registry\"></div><button class=\"rp-btn\" id=\"pk-motor-add\" style=\"margin-top:.4rem\">Add registered motor</button><div class=\"rp-form\" style=\"grid-template-columns:1fr 1fr;margin-top:.7rem\"><div class=\"rp-field\"><label>Saved sequence</label><select id=\"pk-motor-sequence\"></select></div><div class=\"rp-field\"><label>Sequence name</label><input id=\"pk-motor-sequence-name\" placeholder=\"Greeting motion\"></div></div><div class=\"pk-motor-canvas\" id=\"pk-motor-canvas\"></div><div class=\"pk-ctl\"><button class=\"rp-btn\" id=\"pk-motor-step-add\">Add timed node</button><button class=\"rp-btn primary\" id=\"pk-motor-save\">Save motor profile</button><button class=\"rp-btn\" id=\"pk-motor-run\">Run selected sequence</button><button class=\"rp-btn danger\" id=\"pk-motor-test-all\">Test all registered GPIO</button></div><label class=\"pk-prodtoggle\"><input type=\"checkbox\" id=\"pk-motor-greeting\"><span>Run this sequence automatically with the greeting pipeline</span></label><div class=\"pk-note\">Full GPIO test pulses registered relays only: 300 ms on, 200 ms pause, one at a time.</div><div class=\"pk-motor-status\" id=\"pk-motor-status\">Loading motor profile...</div>');\n b.innerHTML += sect('Production camera + audio','<div class=\"pk-note\" id=\"pk-device-search\" style=\"margin-bottom:.55rem\">Hardware locked for launch. Camera, microphone, and speaker cannot drift between restarts.</div><div class=\"rp-field\"><label>Camera input</label><select id=\"pk-video-device\" disabled></select></div><div class=\"rp-field\"><label>Microphone input</label><select id=\"pk-audio-input\" disabled></select></div><div class=\"rp-field\"><label>Speaker output</label><select id=\"pk-audio-output\" disabled></select></div><button class=\"rp-btn primary\" id=\"pk-device-test-speaker\" style=\"margin-top:0.35rem\">Test USB speaker now</button><div class=\"rp-field\"><label>Preset greeting phrases <span class=\"pk-note\">one per line, cached per voice</span></label><textarea id=\"pk-greeting-phrases\" rows=\"3\" placeholder=\"Hi, I am Jaguar! / Welcome to RoboPark.\"></textarea></div><button class=\"rp-btn\" id=\"pk-device-save\" style=\"margin-top:0.35rem\">Save greetings + enforce hardware</button><div class=\"pk-note\" id=\"pk-device-note\">waiting for robot heartbeat inventory</div>');\n // Put live hardware selection beside the camera and audio tests,\n // ahead of persona and service administration in the Park drawer.\n var deviceInput=$('pk-device-search'), deviceSection=deviceInput&&deviceInput.closest('.pk-sect');\n var speakerTest=$('pk-spk-test'), speakerSection=speakerTest&&speakerTest.closest('.pk-sect');\n if(deviceSection && speakerSection) speakerSection.after(deviceSection);\n refreshDeviceSelectors(n);\n b.innerHTML += '<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn danger\" id=\"pk-recover\" data-robot=\"'+esc(n.name)+'\">Recover robot</button><span class=\"pk-note\" style=\"margin:0\">clears stale sessions and queued triggers for this robot</span></div>';\n var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });\n ['pk-video-device','pk-audio-input','pk-audio-output'].forEach(function(field){\n var select=$(field);if(!select)return;\n select.addEventListener('change',function(){\n select.dataset.dirty='true';\n var note=$('pk-device-note');if(note)note.textContent='unsaved selection \u00B7 click Save device selection + greetings';\n if(field==='pk-audio-output'){\n var selectedLabel=$('pk-spk-selected'),option=select.options&&select.selectedIndex>=0?select.options[select.selectedIndex]:null;\n if(selectedLabel)selectedLabel.textContent=option?String(option.textContent||option.value):String(select.value||'default');\n }\n });\n });\n var micListen=$('pk-mic-listen');if(micListen)micListen.addEventListener('click',function(){setMicMonitorAudible(!micMonitorAudible);});\n var micVolume=$('pk-mic-volume');if(micVolume)micVolume.addEventListener('input',function(){micMonitorVolume=Math.max(0,Math.min(1,Number(micVolume.value||80)/100));if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;});\n var micGround=$('pk-mic-groundtruth');if(micGround)micGround.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicGroundtruth(n);});\n var micRound=$('pk-mic-roundtrip');if(micRound)micRound.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicRoundtrip(n);});\n var micApply=$('pk-mic-apply');if(micApply)micApply.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveDeviceSelectors(n);});\n var motorAdd=$('pk-motor-add');if(motorAdd)motorAdd.addEventListener('click',function(){motorEditorAddMotor();});\n var motorStep=$('pk-motor-step-add');if(motorStep)motorStep.addEventListener('click',function(){motorEditorAddStep();});\n var motorSave=$('pk-motor-save');if(motorSave)motorSave.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveMotorProfile(n);});\n var motorRun=$('pk-motor-run');if(motorRun)motorRun.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMotorSequence(n);});\n var motorTestAll=$('pk-motor-test-all');if(motorTestAll)motorTestAll.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}testAllMotorRelays(n);});\n var motorSeq=$('pk-motor-sequence');if(motorSeq)motorSeq.addEventListener('change',function(){selectMotorSequence(motorSeq.value);});\n var motorRegistry=$('pk-motor-registry');if(motorRegistry)motorRegistry.addEventListener('click',function(ev){var button=ev.target.closest('[data-motor-action]');if(!button)return;var index=Number(button.dataset.index);if(button.dataset.motorAction==='remove'){motorProfile.registry.splice(index,1);renderMotorEditor();}else if(button.dataset.motorAction==='test'){testMotorRelay(n,index);}});\n var motorCanvas=$('pk-motor-canvas');if(motorCanvas)motorCanvas.addEventListener('click',function(ev){var button=ev.target.closest('[data-step-remove]');if(button){captureMotorEditor();motorEditorSequence().steps.splice(Number(button.dataset.stepRemove),1);renderMotorEditor();}});\n loadMotorProfile(n);\n startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);\n _bindShellSection(n);\n _spkTestRenderHistory(schedId(n));\n var runSpkTest = $('pk-spk-runtest'); if(runSpkTest) runSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n); });\n var runSpkTone = $('pk-spk-tone'); if(runSpkTone) runSpkTone.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n,'tone'); });\n var quickSpkTest = $('pk-device-test-speaker'); if(quickSpkTest) quickSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n,'tts'); });\n var spkMode = $('pk-spk-mode'); if(spkMode) spkMode.addEventListener('change', function(){ if(runSpkTest) runSpkTest.textContent=spkMode.value==='tts'?'Play cached voice':'Run tone test'; });\n var gl=$('pk-golive');\n if(gl){\n gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });\n }\n var fullCam=$('pk-camera-fullscreen'); if(fullCam) fullCam.addEventListener('click',function(){ openCameraOperations(n); });\n var editCharacter=$('pk-edit-character'); if(editCharacter) editCharacter.addEventListener('click',function(){var id=$('pk-eff-char').value;if(id)openCharacterManager(id);else showOut('Choose a character to edit');});\n var editStack=$('pk-edit-stack'); if(editStack) editStack.addEventListener('click',function(){var id=$('pk-eff-voice').value;if(id)openVoiceStackManager(id);else showOut('Choose a voice stack to edit');});\n var newStack=$('pk-new-stack'); if(newStack) newStack.addEventListener('click',function(){openVoiceStackManager();});\n var pm=$('pk-prodmode'); if(pm) pm.addEventListener('change', function(){ if(rpReadOnly){ pm.checked=!pm.checked; showOut('read-only node'); return; } setProductionMode(n, pm.checked); });\n var svcCopy=$('pk-svc-clicmd-copy'); if(svcCopy) svcCopy.addEventListener('click', function(){ var i=$('pk-svc-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied \u2014 run it on the robot itself'); });\n var svcList=$('pk-svc-list'); if(svcList) svcList.addEventListener('click', function(ev){\n var btn=ev.target.closest('.pk-svc-restart-btn'); if(!btn) return;\n if(rpReadOnly){ showOut('read-only node'); return; }\n restartService(n, btn.dataset.svc, btn);\n });\n var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });\n var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });\n var pipelineStart=$('pk-pipeline-start'); if(pipelineStart) pipelineStart.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } startPipelineTest(n); });\n var unattended=$('pk-unattended-test'); if(unattended) unattended.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runUnattendedReadiness(n); });\n var pipelineStop=$('pk-pipeline-stop'); if(pipelineStop) pipelineStop.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } stopPipelineTest(n); });\n var historyPrev=$('pk-history-prev');if(historyPrev)historyPrev.addEventListener('click',function(){pipelineHistoryPage=Math.max(1,pipelineHistoryPage-1);loadPipelineHistory(n);});\n var historyNext=$('pk-history-next');if(historyNext)historyNext.addEventListener('click',function(){pipelineHistoryPage+=1;loadPipelineHistory(n);});\n var historyList=$('pk-pipeline-history');if(historyList)historyList.addEventListener('click',function(ev){var card=ev.target.closest('.pk-run-card');if(card)openSessionDetailModal({session_id:card.dataset.session});});\n pipelineHistoryPage=1;\n loadPipelineHistory(n);\n pollPipeline(n);\n if(pipelineTimer) clearInterval(pipelineTimer);\n pipelineTimer=setInterval(function(){ if(drawerId===n.id) pollPipeline(n); }, 1500);\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\n var motorProfile={registry:[],sequences:[],greeting_sequence_id:null,motor_server_url:'http://127.0.0.1:8001'};\n var motorSequenceId='';\n function motorEditorSequence(){\n var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});\n if(!sequence){sequence={id:motorSequenceId||('sequence-'+Date.now()),name:'New sequence',steps:[]};motorSequenceId=sequence.id;motorProfile.sequences.push(sequence);}\n if(!Array.isArray(sequence.steps))sequence.steps=[];\n return sequence;\n }\n function captureMotorEditor(){\n var rows=document.querySelectorAll('#pk-motor-registry .pk-motor-row');\n if(rows.length)motorProfile.registry=Array.prototype.map.call(rows,function(row,index){\n var fields=row.querySelectorAll('input');\n return {id:slugify(fields[0].value)||('motor-'+(index+1)),name:fields[1].value.trim()||('Motor '+(index+1)),gpio:Number(fields[2].value),active_high:fields[3].checked,max_duration_ms:Number(fields[4].value)||3000};\n });\n var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});\n if(sequence){\n var name=$('pk-motor-sequence-name');if(name)sequence.name=name.value.trim()||sequence.name||'Sequence';\n sequence.steps=Array.prototype.map.call(document.querySelectorAll('#pk-motor-canvas .pk-motor-node'),function(node){\n return {motor_id:node.querySelector('[data-step-motor]').value,delay_ms:Number(node.querySelector('[data-step-delay]').value)||0,duration_ms:Number(node.querySelector('[data-step-duration]').value)||300};\n });\n var greeting=$('pk-motor-greeting');if(greeting)motorProfile.greeting_sequence_id=greeting.checked?sequence.id:(motorProfile.greeting_sequence_id===sequence.id?null:motorProfile.greeting_sequence_id);\n }\n var url=$('pk-motor-url');if(url)motorProfile.motor_server_url=url.value.trim()||'http://127.0.0.1:8001';\n }\n function renderMotorEditor(){\n var registry=$('pk-motor-registry'),canvas=$('pk-motor-canvas'),select=$('pk-motor-sequence');if(!registry||!canvas||!select)return;\n registry.innerHTML=(motorProfile.registry||[]).map(function(motor,index){return '<div class=\"pk-motor-row\"><input value=\"'+esc(motor.id)+'\" aria-label=\"Motor id\"><input value=\"'+esc(motor.name)+'\" aria-label=\"Motor name\"><input type=\"number\" min=\"2\" max=\"27\" value=\"'+esc(motor.gpio)+'\" aria-label=\"BCM GPIO\"><label title=\"Active high\"><input type=\"checkbox\" '+(motor.active_high!==false?'checked':'')+'> high</label><input type=\"number\" min=\"50\" max=\"10000\" step=\"50\" value=\"'+esc(motor.max_duration_ms||3000)+'\" aria-label=\"Max pulse milliseconds\"><button class=\"rp-btn\" data-motor-action=\"test\" data-index=\"'+index+'\">Test</button><button class=\"rp-btn danger\" data-motor-action=\"remove\" data-index=\"'+index+'\">Remove</button></div>';}).join('')||'<div class=\"pk-note\">No relays registered. Add only wired BCM outputs.</div>';\n select.innerHTML=(motorProfile.sequences||[]).map(function(sequence){return '<option value=\"'+esc(sequence.id)+'\" '+(sequence.id===motorSequenceId?'selected':'')+'>'+esc(sequence.name||sequence.id)+'</option>';}).join('')+'<option value=\"__new__\">+ New sequence</option>';\n var sequence=motorEditorSequence(),name=$('pk-motor-sequence-name'),greeting=$('pk-motor-greeting'),url=$('pk-motor-url');\n if(name)name.value=sequence.name||'';if(greeting)greeting.checked=motorProfile.greeting_sequence_id===sequence.id;if(url)url.value=motorProfile.motor_server_url||'http://127.0.0.1:8001';\n var motorOptions=(motorProfile.registry||[]).map(function(motor){return '<option value=\"'+esc(motor.id)+'\">'+esc(motor.name+' | BCM '+motor.gpio)+'</option>';}).join('');\n canvas.innerHTML=(sequence.steps||[]).map(function(step,index){return '<div class=\"pk-motor-node\"><div class=\"pk-motor-node-num\">'+(index+1)+'</div><label>Relay<select data-step-motor>'+motorOptions.replace('value=\"'+esc(step.motor_id)+'\"','value=\"'+esc(step.motor_id)+'\" selected')+'</select></label><label>Delay ms<input data-step-delay type=\"number\" min=\"0\" max=\"30000\" step=\"50\" value=\"'+esc(step.delay_ms||0)+'\"></label><label>Pulse ms<input data-step-duration type=\"number\" min=\"50\" max=\"10000\" step=\"50\" value=\"'+esc(step.duration_ms||300)+'\"></label><button class=\"rp-btn danger\" data-step-remove=\"'+index+'\">Remove</button></div>';}).join('')||'<div class=\"pk-note\">No timed nodes. Add a node to build the sequence.</div>';\n }\n function selectMotorSequence(id){\n captureMotorEditor();\n if(id==='__new__'){var sequence={id:'sequence-'+Date.now(),name:'New sequence',steps:[]};motorProfile.sequences.push(sequence);motorSequenceId=sequence.id;}else motorSequenceId=id;\n renderMotorEditor();\n }\n function motorEditorAddMotor(){captureMotorEditor();var number=motorProfile.registry.length+1;motorProfile.registry.push({id:'motor-'+number,name:'Motor '+number,gpio:Math.min(27,Math.max(2,number+4)),active_high:true,max_duration_ms:3000});renderMotorEditor();}\n function motorEditorAddStep(){captureMotorEditor();if(!motorProfile.registry.length){motorStatus('Register a motor before adding a timed node','bad');return;}motorEditorSequence().steps.push({motor_id:motorProfile.registry[0].id,delay_ms:0,duration_ms:300});renderMotorEditor();}\n function motorStatus(message,kind){var host=$('pk-motor-status');if(host){host.textContent=message;host.className='pk-motor-status '+(kind||'');}}\n function loadMotorProfile(n){\n var robotId=schedId(n);motorStatus('Loading saved motor profile...');\n getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile').then(function(data){motorProfile={registry:data.registry||[],sequences:data.sequences||[],greeting_sequence_id:data.greeting_sequence_id||null,motor_server_url:(schedulerDevice(n)||{}).motor_server_url||'http://127.0.0.1:8001'};if(!motorProfile.sequences.length)motorProfile.sequences=[{id:'greeting-motion',name:'Greeting motion',steps:[]}];motorSequenceId=motorProfile.greeting_sequence_id||motorProfile.sequences[0].id;renderMotorEditor();motorStatus('Saved profile loaded | '+motorProfile.registry.length+' relay(s), '+motorProfile.sequences.length+' sequence(s)','ok');}).catch(function(error){motorStatus('Motor profile unavailable: '+error.message,'bad');});\n }\n function saveMotorProfile(n){\n captureMotorEditor();var robotId=schedId(n),url=motorProfile.motor_server_url;\n var parsedUrl=null;try{parsedUrl=new URL(url);}catch(e){}\n if(!parsedUrl||parsedUrl.protocol!=='http:'||(parsedUrl.hostname!=='127.0.0.1'&&parsedUrl.hostname!=='localhost')||!parsedUrl.port){motorStatus('Motor server must be robot-local: http://127.0.0.1:PORT','bad');return Promise.reject(new Error('motor server must be localhost'));}\n motorStatus('Validating and saving motor profile...');\n return rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(robotId),{motor_server_url:url}).then(function(){return rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile',{registry:motorProfile.registry,sequences:motorProfile.sequences,greeting_sequence_id:motorProfile.greeting_sequence_id});}).then(function(data){motorProfile.registry=data.registry||[];motorProfile.sequences=data.sequences||[];motorProfile.greeting_sequence_id=data.greeting_sequence_id||null;renderMotorEditor();motorStatus('Profile saved. Greeting motion will start on first TTS playback.','ok');return data;}).catch(function(error){motorStatus('Save failed: '+error.message,'bad');throw error;});\n }\n function runMotorSequence(n){\n var robotId=schedId(n),sequence=motorEditorSequence();motorStatus('Saving and queueing '+sequence.name+'...');\n saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-sequences/'+encodeURIComponent(sequence.id)+'/run',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-sequence');}).then(function(result){motorStatus(result.ok?'Sequence completed on robot.':'Sequence failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Run failed: '+error.message,'bad');});\n }\n function testMotorRelay(n,index){\n captureMotorEditor();var motor=motorProfile.registry[index];if(!motor)return;var robotId=schedId(n);motorStatus('Saving profile before safe 300 ms '+motor.name+' test...');\n saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/'+encodeURIComponent(motor.id)+'/test?duration_ms=300',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-test');}).then(function(result){motorStatus(result.ok?motor.name+' relay test completed.':'Relay test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Relay test failed: '+error.message,'bad');});\n }\n function testAllMotorRelays(n){\n captureMotorEditor();if(!motorProfile.registry.length){motorStatus('Register at least one relay before running the GPIO test.','bad');return;}\n if(!window.confirm('Pulse all '+motorProfile.registry.length+' registered relays one at a time? Ensure the motor area is clear.'))return;\n var robotId=schedId(n);motorStatus('Saving profile and testing '+motorProfile.registry.length+' registered GPIO relay(s), one at a time...');\n saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/test-all?duration_ms=300&pause_ms=200',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','gpio-test-all');}).then(function(result){motorStatus(result.ok?'Full registered GPIO test completed: '+(result.completed_steps||[]).length+' relay(s).':'GPIO test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('GPIO test failed: '+error.message,'bad');});\n }\n\n var pipelineHistoryPage=1;\n var PIPELINE_LABELS={\n robot_online:'Robot online',camera_ready:'Camera detected',microphone_ready:'Microphone detected',speaker_ready:'Speaker detected',\n motion_detected:'Motion received',scheduler_session:'Scheduler session',livekit_join:'LiveKit joined',camera_published:'Camera published',\n microphone_published:'Microphone published',voice_worker:'Voice worker',stt_listening:'STT listening',llm_response:'LLM response',\n tts_subscribed:'TTS subscribed',playback_started:'Robot playback',motor_sequence:'Greeting motion',session_ended:'Session ended'\n };\n var PIPELINE_PHASES={robot_online:'hardware',camera_ready:'hardware',microphone_ready:'hardware',speaker_ready:'hardware',motion_detected:'trigger',scheduler_session:'session',livekit_join:'session',camera_published:'media',microphone_published:'media',voice_worker:'intelligence',stt_listening:'intelligence',llm_response:'intelligence',tts_subscribed:'playback',playback_started:'playback',motor_sequence:'playback',session_ended:'close'};\n function pipelineCurrentStage(stages,latest,running){\n for(var i=0;i<stages.length;i++){var bad=latest[stages[i]];if(bad&&(bad.status==='failed'||bad.status==='blocked'))return stages[i];}\n if(!running)return null;\n var lastOk=-1;\n for(var j=0;j<stages.length;j++){var ev=latest[stages[j]];if(ev&&(ev.status==='ok'||ev.status==='skipped'))lastOk=j;if(ev&&ev.status==='running')return stages[j];}\n return stages[Math.min(stages.length-1,lastOk+1)];\n }\n function renderPipeline(data){\n var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'),focus=$('pk-live-stage'); if(!host||!summary) return;\n var latest={}, prereqs=data.prerequisites||{}, session=data.latest_session||{}, sid=session.id||null;\n (data.events||[]).forEach(function(ev){\n if(ev.session_id && sid && ev.session_id!==sid) return;\n latest[ev.stage]=ev;\n });\n ['robot_online','camera_ready','microphone_ready','speaker_ready'].forEach(function(stage){\n if(!latest[stage]) latest[stage]={stage:stage,status:prereqs[stage]?'ok':'blocked',message:prereqs[stage]?'Ready':'Not reported'};\n });\n var running=data.overall==='running',stages=data.stages||Object.keys(PIPELINE_LABELS),authoritative=data.current_stage||{},current=authoritative.stage||pipelineCurrentStage(stages,latest,running);\n host.innerHTML=stages.map(function(stage,index){\n var ev=latest[stage],isCurrent=stage===current,status=(isCurrent&&authoritative.status)||ev&&ev.status||(isCurrent?'running':'pending');\n var detail=isCurrent&&(authoritative.message||authoritative.label)||ev&&(ev.message||ev.timestamp)||'waiting';\n var time=ev&&ev.timestamp?new Date(ev.timestamp+'Z').toLocaleString():'';\n var phase=PIPELINE_PHASES[stage]||'session';\n return '<div class=\"pk-pipe-step phase-'+phase+' '+esc(status)+(isCurrent?' current':'')+'\"'+(isCurrent?' aria-current=\"step\"':'')+'><span class=\"step-index\">'+String(index+1).padStart(2,'0')+'</span><span><span class=\"stage\">'+esc(PIPELINE_LABELS[stage]||stage)+'<em class=\"step-state\">'+esc(isCurrent?'ACTIVE':status)+'</em></span><span class=\"phase\">'+esc(phase)+'</span></span><span class=\"detail\">'+esc(detail)+(time?'<span class=\"time\">'+esc(time)+'</span>':'')+'</span></div>';\n }).join('');\n var blockers=data.blockers||[];\n if(focus){var focusStatus=authoritative.status||blockers.length?'blocked':running?'active':'waiting';if(authoritative.status)focusStatus=authoritative.status;focus.className='pk-live-stage-focus '+esc(focusStatus);focus.innerHTML='<span class=\"eyebrow\">Current production stage</span><b>'+esc(authoritative.label||PIPELINE_LABELS[current]||current||'Standing by')+'</b><span>'+esc(authoritative.message||blockers[0]||'Robot is armed and waiting for motion')+'</span>';}\n summary.className='pk-pipeline-summary '+esc(data.overall||'blocked');\n summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?('ACTIVE: '+(PIPELINE_LABELS[current]||current||'starting')+' | session '+(sid||'allocating')):'READY: camera-to-speaker remote test can start');\n var micHealth=$('pk-mic-health');if(micHealth){\n var micEvent=latest.microphone_published,trackOk=micEvent&&micEvent.status==='ok',inventoryOk=!!prereqs.microphone_ready;\n micHealth.innerHTML='<div class=\"pk-mic-check ok\"><div class=\"k\">Hardware lock</div><div class=\"v\">USB mic hw:3,0</div></div><div class=\"pk-mic-check '+(inventoryOk?'ok':'bad')+'\"><div class=\"k\">Inventory</div><div class=\"v\">'+(inventoryOk?'detected':'missing')+'</div></div><div class=\"pk-mic-check '+(trackOk?'ok':micEvent?'bad':'')+'\"><div class=\"k\">Production track</div><div class=\"v\">'+esc(micEvent?(micEvent.message||micEvent.status):'waiting for session')+'</div></div>';\n }\n }\n function loadPipelineHistory(n){\n var host=$('pk-pipeline-history'),pageLabel=$('pk-history-page');if(!host)return;\n host.innerHTML='<div class=\"pk-note\">Loading session page...</div>';\n getJson('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-history?page='+pipelineHistoryPage+'&page_size=6'+(QS?'&'+QS.slice(1):''))\n .then(function(data){\n pipelineHistoryPage=data.page||1;if(pageLabel)pageLabel.textContent='page '+pipelineHistoryPage+' / '+(data.pages||1)+' | '+(data.total||0)+' runs';\n var prev=$('pk-history-prev'),next=$('pk-history-next');if(prev)prev.disabled=pipelineHistoryPage<=1;if(next)next.disabled=pipelineHistoryPage>=(data.pages||1);\n host.innerHTML=(data.items||[]).map(function(run){\n var events=run.events||[],latest={};events.forEach(function(ev){latest[ev.stage]=ev;});\n var hasError=Number(run.error_count||0)>0,state=!run.ended_at?'running':hasError?'bad':'ok',label=!run.ended_at?'active':hasError?'attention':(run.end_reason||'complete');\n var started=run.started_at?new Date(run.started_at+'Z'):null,duration=run.duration_seconds!=null?run.duration_seconds:(started?Math.max(0,Math.floor((Date.now()-started.getTime())/1000)):0);\n var stageBar=Object.keys(PIPELINE_LABELS).map(function(stage){var ev=latest[stage];return '<i class=\"'+esc(ev&&ev.status||'pending')+'\" title=\"'+esc(PIPELINE_LABELS[stage])+'\"></i>';}).join('');\n return '<div class=\"pk-run-card\" data-session=\"'+esc(run.session_id)+'\"><div class=\"pk-run-top\"><div><div class=\"pk-run-title\">'+esc(run.session_id)+'</div><div class=\"pk-run-meta\">'+esc(started?started.toLocaleString():'unknown time')+' | '+esc(rpFmtDuration(duration))+' | '+esc(run.transcript_count||0)+' transcript turns | '+esc(run.pipeline_event_count||0)+' events</div></div><span class=\"pk-run-state '+state+'\">'+esc(label)+'</span></div><div class=\"pk-run-stages\">'+stageBar+'</div></div>';\n }).join('')||'<div class=\"pk-note\">No production sessions recorded for this robot.</div>';\n }).catch(function(e){host.innerHTML='<div class=\"pk-note\" style=\"color:var(--bad)\">History unavailable: '+esc(e.message)+'</div>';});\n }\n function pollPipeline(n){\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-status'+QS,{cache:'no-store'})\n .then(function(r){ if(!r.ok) throw new Error('scheduler '+r.status); return r.json(); })\n .then(renderPipeline)\n .catch(function(e){ var s=$('pk-pipeline-summary'); if(s){ s.className='pk-pipeline-summary blocked'; s.textContent='SCHEDULER UNREACHABLE: '+e.message; } });\n }\n function queuePipelineTest(n){\n var start=$('pk-pipeline-start'); if(start){ start.disabled=true; start.textContent='Starting...'; }\n if(!previewOn) startPreview(n);\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/start',{}).then(function(result){\n if(!result.queued) throw new Error(result.reason||'trigger was not queued');\n showOut('full production test queued for '+(n.label||n.name));\n pollPipeline(n);\n // The stream endpoint is initially idle and becomes active only after\n // the robot consumes the trigger. Retry through that transition.\n [400,1200,2500,4500].forEach(function(delay){setTimeout(function(){if(drawerId)startMicMonitor(n);},delay);});\n }).catch(function(e){ showOut('production test: '+e.message); pollPipeline(n); }).finally(function(){ if(start){ start.disabled=false; start.textContent='Start full test'; } });\n }\n function startPipelineTest(n){\n var device=schedulerDevice(n)||{};\n if(!device.id){ showOut('Robot has no scheduler device identity'); return; }\n if(!device.production_mode){\n rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(device.id),{production_mode:true}).then(function(){\n device.production_mode=true; var pm=$('pk-prodmode'); if(pm) pm.checked=true; queuePipelineTest(n);\n }).catch(function(e){ showOut('Could not enable production mode: '+e.message); });\n return;\n }\n queuePipelineTest(n);\n }\n function stopPipelineTest(n){\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/stop',{}).then(function(){\n showOut('conversation stopped for '+(n.label||n.name)); pollPipeline(n); startCam(n);\n }).catch(function(e){ showOut('stop test: '+e.message); });\n }\n function runUnattendedReadiness(n){\n var button=$('pk-unattended-test'),resultEl=$('pk-unattended-result'),robotId=schedId(n),device=schedulerDevice(n)||{};\n if(!device.id){showOut('Robot has no scheduler device identity');return;}\n if(button){button.disabled=true;button.textContent='Testing hardware...';}\n function report(text,bad){if(resultEl){resultEl.textContent=text;resultEl.style.color=bad?'var(--bad)':'var(--warn)';}}\n function delay(ms){return new Promise(function(resolve){setTimeout(resolve,ms);});}\n function status(){return getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/pipeline-status'+QS);}\n function waitForGreeting(deadline){\n return status().then(function(data){\n renderPipeline(data);\n var sid=data.latest_session&&data.latest_session.id,seen={};\n (data.events||[]).forEach(function(ev){if(!ev.session_id||!sid||ev.session_id===sid)seen[ev.stage]=ev;});\n var required=['scheduler_session','livekit_join','camera_published','microphone_published','tts_subscribed','playback_started'];\n var missing=required.filter(function(stage){return !seen[stage]||seen[stage].status!=='ok';});\n if(!missing.length)return {data:data,required:required};\n if(Date.now()>deadline)throw new Error('production greeting timed out; missing '+missing.join(', '));\n report('Pipeline running \u00B7 waiting for '+missing.join(', '));\n return delay(1500).then(function(){return waitForGreeting(deadline);});\n });\n }\n var inv=device.device_inventory||{},media=inv.media_health||{};\n var cameraProblems=[];\n if(media.camera_access===false)cameraProblems.push('camera permission denied');\n if(media.camera_stalled===true)cameraProblems.push('camera worker stalled');\n if(media.camera_frame_age_seconds!=null&&Number(media.camera_frame_age_seconds)>15)cameraProblems.push('camera frame '+Math.round(Number(media.camera_frame_age_seconds))+'s old');\n if(cameraProblems.length){report('FAILED \u00B7 '+cameraProblems.join(' \u00B7 '),true);if(button){button.disabled=false;button.textContent='Run unattended readiness';}return;}\n report('Camera healthy \u00B7 running physical speaker/mic round-trip');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{\n mode:'tone',playback_only:false,frequency:1000,duration:0.8,amplitude:0.6,threshold_db:-30,\n output:'1',input:'2',output_name:'USB Audio Device: -',input_name:'Usb Audio Device: USB Audio'\n }).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','speaker-test');})\n .then(function(acoustic){\n if(!acoustic||!acoustic.ok||acoustic.pass!==true)throw new Error((acoustic&&acoustic.error)||(acoustic&&acoustic.diagnostic)||'speaker/mic acoustic round-trip failed');\n report('Acoustic path passed \u00B7 starting production motion and greeting');\n if(button)button.textContent='Testing production loop...';\n var enable=device.production_mode?Promise.resolve():rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(device.id),{production_mode:true}).then(function(){device.production_mode=true;});\n return enable.then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/pipeline-test/start',{});});\n })\n .then(function(queued){if(!queued||!queued.queued)throw new Error(queued&&queued.reason||'production trigger was not queued');return waitForGreeting(Date.now()+45000);})\n .then(function(done){\n report('PASSED \u00B7 camera fresh \u00B7 acoustic speaker/mic path confirmed \u00B7 motion \u2192 scheduler \u2192 LiveKit \u2192 mic/camera \u2192 TTS \u2192 robot playback confirmed');\n if(resultEl)resultEl.style.color='var(--ok)';\n showOut('unattended production readiness passed for '+(n.label||n.name));\n })\n .catch(function(e){report('FAILED \u00B7 '+e.message,true);showOut('unattended readiness: '+e.message);})\n .then(function(){if(button){button.disabled=false;button.textContent='Run unattended readiness';}});\n }\n function refreshDrawer(){\n var n=drawerId?byId[drawerId]:null; if(!n) return;\n if(n.kind==='robot' && !deviceSelectorsReady && $('pk-video-device')){\n var devCheck=schedulerDevice(n);\n if(devCheck && hasRealInventory(devCheck.device_inventory)) refreshDeviceSelectors(n);\n }\n if(n.kind==='robot' && $('pk-cam')){\n var camDev=schedulerDevice(n);\n // Self-heal: a production-mode robot's feed should never sit idle for\n // long \u2014 if it's not currently live (no session, no preview running),\n // re-arm it. Also keep retrying the opencv vision overlay in case the\n // robot's lan_ip only became known after this panel was first drawn.\n if(camDev && camDev.production_mode && !previewOn && !lkRoom) startCam(n);\n if(!visionOn && lkRoom) tryVisionOverlay(n);\n }\n if(n.kind==='robot' && $('pk-svc-list')) refreshSupervisorPanel(n);\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 var ready=$('pk-readiness'); if(ready && row){ ready.textContent=(row.readiness_code||'unknown').replace(/_/g,' ')+' / '+(row.readiness_message||'no readiness detail'); ready.style.color=(row.readiness_code==='ready'||row.readiness_code==='in_session')?'var(--ok)':'var(--bad)'; }\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||row.current_session_id||row.status==='connecting'||row.status==='running';\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 var cameraOpsNode=null,cameraOpsTimer=null,cameraOpsRetryTimer=null;\n function cameraOpsFeedUrl(n){\n var ref=n&&(n.displayName||n.name||n.nodeId||n.id); if(!ref)return '';\n return '/fed/media/robots/'+encodeURIComponent(ref)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();\n }\n function refreshCameraOperations(){\n var n=cameraOpsNode;if(!n)return;var row=schedRow(n)||{},device=schedulerDevice(n)||{},hw=hwByNode[n.nodeId]||{};\n var readiness=String(row.readiness_code||(!n.connected?'offline':'mesh_online')),active=!!(row.active_session||row.in_session||row.session_active||row.current_session_id||row.status==='running'||row.status==='connecting');\n var currentStage=row.current_stage||{},currentStageLabel=currentStage.label||row.current_stage_label||(active?'Conversation starting':device.production_mode?'Armed - waiting for motion':'Production paused'),currentStageStatus=currentStage.status||row.current_stage_status||(active?'active':'waiting');\n var img=$('pk-camera-ops-img'),cameraLive=!!(img&&img.dataset.live==='true'),clockEl=$('pk-camera-ops-clock');if(clockEl)clockEl.textContent=clock(Date.now());\n var rec=$('pk-camera-ops-rec');if(rec)rec.textContent=(cameraLive?'REC / LIVE':'REC / CONNECTING')+' / '+String(n.displayName||n.name||'robot').toUpperCase();\n var overlay=$('pk-camera-ops-overlay');if(overlay)overlay.innerHTML='<div class=\"metric\"><b>'+esc(currentStageLabel)+'</b><span>active stage</span></div><div class=\"metric\"><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>readiness</span></div><div class=\"metric\"><b>'+(row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'--')+'</b><span>average latency</span></div><div class=\"metric\"><b>'+(row.drop_rate!=null?Math.round(row.drop_rate*100)+'%':'--')+'</b><span>session drops</span></div>';\n var insights=$('pk-camera-ops-insights');if(insights)insights.innerHTML='<span>Camera relay</span><b class=\"'+(cameraLive?'ok':'bad')+'\">'+(cameraLive?'LIVE':'CONNECTING')+'</b><span>Mesh node</span><b class=\"'+(n.connected?'ok':'bad')+'\">'+(n.connected?'connected':'offline')+'</b><span>Production mode</span><b class=\"'+(device.production_mode?'ok':'')+'\">'+(device.production_mode?'armed':'disabled')+'</b><span>Readiness</span><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>Heartbeat</span><b>'+(row.heartbeat_age_seconds!=null?Math.round(row.heartbeat_age_seconds)+'s ago':'--')+'</b><span>Current room</span><b>'+esc(String(row.current_session_id||row.active_session_id||'none').slice(0,24))+'</b><span>Triggers</span><b>'+esc(row.trigger_count!=null?row.trigger_count:0)+'</b><span>Sessions</span><b>'+esc(row.sessions_total!=null?row.sessions_total:0)+'</b>';\n var stages=[[currentStageLabel,true,'ACTIVE'],['Robot online',!!n.connected,'ready'],['Camera streaming',cameraLive,'ready'],['Motion detector armed',!!device.production_mode,'armed'],['Scheduler session',active,'active']],pipe=$('pk-camera-ops-pipeline');if(pipe)pipe.innerHTML=stages.map(function(s,index){return '<div class=\"'+(index===0?'fleet-stage-focus '+esc(currentStageStatus):'')+'\"><i class=\"'+(s[1]?'ok':'warn')+'\"></i><span>'+esc(s[0])+'</span><b>'+(s[1]?s[2]:'waiting')+'</b></div>';}).join('');\n var inv=device.device_inventory||{},mh=inv.media_health||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Camera permission</span><b class=\"'+(mh.camera_access===false?'bad':'ok')+'\">'+(mh.camera_access===false?'DENIED':mh.camera_access===true?'granted':'--')+'</b><span>Audio permission</span><b class=\"'+(mh.audio_access===false?'bad':'ok')+'\">'+(mh.audio_access===false?'DENIED':mh.audio_access===true?'granted':'--')+'</b><span>Camera worker</span><b class=\"'+(mh.camera_stalled?'bad':'ok')+'\">'+(mh.camera_stalled?'STALLED':mh.camera_worker?'streaming':'--')+'</b><span>Frame age</span><b>'+(mh.camera_frame_age_seconds!=null?Number(mh.camera_frame_age_seconds).toFixed(1)+'s':'--')+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';\n var prod=$('pk-camera-ops-production');if(prod){prod.textContent=device.production_mode?'Disable production':'Enable production';prod.classList.toggle('primary',!!device.production_mode);}\n }\n function reloadCameraOperations(){\n if(!cameraOpsNode)return;var img=$('pk-camera-ops-img'),empty=$('pk-camera-ops-empty');if(!img)return;img.dataset.live='false';if(empty){empty.style.display='block';empty.textContent='opening RoboVision camera...';}\n img.onload=function(){img.dataset.live='true';if(cameraOpsRetryTimer){clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=null;}if(empty)empty.style.display='none';refreshCameraOperations();};img.onerror=function(){var failedNode=cameraOpsNode;img.dataset.live='false';if(empty){empty.style.display='block';empty.textContent='camera relay unavailable / retrying';}refreshCameraOperations();if(cameraOpsRetryTimer)clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=setTimeout(function(){if(cameraOpsNode&&cameraOpsNode===failedNode)reloadCameraOperations();},3000);};img.src=cameraOpsFeedUrl(cameraOpsNode);refreshCameraOperations();\n }\n function openCameraOperations(n){\n cameraOpsNode=n;var host=$('pk-camera-ops');if(!host)return;host.classList.add('open');document.body.classList.add('camera-ops-open');var name=n.displayName||n.name||'robot',avatar=rpAvatarFor(name),av=$('pk-camera-ops-avatar');$('pk-camera-ops-name').textContent=name+' / live operations';$('pk-camera-ops-sub').textContent='RoboVision mesh relay / '+(n.connected?'connected':'offline');if(av){if(avatar){av.src=avatar+QS;av.style.display='block';}else{av.removeAttribute('src');av.style.display='none';}}reloadCameraOperations();if(cameraOpsTimer)clearInterval(cameraOpsTimer);cameraOpsTimer=setInterval(refreshCameraOperations,1000);\n }\n function closeCameraOperations(){\n var host=$('pk-camera-ops'),img=$('pk-camera-ops-img');if(host)host.classList.remove('open');document.body.classList.remove('camera-ops-open');if(cameraOpsRetryTimer){clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=null;}if(img){img.src='';img.dataset.live='false';}if(cameraOpsTimer){clearInterval(cameraOpsTimer);cameraOpsTimer=null;}cameraOpsNode=null;if(document.fullscreenElement&&document.exitFullscreen)document.exitFullscreen().catch(function(){});\n }\n (function bindCameraOperations(){\n var close=$('pk-camera-ops-close'),refresh=$('pk-camera-ops-refresh'),nativeBtn=$('pk-camera-ops-native'),trigger=$('pk-camera-ops-trigger'),prod=$('pk-camera-ops-production'),end=$('pk-camera-ops-end');\n if(close)close.addEventListener('click',closeCameraOperations);if(refresh)refresh.addEventListener('click',reloadCameraOperations);if(nativeBtn)nativeBtn.addEventListener('click',function(){var host=$('pk-camera-ops');if(host&&host.requestFullscreen)host.requestFullscreen().catch(function(e){showOut('fullscreen: '+e.message);});});if(trigger)trigger.addEventListener('click',function(){if(cameraOpsNode)triggerSession(schedId(cameraOpsNode));});if(prod)prod.addEventListener('click',function(){if(cameraOpsNode){var d=schedulerDevice(cameraOpsNode)||{};setProductionMode(cameraOpsNode,!d.production_mode);setTimeout(refreshCameraOperations,400);}});if(end)end.addEventListener('click',function(){if(cameraOpsNode)endSession(schedId(cameraOpsNode));});document.addEventListener('keydown',function(e){if(e.key==='Escape'&&cameraOpsNode)closeCameraOperations();});\n })();\n\n // \u2500\u2500 cam: target a LiveKit track; fall back to a styled preview \u2500\u2500\n function startCam(n){\n idleCam('connecting');\n tryVisionOverlay(n, true);\n startMicMonitor(n);\n return;\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){\n // No real session running. If this robot is in production mode,\n // keep its camera live via the same on-demand preview path \"Go\n // live\" uses, instead of showing an idle placeholder \u2014 production\n // mode means the feed should always be visible.\n // Production mode leaves the idle camera with the robot's motion\n // detector. Operator preview is started explicitly with Go live.\n idleCam(info.reason||'idle');\n return;\n }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); tryVisionOverlay(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 RoboVision camera relay\u2026', idle:'camera relay standing by', production_mode_off:'production mode off', 'no-sdk':'voice session active \u00B7 loading microphone\u2026', unreachable:'camera relay 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 selectedRobotMic(){var el=$('pk-audio-input');if(!el)return 'default';var option=el.options&&el.selectedIndex>=0?el.options[el.selectedIndex]:null;return option?String(option.textContent||option.value):String(el.value||'default');}\n function selectedRobotOutput(){var el=$('pk-audio-output');if(!el)return 'default';var option=el.options&&el.selectedIndex>=0?el.options[el.selectedIndex]:null;return option?String(option.textContent||option.value):String(el.value||'default');}\n function setMicMonitorAudible(enabled){\n micMonitorAudible=!!enabled;\n var btn=$('pk-mic-listen'),note=$('pk-mic-listen-note');\n if(micMonitorAudible){\n var AC=window.AudioContext||window.webkitAudioContext;\n if(AC) audioCtx=audioCtx||new AC();\n if(audioCtx&&audioCtx.state==='suspended') audioCtx.resume().catch(function(e){if(note)note.textContent='Browser audio resume failed: '+e.message;});\n }\n if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;\n if(btn){btn.textContent=micMonitorAudible?'Mute browser monitor':'Listen in browser';btn.classList.toggle('primary',micMonitorAudible);}\n if(note)note.textContent=micMonitorAudible?(lkAudioEls.length?'Robot mic is playing in this browser. Use headphones.':'Listening armed \u2014 waiting for the robot mic track.'):'Browser monitoring muted.';\n lkAudioEls.forEach(function(el){el.muted=true;});\n }\n function startMicMonitor(n){\n if(lkRoom||micMonitorConnecting)return;\n micMonitorConnecting=true;\n micMonitorState='checking active conversation session';\n var state=$('pk-micstate');if(state)state.textContent='selected '+selectedRobotMic()+' \u00B7 checking active session...';\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS,{cache:'no-store'})\n .then(function(r){if(!r.ok)throw new Error('scheduler '+r.status);return r.json();})\n .then(function(info){\n micMonitorConnecting=false;\n if(info&&info.active&&info.url&&info.token&&window.LivekitClient){micMonitorState='connecting to '+info.url;attachLiveKit(info,n,true);return;}\n micMonitorState=info&&info.reason?String(info.reason).replace(/_/g,' '):'waiting for conversation session';\n if(state)state.textContent='selected '+selectedRobotMic()+' \u00B7 waiting for conversation session';\n })\n .catch(function(e){micMonitorConnecting=false;micMonitorState='monitor unavailable: '+e.message;if(state)state.textContent='selected '+selectedRobotMic()+' \u00B7 '+micMonitorState;});\n }\n function attachLiveKit(info, n, audioOnly){\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,publication,participant){\n if(track.kind==='video'){\n if(audioOnly)return;\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'){\n // Track names are backend-dependent (\"mic\", device labels, or\n // empty). Identify the robot by participant instead; reject the\n // voice worker/viewer so this remains the physical robot mic.\n var identity=String(participant&&participant.identity||'');\n var expected=String(schedId(n)||'').toLowerCase();\n var nodeName=String(n.name||'').toLowerCase();\n var lowerIdentity=identity.toLowerCase();\n var isWorker=/(agent|operator|viewer)/.test(lowerIdentity);\n var isRobot=!isWorker&&(lowerIdentity===expected||lowerIdentity===nodeName||lowerIdentity.indexOf(expected)>-1||lowerIdentity.indexOf(nodeName)>-1);\n if(!isRobot){if(window.console)console.debug('RoboPark ignored non-robot audio track',identity,track.name,publication&&publication.source);return;}\n hookMicAnalyser(track);\n micMonitorState='live robot mic from '+identity;\n var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 live track published';\n // Muted by default: this is the camera-preview panel, running on\n // the SAME physical device as the robot in local testing. Auto-\n // playing the subscribed track (robot mic, or later the agent's\n // TTS) through the browser's speakers next to the robot's own\n // mic closes an acoustic feedback loop \u2014 heard as a nonstop echo\n // that drowns out the actual conversation. The VU meter above\n // (hookMicAnalyser) taps the raw track directly and works fine\n // muted.\n var audio=track.attach(); audio.autoplay=false; audio.playsInline=true;audio.setAttribute('playsinline','');audio.controls=false; audio.muted=true; audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);\n var listenNote=$('pk-mic-listen-note');if(listenNote&&micMonitorAudible)listenNote.textContent='Robot mic is playing in this browser. Use headphones.';\n }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){lkRoom=null;micAnalyser=null;if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}micMonitorSource=null;micMonitorGain=null;micMonitorConnecting=false;micMonitorState='session ended';var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 session ended';if(!audioOnly&&note)note.textContent='session ended';if(!audioOnly&&res)res.textContent='\u2014';});\n var livekitUrl=info.url;\n if((location.hostname==='localhost'||location.hostname==='127.0.0.1') && /^ws:\\/\\/localhost(?::|\\/)/i.test(livekitUrl)) livekitUrl=livekitUrl.replace(/^ws:\\/\\/localhost/i,'ws://127.0.0.1');\n lkRoom.connect(livekitUrl, info.token).then(function(){micMonitorState='connected, waiting for robot mic publication';var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 '+micMonitorState;if(!audioOnly&&note)note.textContent='connected \u00B7 waiting for camera track\u2026';})\n .catch(function(e){lkRoom=null;micMonitorConnecting=false;micMonitorState='LiveKit connection failed: '+(e&&e.message?e.message:'unreachable');var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 '+micMonitorState;if(!audioOnly){idleCam('unreachable');if(note)note.textContent='LiveKit connection failed';}if(window.console)console.error('RoboPark LiveKit connection failed',e);});\n }catch(e){ micMonitorConnecting=false;micMonitorState='LiveKit SDK unavailable';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(); if(audioCtx.state==='suspended') audioCtx.resume().catch(function(){});\n var ms=track.mediaStreamTrack; if(!ms) return;\n if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n var gain=audioCtx.createGain();gain.gain.value=micMonitorAudible?micMonitorVolume:0;src.connect(gain);gain.connect(audioCtx.destination);micMonitorSource=src;micMonitorGain=gain;\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){\n micAnalyser.getByteTimeDomainData(buf);\n var sum=0;\n for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; }\n var rms=Math.sqrt(sum/buf.length);\n // USB microphones commonly sit around -55 dBFS at room level and\n // -20 dBFS when spoken into. Map that useful range to 0..100 rather\n // than displaying normalized RMS directly (which looked stuck at 1%).\n var db=rms>0 ? 20*Math.log10(rms) : -90;\n lvl=Math.max(0,Math.min(1,(db+60)/45));\n }\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?('selected '+selectedRobotMic()+' \u00B7 '+(lvl>0.12?'live, hearing audio':'live, quiet')):('selected '+selectedRobotMic()+' \u00B7 '+micMonitorState));\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; we don't get a\n // published track, so the meter stays at \"\u2014\" unless a speaker\n // round-trip test is currently running. While a test plays, the\n // bars animate to show the live mic picking up the tone.\n var liveRms = window.__pkSpkLiveRms || 0;\n var pass_ = window.__pkSpkLivePass;\n for(var j=0;j<si.length;j++){\n var on = j<liveRms*si.length;\n si[j].style.height=(3+(on?liveRms*14:0))+'px';\n si[j].style.background = on\n ? (pass_===true ? 'var(--ok)'\n : pass_===false ? 'var(--bad)'\n : (j<si.length*0.7?'var(--ok)':j<si.length*0.9?'var(--warn)':'var(--bad)'))\n : 'var(--glass-border)';\n }\n set('pk-spkval', liveRms>0 ? Math.round(liveRms*100)+'%' : '\u2014');\n if(swx){\n swx.clearRect(0,0,sw.width,sw.height);\n // baseline\n swx.strokeStyle='rgba(255,255,255,0.10)';\n swx.lineWidth=1; swx.beginPath(); swx.moveTo(0,sw.height/2); swx.lineTo(sw.width,sw.height/2); swx.stroke();\n // recorded waveform (when we have one)\n var wav = window.__pkSpkWaveform;\n if(wav && wav.length){\n swx.strokeStyle = pass_===true ? 'rgba(110,224,166,0.95)'\n : pass_===false ? 'rgba(220,80,80,0.95)'\n : 'rgba(255,214,112,0.85)';\n swx.lineWidth=1.4; swx.beginPath();\n for(var x=0;x<sw.width;x++){\n var idx = Math.floor(x/wav.length * wav.length);\n var v = wav[idx] || 0;\n var yy = sw.height/2 - v * (sw.height*0.45);\n if(x===0) swx.moveTo(x,yy); else swx.lineTo(x,yy);\n }\n swx.stroke();\n } else {\n swx.strokeStyle='rgba(255,255,255,0.18)'; swx.lineWidth=1.4; swx.beginPath();\n 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); }\n swx.stroke();\n }\n }\n meterRAF=requestAnimationFrame(loop);\n })();\n }\n\n // \u2500\u2500 C2: speaker roundtrip test \u2500\u2500\n // Queues a \"play tone + record mic\" test on the robot's supervisor,\n // polls the result, and renders a PASS/FAIL banner + a tiny\n // history of the last 5 tests. The meter above mirrors the\n // recorded RMS while the test is running.\n var _spkTestHist = {}; // robotId -> [{t, pass, peak_db, ...}]\n\n function _spkTestSetLive(robotId, data){\n if(data && data.live){\n window.__pkSpkLiveRms = data.rms || 0;\n window.__pkSpkLivePass = data.pass;\n window.__pkSpkWaveform = data.waveform || null;\n } else {\n window.__pkSpkLiveRms = 0;\n window.__pkSpkLivePass = undefined;\n window.__pkSpkWaveform = null;\n }\n }\n function _spkTestRenderHistory(robotId){\n var host = $('pk-spk-test-history'); if(!host) return;\n var list = _spkTestHist[robotId] || [];\n if(!list.length){\n host.innerHTML = '<div class=\"pk-note\" style=\"margin-top:0.3rem\">no tests yet \u2014 press \u201CTest speaker\u201D</div>';\n return;\n }\n var rows = list.map(function(t){\n var cls = t.pass===true ? 'var(--ok)' : (t.pass===false ? 'var(--bad)' : 'var(--muted)');\n var mark = t.pass===true ? '\u2713' : (t.pass===false ? '\u2717' : '\u00B7');\n var when = new Date(t.t).toTimeString().slice(0,8);\n var peak = t.peak_db != null ? (t.peak_db+' dB') : '\u2014';\n var freq = t.mode==='tts' ? 'cached voice' : (t.frequency != null ? (Math.round(t.frequency)+' Hz') : '\u2014');\n return '<div class=\"pk-spk-hist-row\"><span class=\"pk-spk-mark\" style=\"color:'+cls+'\">'+mark+'</span>'\n + '<span class=\"pk-spk-when\">'+when+'</span>'\n + '<span class=\"pk-spk-freq\">'+freq+'</span>'\n + '<span class=\"pk-spk-peak\">peak '+peak+'</span>'\n + (t.diagnostic ? '<span class=\"pk-spk-diag\">'+esc(t.diagnostic)+'</span>' : '')\n + '</div>';\n }).join('');\n host.innerHTML = '<div class=\"pk-spk-hist-head\">last '+list.length+' tests</div>' + rows;\n }\n function _spkTestRenderResult(robotId, result){\n var host = $('pk-spk-test-result'); if(!host) return;\n if(!result){\n host.innerHTML = '';\n return;\n }\n if(!result.ok){\n host.innerHTML = '<div class=\"pk-spk-result bad\">'\n + '<div class=\"pk-spk-banner\">\u2717 round-trip test failed</div>'\n + '<div class=\"pk-spk-err\">'+esc(result.error||'(no response)')+'</div>'\n + '</div>';\n return;\n }\n var isTts=result.mode==='tts';\n var banner = result.pass\n ? '<div class=\"pk-spk-banner ok\">\u2713 '+(isTts?'cached voice playback OK':'speaker round-trip OK')+'</div>'\n : '<div class=\"pk-spk-banner bad\">\u2717 no signal detected</div>';\n var meta = '<div class=\"pk-spk-meta\">'\n + (result.playback_only?'':'<div><span class=\"l\">peak</span><span class=\"v\">'+esc(result.recorded_peak_db+' dB')+'</span></div><div><span class=\"l\">RMS</span><span class=\"v\">'+esc(result.recorded_rms_db+' dB')+'</span></div><div><span class=\"l\">threshold</span><span class=\"v\">'+esc(result.threshold_db+' dB')+'</span></div>')\n + (isTts?'<div><span class=\"l\">voice</span><span class=\"v\">'+esc((result.tts_provider||'tts')+' / '+(result.tts_voice||'default'))+'</span></div><div><span class=\"l\">cache</span><span class=\"v\">'+(result.cache_hit?'hit':'generated')+'</span></div>':'<div><span class=\"l\">frequency</span><span class=\"v\">'+esc(Math.round(result.frequency)+' Hz')+'</span></div>')\n + '<div><span class=\"l\">duration</span><span class=\"v\">'+esc(result.duration+' s')+'</span></div>'\n + '<div><span class=\"l\">latency</span><span class=\"v\">'+esc(result.duration_ms+' ms')+'</span></div>'\n + '<div><span class=\"l\">output</span><span class=\"v\">'+esc(result.output_device)+'</span></div>'\n + '<div><span class=\"l\">input</span><span class=\"v\">'+esc(result.input_device)+'</span></div>'\n + '<div><span class=\"l\">format</span><span class=\"v\">'+esc((result.output_sample_rate||result.sample_rate)+' Hz / '+(result.output_channels||1)+'ch out'+(result.playback_only?'':' \u00B7 '+(result.input_sample_rate||result.sample_rate)+' Hz / '+(result.input_channels||1)+'ch in'))+'</span></div>'\n + '</div>';\n var diag = result.diagnostic ? '<div class=\"pk-spk-diag\">'+esc(result.diagnostic)+'</div>' : '';\n host.innerHTML = '<div class=\"pk-spk-result '+(result.pass?'ok':'bad')+'\">'\n + banner + meta + diag + '</div>';\n }\n function _micTestRenderResult(result,label){\n var host=$('pk-mic-test-result'),state=$('pk-mic-test-state');if(!host)return;\n if(!result||!result.ok){var error=result&&result.error||'robot did not return a result';host.innerHTML='<div class=\"pk-mic-result bad\"><b>TEST FAILED</b><div style=\"margin-top:.3rem\">'+esc(error)+'</div></div>';if(state)state.textContent='Failed | '+error;return;}\n var passed=result.pass===true,mode=result.mode||label;\n host.innerHTML='<div class=\"pk-mic-result '+(passed?'ok':'bad')+'\"><b>'+(passed?'MIC PATH VERIFIED':'NO USABLE SIGNAL')+'</b><div class=\"pk-mic-readings\"><div class=\"pk-mic-reading\"><span>Peak</span><b>'+esc(result.recorded_peak_db!=null?result.recorded_peak_db+' dB':'--')+'</b></div><div class=\"pk-mic-reading\"><span>RMS</span><b>'+esc(result.recorded_rms_db!=null?result.recorded_rms_db+' dB':'--')+'</b></div><div class=\"pk-mic-reading\"><span>Nonzero</span><b>'+esc(result.nonzero_percent!=null?result.nonzero_percent+'%':'--')+'</b></div><div class=\"pk-mic-reading\"><span>Format</span><b>'+esc((result.sample_rate||result.input_sample_rate||'--')+' Hz / '+(result.channels||result.input_channels||1)+'ch')+'</b></div></div><div style=\"margin-top:.38rem;color:var(--muted)\">'+esc(result.input_device||'USB microphone')+' | '+esc(result.duration_ms||0)+' ms | '+esc(result.diagnostic||mode)+'</div></div>';\n if(state)state.textContent=passed?'Passed | '+mode+' confirmed on the robot':'Capture completed but no usable PCM signal was detected';\n }\n function setMicTestBusy(busy,text){\n [$('pk-mic-groundtruth'),$('pk-mic-roundtrip'),$('pk-mic-apply')].forEach(function(button){if(button)button.disabled=busy;});\n var state=$('pk-mic-test-state');if(state&&text)state.textContent=text;\n }\n function runMicGroundtruth(n){\n var robotId=schedId(n),duration=parseFloat((($('pk-mic-duration')||{}).value)||'4');setMicTestBusy(true,'Recording raw PCM on the robot for '+duration+' seconds...');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'mic_groundtruth',duration:duration,input:'2',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})\n .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-groundtruth');})\n .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'mic_groundtruth');setMicTestBusy(false);});\n }\n function runMicRoundtrip(n){\n var robotId=schedId(n);setMicTestBusy(true,'Playing a reference tone and recording the locked USB microphone...');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'tone',playback_only:false,frequency:1000,duration:1,amplitude:.6,threshold_db:-30,output:'1',input:'2',output_name:'USB Audio Device: - (hw:2,0)',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})\n .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-roundtrip');})\n .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'roundtrip');setMicTestBusy(false);});\n }\n function runSpeakerTest(n, forcedMode){\n var robotId = schedId(n), mode=forcedMode||(($('pk-spk-mode')||{}).value||'tts');\n var buttons=[$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')];buttons.forEach(function(b){if(b)b.disabled=true;});\n var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = true; btn.textContent = mode==='tts'?'loading voice\u2026':'testing\u2026'; }\n var status = $('pk-spk-test-stateline');\n if(status) status.textContent = mode==='tts'?'loading cached character voice\u2026':'queued \u2014 waiting for robot\u2026';\n var freq = parseFloat(($('pk-spk-freq')||{}).value || '1000');\n var dur = parseFloat(($('pk-spk-dur')||{}).value || '0.6');\n var phrase=String((($('pk-spk-phrase')||{}).value)||'Hello, I am ready to talk.').trim();\n var output=(($('pk-audio-output')||{}).value)||'default';\n var input=(($('pk-audio-input')||{}).value)||'default';\n var outputName=selectedRobotOutput(),inputName=selectedRobotMic();\n // Animate the meter with a synthetic \"during-test\" peak so the\n // operator sees something happen immediately while waiting for\n // the real recording.\n _spkTestSetLive(robotId, {live:true, rms: 0.45, pass: undefined, waveform: null});\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',\n {mode:mode, playback_only:mode==='tts', text:phrase, frequency:freq, duration:dur, amplitude:0.6, threshold_db:-30, output:output, input:input, output_name:outputName, input_name:inputName})\n .then(function(q){\n // long-poll up to 8s, then fall back to 12s of result polling\n if(status) status.textContent = mode==='tts'?'playing cached voice on '+selectedRobotOutput()+'\u2026':'playing tone + recording\u2026';\n return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, '', 'speaker-test');\n })\n .catch(function(e){\n if(status) status.textContent = '\u2717 test failed: '+e.message;\n _spkTestSetLive(robotId, {live:false});\n _spkTestRenderResult(robotId, {ok:false, error:e.message});\n buttons.forEach(function(b){if(b)b.disabled=false;});\n if(btn){ btn.disabled = false; btn.textContent = mode==='tts'?'Play cached voice':'Run tone test'; }\n });\n }\n // Render a synthetic \"during-test\" waveform so the meter animates\n // while waiting for the round-trip result. Called by startMeters\n // to keep the bars interesting during the test.\n function _spkTestMark(robotId, result){\n // Append to history (cap at 5)\n var list = _spkTestHist[robotId] || [];\n list.unshift({\n t: Date.now(),\n pass: result && result.ok ? (result.pass===true) : null,\n peak_db: result && result.recorded_peak_db != null ? result.recorded_peak_db : null,\n frequency: result && result.frequency != null ? result.frequency : null,\n mode: result && result.mode || 'tone',\n diagnostic: result && result.diagnostic || (result && !result.ok ? result.error : null),\n });\n while(list.length > 5) list.pop();\n _spkTestHist[robotId] = list;\n _spkTestRenderHistory(robotId);\n }\n // Override the per-robot shell-result renderer for speaker_test so\n // we also update the speaker-test panel + meter. _shellWaitForResult\n // already calls _renderShellResult with the right shape.\n var _origRenderShellResult = _renderShellResult;\n _renderShellResult = function(robotId, result, label){\n _origRenderShellResult(robotId, result, label);\n if(label === 'speaker-test'){\n _spkTestSetLive(robotId, {live:false});\n _spkTestMark(robotId, result);\n _spkTestRenderResult(robotId, result);\n [$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')].forEach(function(b){if(b)b.disabled=false;});\n var status = $('pk-spk-test-stateline');\n if(status){\n if(!result || !result.ok){ status.textContent = '\u2717 test failed'; }\n else if(result.playback_only&&result.played){ status.textContent = '\u2713 speaker playback completed'; }\n else if(result.pass){ status.textContent = '\u2713 pass \u00B7 '+result.recorded_peak_db+' dB peak'; }\n else { status.textContent = '\u2717 no signal \u2014 '+result.recorded_peak_db+' dB peak'; }\n }\n var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = (($('pk-spk-mode')||{}).value==='tone'?'Run tone test':'Play cached voice'); }\n }\n if(label==='mic-groundtruth'||label==='mic-roundtrip'){\n _micTestRenderResult(result,label);\n setMicTestBusy(false);\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, updateTelemetry:updateTelemetry };\n })();\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n setInterval(loadParkSlots, 7000);\n // Pre-load character presets and voice stacks on startup so the Park\n // robot-detail drawer has populated dropdowns even if the user never visits the robopark tab.\n loadCharacterPresets();\n loadVoiceStacks();\n setInterval(function(){ loadCharacterPresets(); loadVoiceStacks(); }, 12000);\n // C3: load the operator name (or prompt for one) before the first\n // tab opens, so the very first audit-attributed action already has\n // the right name attached.\n loadOperatorName();\n maybePromptOperator();\n initTab();\n})();\n</script>\n</body>\n</html>";
24
+ 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<meta name=\"robopark-ui-build\" content=\"provisioning-command-20260717-2\">\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:#050504;\n --grad: radial-gradient(900px 520px at 7% -8%, rgba(255,198,79,0.18), transparent 64%),\n radial-gradient(760px 520px at 98% 4%, rgba(161,105,16,0.16), transparent 62%),\n radial-gradient(650px 520px at 62% 112%, rgba(255,211,123,0.08), transparent 68%),\n linear-gradient(145deg,#030303 0%,#0b0905 46%,#050504 100%);\n --panel: rgba(16,14,10,0.64); --panel-2: rgba(27,23,15,0.68); --panel-solid:#0d0b08;\n --glass-border: rgba(255,224,151,0.14); --glass-hi: rgba(255,241,204,0.09);\n --border: rgba(255,224,151,0.12); --border-strong: rgba(255,205,93,0.52);\n --ink:#F6F0E3; --muted:#A99D87;\n --gold:#C9962E; --gold-bright:#F6D77B; --gold-pale:#FFF0BE; --gold-deep:#78520F;\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 18px 55px rgba(0,0,0,0.56),0 2px 12px rgba(0,0,0,0.36);\n --mono:\"Cascadia Code\",\"SFMono-Regular\",Consolas,monospace;\n --sans:\"Trebuchet MS\",\"Segoe UI\",sans-serif;\n --display:\"Palatino Linotype\",\"Book Antiqua\",Palatino,serif;\n}\n@media (prefers-color-scheme:dark){:root{}}\n:root[data-theme=\"dark\"]{}\n:root[data-theme=\"light\"]{\n --bg:#090806;--panel:rgba(24,20,13,.72);--panel-2:rgba(38,31,18,.76);--panel-solid:#100e09;\n --ink:#FFF8E8;--muted:#B5A88F;--glass-border:rgba(255,224,151,.17);--glass-hi:rgba(255,244,214,.1);\n --border:rgba(255,224,151,.14);--border-strong:rgba(255,205,93,.58);--gold:#D3A43D;--gold-bright:#FFE094;\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;}\nbody:before{content:\"\";position:fixed;inset:0;pointer-events:none;z-index:-1;opacity:.22;background-image:linear-gradient(rgba(255,221,140,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,221,140,.025) 1px,transparent 1px);background-size:42px 42px;mask-image:linear-gradient(to bottom,black,transparent 82%);}\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),inset 0 -1px 0 rgba(0,0,0,.5);}\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-family:var(--display);font-size:1.08rem;letter-spacing:.015em;color:var(--gold-pale);text-shadow:0 0 24px rgba(246,215,123,.2);}\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/* fleet operations overview */\n.fleet-ops{display:flex;flex-direction:column;gap:.8rem;margin-bottom:1rem;}\n.fleet-hero{position:relative;overflow:hidden;display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;padding:1.05rem 1.15rem;border:1px solid var(--glass-border);border-radius:18px;background:radial-gradient(520px 170px at 5% 0%,rgba(246,215,123,.15),transparent 70%),linear-gradient(145deg,rgba(29,24,14,.8),rgba(7,6,4,.72));box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.fleet-hero:after{content:\"\";position:absolute;width:180px;height:180px;right:9%;top:-120px;border:1px solid rgba(246,215,123,.13);border-radius:50%;box-shadow:0 0 0 30px rgba(246,215,123,.025),0 0 0 62px rgba(246,215,123,.015);}\n.fleet-hero .eyebrow{font-family:var(--mono);font-size:.56rem;letter-spacing:.17em;text-transform:uppercase;color:var(--gold);}\n.fleet-hero h1{font-family:var(--display);font-size:clamp(1.45rem,2.5vw,2.25rem);line-height:1.05;color:var(--gold-pale);font-weight:500;margin:.18rem 0;}\n.fleet-hero p{font-size:.72rem;color:var(--muted);max-width:62ch;}\n.fleet-hero-actions{display:flex;gap:.45rem;flex-wrap:wrap;justify-content:flex-end;position:relative;z-index:1;}\n.fleet-kpis{display:grid;grid-template-columns:repeat(6,minmax(105px,1fr));gap:.55rem;}\n.fleet-kpi{padding:.75rem .8rem;border:1px solid var(--glass-border);border-radius:14px;background:linear-gradient(150deg,rgba(34,28,16,.58),rgba(8,7,5,.64));box-shadow:inset 0 1px 0 var(--glass-hi);min-width:0;}\n.fleet-kpi .label{font-family:var(--mono);font-size:.5rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.fleet-kpi .value{font-family:var(--display);font-size:1.55rem;line-height:1.15;color:var(--gold-pale);font-variant-numeric:tabular-nums;}\n.fleet-kpi .detail{font-family:var(--mono);font-size:.52rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.fleet-kpi.ok .value{color:var(--ok)}.fleet-kpi.warn .value{color:var(--warn)}.fleet-kpi.bad .value{color:var(--bad)}\n.fleet-insights{display:grid;grid-template-columns:1.05fr 1.35fr 1.25fr;gap:.65rem;}\n.fleet-panel{min-width:0;border:1px solid var(--glass-border);border-radius:16px;background:rgba(12,10,7,.68);box-shadow:inset 0 1px 0 var(--glass-hi);padding:.8rem;}\n.fleet-panel-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding-bottom:.55rem;margin-bottom:.55rem;border-bottom:1px solid var(--glass-border);}\n.fleet-panel-head b{font-family:var(--display);font-size:.92rem;color:var(--gold-pale);font-weight:500}.fleet-panel-head span{font-family:var(--mono);font-size:.5rem;color:var(--muted)}\n.fleet-readiness{display:grid;grid-template-columns:104px 1fr;gap:.75rem;align-items:center;}\n.fleet-donut{width:100px;height:100px;border-radius:50%;position:relative;display:grid;place-items:center;background:conic-gradient(var(--ok) 0 var(--ready-angle,0deg),var(--warn) var(--ready-angle,0deg) var(--warn-angle,0deg),var(--bad) var(--warn-angle,0deg) 360deg);box-shadow:0 0 30px rgba(246,215,123,.08);}\n.fleet-donut:after{content:\"\";position:absolute;inset:15px;border-radius:50%;background:#0c0a07;border:1px solid var(--glass-border)}.fleet-donut strong{position:relative;z-index:1;font-family:var(--display);font-size:1.15rem;color:var(--gold-pale)}\n.fleet-legend{display:grid;gap:.32rem}.fleet-legend div{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-family:var(--mono);font-size:.56rem;color:var(--muted)}.fleet-legend i{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:.35rem}.fleet-legend b{color:var(--ink)}\n.fleet-bars{display:grid;gap:.4rem;max-height:154px;overflow:auto}.fleet-bar-row{display:grid;grid-template-columns:minmax(58px,90px) 1fr 48px;gap:.45rem;align-items:center;font-family:var(--mono);font-size:.54rem;color:var(--muted)}.fleet-bar-row .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}.fleet-bar-row .track{height:7px;border-radius:10px;background:rgba(255,255,255,.045);overflow:hidden}.fleet-bar-row .fill{height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--gold-deep),var(--gold-bright));box-shadow:0 0 9px rgba(246,215,123,.2)}.fleet-bar-row .val{text-align:right}\n.fleet-attention{display:grid;gap:.36rem;max-height:154px;overflow:auto}.fleet-alert-row{display:grid;grid-template-columns:7px 1fr auto;gap:.48rem;align-items:start;padding:.36rem .42rem;border-radius:8px;background:rgba(255,255,255,.018);font-size:.61rem}.fleet-alert-row i{width:7px;height:7px;border-radius:50%;margin-top:.28rem;background:var(--warn)}.fleet-alert-row.bad i{background:var(--bad)}.fleet-alert-row.ok i{background:var(--ok)}.fleet-alert-row b{display:block;color:var(--ink);font-size:.62rem}.fleet-alert-row span{color:var(--muted);font-size:.56rem}.fleet-alert-row em{font-family:var(--mono);font-style:normal;font-size:.5rem;color:var(--muted);white-space:nowrap}\n.fleet-release{grid-column:1/-1;display:flex;align-items:center;gap:.55rem;flex-wrap:wrap;padding:.55rem .7rem}.fleet-release .version-pill{font-family:var(--mono);font-size:.56rem;border:1px solid var(--glass-border);border-radius:100px;padding:.18rem .5rem;color:var(--muted)}.fleet-release .version-pill.current{color:var(--ok);border-color:rgba(111,224,166,.28)}\n.node .fleet-card-actions{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.65rem;padding-top:.55rem;border-top:1px solid var(--glass-border)}\n.fleet-live-camera{position:relative;aspect-ratio:16/9;margin:.55rem 0;border:1px solid var(--glass-border);border-radius:12px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(246,215,123,.08),transparent 44%),#050504;box-shadow:inset 0 1px 0 var(--glass-hi)}\n.fleet-live-camera img{width:100%;height:100%;display:block;object-fit:cover;background:#050504}.fleet-live-camera .state{position:absolute;left:.5rem;bottom:.45rem;padding:.18rem .4rem;border-radius:100px;background:rgba(3,3,2,.78);border:1px solid var(--glass-border);font-family:var(--mono);font-size:.5rem;color:var(--warn);backdrop-filter:blur(8px)}.fleet-live-camera.live .state{color:var(--ok)}.fleet-live-camera.failed .state{color:var(--bad)}\n@media(max-width:1150px){.fleet-kpis{grid-template-columns:repeat(3,1fr)}.fleet-insights{grid-template-columns:1fr 1fr}.fleet-insights .fleet-panel:last-of-type{grid-column:1/-1}}\n@media(max-width:720px){.fleet-hero{align-items:flex-start;flex-direction:column}.fleet-hero-actions{justify-content:flex-start}.fleet-kpis{grid-template-columns:repeat(2,1fr)}.fleet-insights{display:block}.fleet-panel{margin-bottom:.65rem}.fleet-readiness{grid-template-columns:90px 1fr}.fleet-donut{width:86px;height:86px}}\n\n/* fullscreen RoboVision operations view */\n.pk-camera-ops{position:fixed;inset:0;z-index:80;display:none;background:#030302;color:var(--ink);overflow:hidden;}\n.pk-camera-ops.open{display:grid;grid-template-rows:64px 1fr;}\n.pk-camera-ops-head{display:flex;align-items:center;gap:.75rem;padding:.65rem 1rem;border-bottom:1px solid var(--glass-border);background:rgba(12,10,7,.9);backdrop-filter:blur(22px);}\n.pk-camera-ops-head .avatar{width:40px;height:40px;object-fit:contain;filter:drop-shadow(0 7px 14px rgba(0,0,0,.5))}.pk-camera-ops-head .title{font-family:var(--display);font-size:1.08rem;color:var(--gold-pale)}.pk-camera-ops-head .sub{font-family:var(--mono);font-size:.54rem;color:var(--muted)}\n.pk-camera-ops-actions{margin-left:auto;display:flex;gap:.4rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}\n.pk-camera-ops-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;min-height:0;}\n.pk-camera-stage{position:relative;min-width:0;min-height:0;background:radial-gradient(circle at 50% 50%,#17130b,#030302 72%);overflow:hidden;display:grid;place-items:center;}\n.pk-camera-stage img{width:100%;height:100%;object-fit:contain;display:block}.pk-camera-stage .empty{position:absolute;border:0;font-family:var(--mono);font-size:.68rem;letter-spacing:.08em;text-transform:uppercase}\n.pk-camera-stage:after{content:\"\";position:absolute;inset:18px;border:1px solid rgba(246,215,123,.13);pointer-events:none;clip-path:polygon(0 0,13% 0,13% 1px,1px 1px,1px 15%,0 15%,0 0,100% 0,100% 15%,calc(100% - 1px) 15%,calc(100% - 1px) 1px,87% 1px,87% 0,100% 0,100% 100%,87% 100%,87% calc(100% - 1px),calc(100% - 1px) calc(100% - 1px),calc(100% - 1px) 85%,100% 85%,100% 100%,0 100%,0 85%,1px 85%,1px calc(100% - 1px),13% calc(100% - 1px),13% 100%,0 100%)}\n.pk-camera-rec{position:absolute;left:34px;top:31px;display:flex;align-items:center;gap:.45rem;padding:.28rem .48rem;border-radius:7px;background:rgba(0,0,0,.58);backdrop-filter:blur(8px);font-family:var(--mono);font-size:.57rem;color:#fff}.pk-camera-rec i{width:7px;height:7px;border-radius:50%;background:var(--bad);box-shadow:0 0 10px var(--bad);animation:pulse 1.4s infinite}\n.pk-camera-clock{position:absolute;right:34px;top:31px;padding:.28rem .48rem;border-radius:7px;background:rgba(0,0,0,.58);font-family:var(--mono);font-size:.57rem;color:var(--gold-pale)}\n.pk-camera-overlay{position:absolute;left:34px;bottom:31px;width:min(660px,calc(100% - 68px));display:grid;grid-template-columns:repeat(4,1fr);gap:.45rem}.pk-camera-overlay .metric{padding:.55rem .6rem;border:1px solid rgba(246,215,123,.18);border-radius:10px;background:rgba(5,4,3,.68);backdrop-filter:blur(12px)}.pk-camera-overlay b{display:block;font-family:var(--display);font-size:.98rem;color:var(--gold-pale)}.pk-camera-overlay span{font-family:var(--mono);font-size:.46rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}\n.pk-camera-side{min-height:0;overflow:auto;padding:.8rem;background:linear-gradient(180deg,rgba(20,16,9,.94),rgba(7,6,4,.98));border-left:1px solid var(--glass-border)}\n.pk-camera-side .section{padding:.7rem;margin-bottom:.65rem;border:1px solid var(--glass-border);border-radius:13px;background:rgba(255,255,255,.018)}.pk-camera-side h3{font-family:var(--mono);font-size:.55rem;letter-spacing:.13em;text-transform:uppercase;color:var(--gold);margin-bottom:.5rem}.pk-camera-insight{display:grid;grid-template-columns:1fr auto;gap:.38rem .55rem;font-family:var(--mono);font-size:.56rem}.pk-camera-insight span{color:var(--muted)}.pk-camera-insight b{color:var(--ink);text-align:right;font-weight:600}.pk-camera-insight b.ok{color:var(--ok)}.pk-camera-insight b.bad{color:var(--bad)}\n.pk-camera-pipeline{display:grid;gap:.34rem}.pk-camera-pipeline div{display:grid;grid-template-columns:8px 1fr auto;gap:.42rem;align-items:center;font-family:var(--mono);font-size:.53rem;color:var(--muted)}.pk-camera-pipeline i{width:7px;height:7px;border-radius:50%;background:var(--muted)}.pk-camera-pipeline i.ok{background:var(--ok);box-shadow:0 0 8px rgba(111,224,166,.45)}.pk-camera-pipeline i.warn{background:var(--warn)}\nbody.camera-ops-open{overflow:hidden}\n@media(max-width:850px){.pk-camera-ops.open{grid-template-rows:auto 1fr}.pk-camera-ops-head{align-items:flex-start;flex-wrap:wrap}.pk-camera-ops-actions{width:100%;margin-left:0;justify-content:flex-start}.pk-camera-ops-grid{display:block;overflow:auto}.pk-camera-stage{height:58vh;min-height:340px}.pk-camera-side{border-left:0;border-top:1px solid var(--glass-border)}.pk-camera-overlay{grid-template-columns:repeat(2,1fr)}}\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.pk-pipeline{display:grid;gap:0.42rem;margin-top:0.7rem;position:relative;}\n.pk-live-stage-focus{--stage-color:var(--gold-bright);position:relative;overflow:hidden;margin-top:.58rem;padding:.78rem .85rem .78rem 1rem;border:1px solid var(--stage-color);border-radius:13px;background:linear-gradient(105deg,color-mix(in srgb,var(--stage-color) 24%,transparent),rgba(8,8,8,.68));box-shadow:0 10px 30px rgba(0,0,0,.28),inset 4px 0 0 var(--stage-color);}\n.pk-live-stage-focus:before{content:'';position:absolute;width:150px;height:150px;right:-65px;top:-95px;border-radius:50%;background:var(--stage-color);opacity:.12;filter:blur(5px);animation:pulse 1.3s infinite;}.pk-live-stage-focus .eyebrow{display:block;font-family:var(--mono);font-size:.49rem;letter-spacing:.13em;text-transform:uppercase;color:var(--stage-color);}.pk-live-stage-focus b{display:block;margin-top:.2rem;font-size:1rem;letter-spacing:.015em;color:var(--ink);}.pk-live-stage-focus span:last-child{display:block;margin-top:.18rem;font-family:var(--mono);font-size:.57rem;color:var(--muted);}.pk-live-stage-focus.blocked,.pk-live-stage-focus.failed{--stage-color:var(--bad)}.pk-live-stage-focus.waiting,.pk-live-stage-focus.paused{--stage-color:var(--muted)}.pk-live-stage-focus.active,.pk-live-stage-focus.running{--stage-color:var(--warn)}\n.pk-pipe-step{--phase:var(--gold);display:grid;grid-template-columns:2rem minmax(7.2rem,.72fr) minmax(9rem,1.35fr);gap:0.55rem;align-items:center;padding:0.55rem 0.62rem;border-radius:11px;border:1px solid rgba(255,255,255,.06);background:linear-gradient(110deg,color-mix(in srgb,var(--phase) 7%,transparent),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.58rem;color:var(--muted);transition:transform .18s,border-color .18s,background .18s;position:relative;overflow:hidden;}\n.pk-pipe-step.phase-hardware{--phase:#63d5c6}.pk-pipe-step.phase-trigger{--phase:#efb94c}.pk-pipe-step.phase-session{--phase:#66a8ff}.pk-pipe-step.phase-media{--phase:#68d48f}.pk-pipe-step.phase-intelligence{--phase:#e38bff}.pk-pipe-step.phase-playback{--phase:#ff9b62}.pk-pipe-step.phase-close{--phase:#a8b0bd}\n.pk-pipe-step .step-index{display:grid;place-items:center;width:1.65rem;height:1.65rem;border-radius:8px;border:1px solid color-mix(in srgb,var(--phase) 35%,transparent);color:var(--phase);background:color-mix(in srgb,var(--phase) 10%,transparent);font-weight:700;}\n.pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.055em;font-weight:650;display:block;}.pk-pipe-step .phase{display:block;color:var(--phase);font-size:.49rem;letter-spacing:.08em;margin-top:.14rem;}\n.pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;line-height:1.35;}.pk-pipe-step .time{display:block;color:color-mix(in srgb,var(--muted) 75%,transparent);font-size:.5rem;margin-top:.12rem;}.pk-pipe-step .step-state{display:inline-block;margin-left:.35rem;padding:.1rem .3rem;border:1px solid currentColor;border-radius:999px;font-size:.43rem;letter-spacing:.07em;vertical-align:1px;color:var(--muted)}\n.pk-pipe-step.ok{border-color:color-mix(in srgb,var(--ok) 22%,transparent)}\n.pk-pipe-step.ok .step-state{color:var(--ok)}.pk-pipe-step.pending{opacity:.52}.pk-pipe-step.pending .step-index{filter:saturate(.3)}\n.pk-pipe-step.failed,.pk-pipe-step.blocked{border-color:rgba(255,90,90,.48);background:linear-gradient(110deg,rgba(255,70,70,.14),rgba(255,255,255,.02));}\n.pk-pipe-step.current{transform:translateX(5px) scale(1.012);border-color:var(--phase);background:linear-gradient(105deg,color-mix(in srgb,var(--phase) 24%,transparent),rgba(255,255,255,.045));box-shadow:0 0 0 1px color-mix(in srgb,var(--phase) 28%,transparent),0 10px 32px rgba(0,0,0,.3);opacity:1;z-index:2;}\n.pk-pipe-step.current:after{content:'ACTIVE';position:absolute;right:-1.7rem;top:.45rem;transform:rotate(38deg);padding:.12rem 2rem;background:var(--phase);color:#080808;font-size:.42rem;font-weight:800;letter-spacing:.08em;}\n.pk-pipe-step.current .step-index{box-shadow:0 0 14px color-mix(in srgb,var(--phase) 55%,transparent);animation:pulse 1.15s infinite;}\n.pk-pipeline-summary{padding:0.7rem 0.75rem;border:1px solid var(--glass-border);border-radius:11px;background:linear-gradient(120deg,rgba(212,170,72,.08),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.62rem;color:var(--muted);line-height:1.45;}\n.pk-pipeline-summary.ready{color:var(--ok);border-color:rgba(70,220,150,.35);}\n.pk-pipeline-summary.running{color:var(--warn);border-color:rgba(255,196,70,.35);}\n.pk-pipeline-summary.blocked{color:var(--bad);border-color:rgba(255,90,90,.4);}\n.fleet-stage-focus{margin-top:.48rem;padding:.48rem .58rem;border:1px solid color-mix(in srgb,var(--warn) 48%,transparent);border-radius:9px;background:linear-gradient(100deg,rgba(255,184,62,.14),rgba(255,255,255,.02));font-family:var(--mono)}.fleet-stage-focus span{display:block;color:var(--warn);font-size:.45rem;letter-spacing:.1em;text-transform:uppercase}.fleet-stage-focus b{display:block;margin-top:.12rem;color:var(--ink);font-size:.66rem}.fleet-stage-focus.blocked,.fleet-stage-focus.failed{border-color:color-mix(in srgb,var(--bad) 55%,transparent)}.fleet-stage-focus.blocked span,.fleet-stage-focus.failed span{color:var(--bad)}.fleet-stage-focus.waiting,.fleet-stage-focus.paused{border-color:var(--glass-border)}.fleet-stage-focus.waiting span,.fleet-stage-focus.paused span{color:var(--muted)}\n.pk-pipe-history{margin-top:.75rem;border-top:1px solid var(--glass-border);padding-top:.7rem}.pk-pipe-history-head{display:flex;align-items:center;justify-content:space-between;gap:.6rem;margin-bottom:.5rem}.pk-pipe-history-head b{font-family:var(--mono);font-size:.65rem;color:var(--ink);text-transform:uppercase;letter-spacing:.06em}.pk-pipe-history-list{display:grid;gap:.45rem}.pk-run-card{border:1px solid var(--glass-border);border-radius:10px;background:var(--panel-2);padding:.55rem .62rem;cursor:pointer}.pk-run-card:hover{border-color:var(--gold)}.pk-run-top{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:start}.pk-run-title{font-family:var(--mono);font-size:.61rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pk-run-meta{font-family:var(--mono);font-size:.53rem;color:var(--muted);margin-top:.17rem}.pk-run-state{font-family:var(--mono);font-size:.5rem;text-transform:uppercase;border:1px solid var(--glass-border);border-radius:999px;padding:.18rem .4rem}.pk-run-state.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}.pk-run-state.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-run-state.running{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}.pk-run-stages{display:flex;gap:3px;margin-top:.45rem}.pk-run-stages i{height:4px;flex:1;border-radius:3px;background:rgba(255,255,255,.07)}.pk-run-stages i.ok{background:var(--ok)}.pk-run-stages i.failed,.pk-run-stages i.blocked{background:var(--bad)}.pk-history-pager{display:flex;align-items:center;gap:.35rem}.pk-history-pager span{font-family:var(--mono);font-size:.54rem;color:var(--muted)}\n.pk-mic-suite{margin-top:.6rem;display:grid;gap:.55rem}.pk-mic-health{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem}.pk-mic-check{padding:.48rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-mic-check .k{font-family:var(--mono);font-size:.49rem;color:var(--muted);text-transform:uppercase}.pk-mic-check .v{font-family:var(--mono);font-size:.62rem;color:var(--ink);margin-top:.16rem;overflow-wrap:anywhere}.pk-mic-check.ok{border-color:color-mix(in srgb,var(--ok) 30%,transparent)}.pk-mic-check.bad{border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-mic-result{border:1px solid var(--glass-border);border-radius:10px;padding:.58rem;background:var(--panel-2);font-family:var(--mono);font-size:.58rem}.pk-mic-result.ok{border-color:var(--ok);background:rgba(60,180,110,.08)}.pk-mic-result.bad{border-color:var(--bad);background:rgba(220,80,80,.08)}.pk-mic-readings{display:grid;grid-template-columns:repeat(4,1fr);gap:.35rem;margin-top:.45rem}.pk-mic-reading{padding:.35rem;background:rgba(0,0,0,.16);border-radius:7px}.pk-mic-reading span{display:block;color:var(--muted);font-size:.48rem;text-transform:uppercase}.pk-mic-reading b{display:block;color:var(--ink);font-size:.67rem;margin-top:.1rem}\n.pk-motor-registry{display:grid;gap:.4rem}.pk-motor-row{display:grid;grid-template-columns:1fr 4.5rem 5.5rem auto;gap:.35rem;align-items:end;padding:.45rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-motor-canvas{display:flex;align-items:stretch;gap:.55rem;overflow-x:auto;padding:.65rem .2rem;min-height:130px}.pk-motor-node{min-width:165px;padding:.55rem;border:1px solid rgba(255,205,93,.35);border-radius:12px;background:linear-gradient(145deg,rgba(201,150,46,.13),rgba(255,255,255,.025));position:relative}.pk-motor-node:not(:last-child):after{content:'';position:absolute;right:-.56rem;top:50%;width:.56rem;border-top:2px solid var(--gold)}.pk-motor-node .node-num{font-family:var(--mono);font-size:.48rem;color:var(--gold);text-transform:uppercase;margin-bottom:.35rem}.pk-motor-node .rp-field{margin-bottom:.3rem}.pk-motor-empty{display:grid;place-items:center;width:100%;min-height:100px;border:1px dashed var(--glass-border);border-radius:10px;color:var(--muted);font-family:var(--mono);font-size:.58rem}.pk-motor-status{font-family:var(--mono);font-size:.58rem;padding:.5rem;border-radius:8px;background:var(--panel-2);border:1px solid var(--glass-border)}.pk-motor-status.ok{color:var(--ok);border-color:var(--ok)}.pk-motor-status.bad{color:var(--bad);border-color:var(--bad)}\n@media(max-width:720px){.pk-pipe-step{grid-template-columns:1.8rem 1fr}.pk-pipe-step .detail{grid-column:2;text-align:left}.pk-mic-health{grid-template-columns:1fr}.pk-mic-readings{grid-template-columns:1fr 1fr}.pk-motor-row{grid-template-columns:1fr 4.5rem}}\n.pk-prodtoggle{display:flex;align-items:center;gap:0.5rem;margin-top:0.6rem;padding:0.45rem 0.6rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;}\n.pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}\n.pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}\n.pk-prodtoggle:has(input:checked){border-color:var(--ok);background:rgba(60,180,110,0.12);}\n.pk-svc-list{display:flex;flex-direction:column;gap:0.35rem;}\n.pk-svc-row{display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.55rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);font-family:var(--mono);font-size:0.64rem;}\n.pk-svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--muted);}\n.pk-svc-row.up .pk-svc-dot{background:var(--ok);box-shadow:0 0 6px var(--ok);}\n.pk-svc-row.down .pk-svc-dot{background:var(--bad);}\n.pk-svc-row.disabled .pk-svc-dot{background:var(--muted);}\n.pk-svc-name{font-weight:600;color:var(--ink);min-width:6.5em;}\n.pk-svc-meta{color:var(--muted);flex:1;}\n.pk-svc-restart-btn{font-size:0.6rem;padding:0.2rem 0.5rem;flex-shrink:0;}\n\n/* \u2500\u2500 C3: operator identity (top-bar badge + audit attribution) \u2500\u2500 */\n.rp-operator-badge{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.6rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;margin-left:0.4rem;user-select:none;}\n.rp-operator-badge:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-operator-badge.set{color:var(--muted);font-style:italic;}\n.rp-operator-badge.set:hover{color:var(--gold-bright);}\n.rp-actor-chip{font-family:var(--mono);font-size:0.62rem;padding:0.28em 0.7em;border-radius:100px;cursor:pointer;border:1px solid var(--border-strong);color:var(--muted);background:var(--panel-2);user-select:none;}\n.rp-actor-chip:hover{color:var(--ink);border-color:var(--border-strong);}\n.rp-actor-chip.active{color:var(--gold-bright);border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 10%,transparent);}\n.rp-actor-chip.disabled{opacity:0.45;cursor:not-allowed;}\n.rp-actor-mine{color:var(--gold-bright);}\n.rp-actor-mine b{color:var(--gold-bright);}\n.rp-actor-other{color:var(--muted);}\n.rp-actor-other b{color:var(--ink);font-weight:500;}\n.rp-audit-row-mine{background:color-mix(in srgb,var(--gold-bright) 6%,transparent);}\n\n/* \u2500\u2500 C4: Incidents sub-tab \u2500\u2500 */\n.rp-incidents-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:0.6rem;margin:0.5rem 0 0.8rem;}\n.rp-inc-kpi{background:var(--panel-2);border:1px solid var(--border-strong);border-radius:8px;padding:0.55rem 0.7rem;display:flex;flex-direction:column;gap:0.15rem;}\n.rp-inc-kpi .lab{font-family:var(--mono);font-size:0.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;display:flex;align-items:center;gap:0.35rem;}\n.rp-inc-kpi .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-weight:600;}\n.rp-inc-kpi .meta{font-family:var(--mono);font-size:0.58rem;color:var(--muted);}\n.pk-kpi-dot{width:8px;height:8px;border-radius:50%;background:var(--muted);display:inline-block;}\n.pk-kpi-dot.ok{background:var(--ok);box-shadow:0 0 6px var(--ok);}\n.pk-kpi-dot.warn{background:var(--warn,#e0a23a);box-shadow:0 0 6px var(--warn,#e0a23a);}\n.pk-kpi-dot.bad{background:var(--bad);box-shadow:0 0 6px var(--bad);}\n.rp-inc-table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:0.66rem;}\n.rp-inc-table th{text-align:left;padding:0.45rem 0.6rem;color:var(--muted);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid var(--glass-border);background:var(--panel-2);position:sticky;top:0;z-index:1;}\n.rp-inc-table td{padding:0.45rem 0.6rem;border-bottom:1px solid rgba(255,255,255,0.04);vertical-align:middle;}\n.rp-inc-table tr.rp-inc-sev-bad td{background:rgba(220,80,80,0.06);}\n.rp-inc-table tr.rp-inc-sev-warn td{background:rgba(224,162,58,0.05);}\n.rp-inc-sev{display:inline-block;padding:0.05em 0.55em;border-radius:5px;font-size:0.6rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;}\n.rp-inc-sev-bad .rp-inc-sev{color:var(--bad);border:1px solid color-mix(in srgb,var(--bad) 40%,transparent);background:rgba(220,80,80,0.10);}\n.rp-inc-sev-warn .rp-inc-sev{color:var(--warn,#e0a23a);border:1px solid color-mix(in srgb,var(--warn,#e0a23a) 40%,transparent);background:rgba(224,162,58,0.10);}\n.rp-inc-sev-info .rp-inc-sev{color:var(--muted);border:1px solid var(--border-strong);background:var(--panel-2);}\n.rp-inc-id{color:var(--muted);font-size:0.6rem;margin-left:0.4rem;}\n.rp-inc-acked-tag{display:inline-block;padding:0.05em 0.5em;border-radius:5px;font-size:0.58rem;color:var(--ok);border:1px solid color-mix(in srgb,var(--ok) 40%,transparent);background:rgba(60,180,110,0.12);margin-left:0.5rem;}\n.rp-inc-acked-row td{opacity:0.7;font-style:italic;}\n.rp-btn.ghost{background:transparent;}\n@media (max-width:900px){ .rp-incidents-kpis{grid-template-columns:repeat(2,1fr);} }\n\n/* \u2500\u2500 C2: speaker roundtrip test panel \u2500\u2500 */\n#pk-spk-test{font-family:var(--mono);}\n#pk-spk-test-stateline{font-family:var(--mono);font-size:0.62rem;color:var(--muted);}\n.pk-spk-result{margin-top:0.45rem;padding:0.55rem 0.65rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);font-family:var(--mono);font-size:0.66rem;}\n.pk-spk-result.ok{border-color:var(--ok);background:rgba(60,180,110,0.10);}\n.pk-spk-result.bad{border-color:var(--bad);background:rgba(220,80,80,0.10);}\n.pk-spk-banner{font-weight:600;font-size:0.78rem;margin-bottom:0.35rem;}\n.pk-spk-banner.ok{color:var(--ok);}\n.pk-spk-banner.bad{color:var(--bad);}\n.pk-spk-meta{display:grid;grid-template-columns:repeat(2,1fr);gap:0.2rem 0.6rem;margin:0.3rem 0;}\n.pk-spk-meta>div{display:flex;justify-content:space-between;gap:0.4rem;}\n.pk-spk-meta .l{color:var(--muted);text-transform:uppercase;font-size:0.58rem;letter-spacing:0.04em;}\n.pk-spk-meta .v{color:var(--ink);font-size:0.66rem;}\n.pk-spk-err{color:var(--bad);font-style:italic;margin-top:0.2rem;}\n.pk-spk-diag{color:var(--muted);font-style:italic;margin-top:0.3rem;font-size:0.6rem;line-height:1.4;}\n.pk-spk-hist-head{font-family:var(--mono);font-size:0.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-top:0.5rem;padding-top:0.4rem;border-top:1px solid var(--glass-border);}\n.pk-spk-hist-row{display:grid;grid-template-columns:1em 4em 4em 8em 1fr;gap:0.45rem;padding:2px 0;font-family:var(--mono);font-size:0.62rem;align-items:baseline;}\n.pk-spk-mark{font-weight:700;}\n.pk-spk-when{color:var(--muted);}\n.pk-spk-freq{color:var(--ink);}\n.pk-spk-peak{color:var(--ink);}\n\n/* \u2500\u2500 C1: per-robot shell section in the Park drawer \u2500\u2500 */\n.pk-shell-row{display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;}\n.pk-shell-lbl{font-family:var(--mono);font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;}\n.pk-shell-sel,.pk-shell-num{font-family:var(--mono);font-size:0.7rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:6px;padding:0.32rem 0.5rem;min-width:0;}\n.pk-shell-sel{flex:1;max-width:220px;}\n.pk-shell-num{width:5.5em;}\n.pk-shell-out-wrap{margin-top:0.4rem;border:1px solid var(--glass-border);border-radius:8px;background:var(--panel-solid);overflow:hidden;}\n.pk-shell-out{font-family:var(--mono);font-size:0.66rem;line-height:1.45;color:var(--ink);max-height:240px;overflow-y:auto;padding:0.45rem 0.6rem;background:#0c0e14;}\n.pk-shell-out:focus{outline:none;}\n.pk-shell-line{display:flex;gap:0.55rem;align-items:flex-start;padding:1px 0;}\n.pk-shell-ts{color:var(--muted);flex-shrink:0;font-size:0.58rem;padding-top:0.15em;min-width:3.6em;}\n.pk-shell-text{white-space:pre-wrap;word-break:break-word;flex:1;}\n.pk-shell-status{color:var(--gold-bright,var(--gold));font-style:italic;}\n.pk-shell-header{color:var(--muted);}\n.pk-shell-stderr{color:var(--bad);}\n.pk-shell-stdout{color:var(--ink);}\n.pk-shell-input{color:var(--info);font-weight:600;}\n.pk-shell-input .pk-shell-text::before{content:'$ ';color:var(--muted);font-weight:400;}\n#pk-shell-stateline{font-family:var(--mono);font-size:0.62rem;color:var(--muted);}\n#pk-shell-cmd{font-family:var(--mono);font-size:0.7rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:6px;padding:0.4rem 0.55rem;}\n\n/* \u2500\u2500 RoboPark Sessions & Telemetry \u2014 sub-tab control room \u2500\u2500 */\n.rp-subtabs{display:flex;gap:0.15rem;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:97px;z-index:18;flex-wrap:wrap;}\n.rp-subtab{font-family:var(--mono);font-size:0.72rem;padding:0.55rem 0.85rem;cursor:pointer;color:var(--muted);background:none;border:none;\n border-bottom:2px solid transparent;display:inline-flex;align-items:center;gap:0.4rem;transition:color .12s,border-color .12s;}\n.rp-subtab:hover{color:var(--ink);}\n.rp-subtab.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.rp-subtab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.rp-subtab .badge{font-family:var(--mono);font-size:0.6rem;padding:0.04em 0.45em;border-radius:100px;background:var(--panel-2);\n border:1px solid var(--glass-border);color:var(--muted);font-weight:600;}\n.rp-subtab.active .badge{color:var(--gold-bright);border-color:var(--border-strong);}\n.rp-sub{display:none;padding:0.8rem 1.1rem 1.5rem;}\n.rp-sub.active{display:block;}\n\n.rp-kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.7rem;margin-bottom:0.9rem;}\n.rp-kpi{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);\n -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);position:relative;overflow:hidden;}\n.rp-kpi::after{content:\"\";position:absolute;inset:0;pointer-events:none;background:linear-gradient(135deg,rgba(255,214,112,0.04),transparent 60%);}\n.rp-kpi .l{font-family:var(--mono);font-size:0.6rem;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);margin-bottom:0.25rem;}\n.rp-kpi .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.7rem;color:var(--ink);font-weight:600;line-height:1.1;}\n.rp-kpi .v.ok{color:var(--ok);} .rp-kpi .v.warn{color:var(--warn);} .rp-kpi .v.bad{color:var(--bad);} .rp-kpi .v.info{color:var(--info);}\n.rp-kpi .s{font-family:var(--mono);font-size:0.62rem;color:var(--muted);margin-top:0.2rem;}\n\n.rp-mini-chart-wrap{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);\n -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);margin-bottom:0.9rem;}\n.rp-mini-chart-wrap h4{font-family:var(--mono);font-size:0.62rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);\n margin-bottom:0.4rem;display:flex;justify-content:space-between;align-items:baseline;}\n.rp-mini-chart-wrap h4 .now{color:var(--muted);font-size:0.6rem;}\n.rp-mini-chart{width:100%;height:120px;display:block;}\n\n.rp-section-h{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.55rem;display:flex;align-items:center;gap:0.5rem;justify-content:space-between;}\n.rp-section-h .meta{color:var(--muted);font-size:0.62rem;text-transform:none;letter-spacing:0.02em;font-weight:400;}\n\n.rp-top3{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:0.6rem;margin-bottom:0.9rem;}\n.rp-top3 .rp-card{padding:0.55rem 0.7rem;}\n\n.rp-stream{border-radius:13px;background:var(--panel);\n -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);padding:0.55rem 0.7rem;max-height:300px;overflow-y:auto;}\n.rp-stream .line{padding:0.22rem 0.4rem;}\n.rp-stream .line .ic{font-size:0.8rem;flex:none;}\n\n.rp-filterbar{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;padding:0.5rem 0.7rem;background:var(--panel-2);\n border:1px solid var(--glass-border);border-radius:11px;margin-bottom:0.6rem;}\n.rp-filterbar .rp-field{flex:1;min-width:120px;max-width:240px;}\n.rp-filterbar .rp-field label{font-size:0.6rem;}\n.rp-filterbar .rp-field input,.rp-filterbar .rp-field select{font-size:0.7rem;padding:0.35rem 0.5rem;}\n.rp-filterbar .rp-btn{padding:0.32rem 0.55rem;}\n\n.rp-sess-table,.rp-history-table,.rp-audit-table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:0.7rem;background:var(--panel);\n -webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);border-radius:11px;overflow:hidden;box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-sess-table th,.rp-history-table th,.rp-audit-table th{padding:0.55rem 0.7rem;text-align:left;font-weight:600;\n font-size:0.6rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-bright);background:var(--panel-2);\n border-bottom:1px solid var(--glass-border);position:sticky;top:0;z-index:1;}\n.rp-sess-table td,.rp-history-table td,.rp-audit-table td{padding:0.5rem 0.7rem;border-bottom:1px solid var(--glass-border);\n color:var(--ink);vertical-align:middle;}\n.rp-sess-table tr:last-child td,.rp-history-table tr:last-child td,.rp-audit-table tr:last-child td{border-bottom:none;}\n.rp-sess-table tr:hover td,.rp-history-table tr:hover td,.rp-audit-table tr:hover td{background:color-mix(in srgb,var(--gold-bright) 5%,transparent);}\n.rp-sess-table .sid,.rp-history-table .sid{font-size:0.62rem;color:var(--muted);max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}\n.rp-sess-table .t-rel{color:var(--ink);display:block;font-size:0.7rem;}\n.rp-sess-table .t-abs{color:var(--muted);font-size:0.58rem;}\n.rp-sess-table .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 6px var(--ok);margin-right:0.4rem;animation:pulse 2.4s infinite;vertical-align:middle;}\n.rp-tbl-wrap{max-height:520px;overflow:auto;border-radius:11px;}\n.rp-tbl-pager{display:flex;justify-content:space-between;align-items:center;padding:0.55rem 0.2rem;gap:0.5rem;font-family:var(--mono);font-size:0.66rem;color:var(--muted);}\n.rp-tbl-pager .rp-btn{padding:0.3rem 0.55rem;}\n.rp-tbl-empty{padding:1.5rem;text-align:center;color:var(--muted);font-family:var(--mono);font-size:0.75rem;}\n.rp-end-badge{display:inline-block;padding:0.05em 0.45em;border-radius:5px;font-size:0.6rem;font-family:var(--mono);\n border:1px solid var(--glass-border);color:var(--muted);background:var(--panel-2);}\n.rp-end-badge.operator{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent);background:var(--bad-bg);}\n.rp-end-badge.silence{color:var(--muted);}\n.rp-end-badge.error{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.rp-end-badge.timeout{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);background:var(--warn-bg);}\n.rp-end-badge.completed{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:var(--ok-bg);}\n.rp-end-badge.active,.rp-end-badge.running{color:var(--ok);border-color:color(--ok);background:var(--ok-bg);}\n\n.rp-cat-chip{font-family:var(--mono);font-size:0.62rem;padding:0.28em 0.7em;border-radius:100px;\n border:1px solid var(--glass-border);background:var(--panel-2);color:var(--muted);cursor:pointer;\n display:inline-flex;align-items:center;gap:0.3rem;transition:all .12s;}\n.rp-cat-chip:hover{color:var(--ink);border-color:var(--border-strong);}\n.rp-cat-chip.active{color:var(--gold-bright);border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 10%,transparent);}\n.rp-cat-chip .ct{font-size:0.55rem;opacity:0.8;}\n\n.rp-modal-back{position:fixed;inset:0;z-index:60;background:rgba(4,3,12,0.62);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);\n display:none;align-items:flex-start;justify-content:center;padding:6vh 1rem 1rem;overflow-y:auto;}\n.rp-modal-back.open{display:flex;}\n.rp-modal{position:relative;width:min(720px,100%);max-height:80vh;background:linear-gradient(180deg,var(--panel-2),var(--panel));\n -webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);\n border:1px solid var(--border-strong);border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,0.55);display:flex;flex-direction:column;overflow:hidden;}\n.rp-modal-head{display:flex;align-items:center;gap:0.6rem;padding:0.8rem 1rem;border-bottom:1px solid var(--glass-border);background:var(--panel-2);}\n.rp-modal-head h3{font-family:var(--mono);font-size:0.86rem;font-weight:600;color:var(--gold-bright);flex:1;word-break:break-word;}\n.rp-modal-head .x{background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;\n width:30px;height:30px;cursor:pointer;font-size:0.95rem;flex:none;}\n.rp-modal-head .x:hover{color:var(--ink);border-color:var(--border-strong);}\n.rp-modal-body{padding:1rem;overflow-y:auto;flex:1;font-family:var(--mono);font-size:0.72rem;line-height:1.5;color:var(--ink);}\n.rp-modal-body .kv{display:grid;grid-template-columns:160px 1fr;gap:0.4rem 0.8rem;margin-bottom:0.4rem;}\n.rp-modal-body .kv .k{color:var(--muted);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.05em;}\n.rp-modal-body .kv .v{color:var(--ink);word-break:break-word;white-space:pre-wrap;}\n.rp-modal-body pre{background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;\n font-size:0.66rem;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;color:var(--ink);}\n\n.rp-call-shell{position:relative;min-height:720px;overflow:hidden;border:1px solid var(--glass-border);border-radius:24px;background:linear-gradient(145deg,rgba(18,15,9,.84),rgba(5,5,4,.9));box-shadow:0 30px 90px rgba(0,0,0,.7),inset 0 1px 0 rgba(255,238,194,.11);}\n.rp-call-shell:before{content:\"\";position:absolute;width:520px;height:520px;border-radius:50%;left:18%;top:-340px;background:rgba(246,215,123,.15);filter:blur(75px);pointer-events:none;}\n.rp-call-top{height:58px;padding:0 1.1rem;display:flex;align-items:center;gap:.75rem;border-bottom:1px solid var(--glass-border);background:rgba(8,7,5,.56);backdrop-filter:blur(24px);position:relative;z-index:3;}\n.rp-call-top .eyebrow{font-family:var(--mono);font-size:.57rem;letter-spacing:.16em;color:var(--gold);text-transform:uppercase;}\n.rp-call-top .title{font-family:var(--display);font-size:1.02rem;color:var(--gold-pale);letter-spacing:.03em;}\n.rp-call-top .session{margin-left:auto;font-family:var(--mono);font-size:.58rem;color:var(--muted);max-width:32ch;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.rp-call-grid{display:grid;grid-template-columns:280px minmax(380px,1fr) 330px;gap:1px;background:var(--glass-border);min-height:662px;position:relative;z-index:2;}\n.rp-call-rail,.rp-call-stage,.rp-call-chat{background:rgba(8,7,5,.82);padding:1rem;min-width:0;}\n.rp-call-rail{display:flex;flex-direction:column;gap:.8rem;}\n.rp-call-card{border:1px solid var(--glass-border);border-radius:16px;background:linear-gradient(145deg,rgba(38,31,18,.58),rgba(13,11,8,.66));box-shadow:inset 0 1px 0 var(--glass-hi),0 12px 35px rgba(0,0,0,.25);padding:.8rem;}\n.rp-call-character{position:relative;display:flex;align-items:center;gap:.75rem;padding:.75rem;border:1px solid rgba(246,215,123,.2);border-radius:15px;background:radial-gradient(circle at 12% 10%,rgba(246,215,123,.15),transparent 52%),rgba(16,14,10,.65);overflow:hidden;}\n.rp-call-character img{width:76px;height:76px;object-fit:contain;filter:drop-shadow(0 10px 20px rgba(0,0,0,.48));}\n.rp-call-character .name{font-family:var(--display);font-size:1.18rem;color:var(--gold-pale);font-weight:700;}\n.rp-call-character .meta{font-size:.64rem;line-height:1.4;}\n.rp-call-service-grid{display:grid;gap:.4rem;}\n.rp-call-service{display:grid;grid-template-columns:29px 1fr auto;align-items:center;gap:.45rem;padding:.48rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(255,255,255,.018);}\n.rp-call-service .ico{width:29px;height:29px;display:grid;place-items:center;border-radius:8px;background:rgba(246,215,123,.09);color:var(--gold-bright);font-size:.75rem;}\n.rp-call-service b{display:block;font-size:.64rem;color:var(--ink);}.rp-call-service small{display:block;font-family:var(--mono);font-size:.52rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:130px;}\n.rp-call-service .ready{font-family:var(--mono);font-size:.48rem;letter-spacing:.08em;text-transform:uppercase;color:var(--ok);}\n.rp-call-device{display:grid;gap:.22rem;margin-top:.45rem}.rp-call-device:first-child{margin-top:0}.rp-call-device label{font-family:var(--mono);font-size:.51rem;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}\n.rp-call-device select{width:100%;min-width:0;font-family:var(--mono);font-size:.62rem;background:#0c0a07;border:1px solid var(--glass-border);color:var(--ink);border-radius:9px;padding:.42rem .5rem;}\n.rp-call-device-note{margin-top:.55rem;padding:.52rem .58rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(0,0,0,.22);font-family:var(--mono);font-size:.54rem;line-height:1.45;color:var(--muted)}\n.rp-call-device-note.ok{color:var(--ok);border-color:rgba(87,224,156,.28);background:rgba(28,64,50,.28)}.rp-call-device-note.bad{color:var(--bad);border-color:rgba(255,126,110,.34);background:rgba(64,24,20,.34)}.rp-call-device-note.warn{color:var(--warn);border-color:rgba(242,193,78,.3);background:rgba(64,50,20,.3)}\n.rp-call-device-actions{display:grid;grid-template-columns:1fr 1fr;gap:.4rem;margin-top:.55rem}.rp-call-device-actions .rp-btn{width:100%}\n.rp-call-stage{display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;}\n.rp-call-stage:before{content:\"\";position:absolute;width:420px;height:420px;border:1px solid rgba(246,215,123,.08);border-radius:50%;box-shadow:0 0 0 42px rgba(246,215,123,.018),0 0 0 92px rgba(246,215,123,.012);}\n.rp-call-visual{position:relative;width:min(330px,80%);aspect-ratio:1;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle,rgba(246,215,123,.14),rgba(10,8,5,.58) 52%,rgba(0,0,0,.82) 72%);border:1px solid rgba(246,215,123,.24);box-shadow:0 0 70px rgba(201,150,46,.13),inset 0 0 48px rgba(246,215,123,.06);z-index:1;transition:box-shadow .3s,border-color .3s;}\n.rp-call-visual.speaking{box-shadow:0 0 95px rgba(246,215,123,.28),inset 0 0 55px rgba(246,215,123,.11);border-color:rgba(255,229,157,.55)}\n.rp-call-avatar{position:absolute;width:58%;height:58%;object-fit:contain;filter:drop-shadow(0 18px 30px rgba(0,0,0,.65));opacity:.96;}\n.rp-call-avatar:not([src]){display:none}\n.rp-call-avatar-fallback{position:absolute;width:128px;height:128px;border-radius:50%;display:grid;place-items:center;font-family:var(--display);font-size:2.8rem;letter-spacing:.08em;color:var(--gold-pale);background:radial-gradient(circle at 32% 24%,rgba(255,234,174,.18),rgba(201,150,46,.07) 48%,rgba(0,0,0,.2));border:1px solid rgba(246,215,123,.2);box-shadow:inset 0 0 34px rgba(246,215,123,.06),0 18px 45px rgba(0,0,0,.42);}\n.rp-call-avatar[src]{z-index:1}.rp-call-avatar[src]+.rp-call-avatar-fallback{display:none}\n.rp-call-bars{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:7px;opacity:.82}.rp-call-bars i{width:5px;height:16px;border-radius:8px;background:linear-gradient(var(--gold-pale),var(--gold));box-shadow:0 0 12px rgba(246,215,123,.35);transition:height .09s ease;}\n.rp-call-ring{position:absolute;inset:-10px;border:1px solid rgba(246,215,123,.13);border-radius:50%;animation:rp-call-spin 18s linear infinite}.rp-call-ring:before,.rp-call-ring:after{content:\"\";position:absolute;width:7px;height:7px;border-radius:50%;background:var(--gold-bright);box-shadow:0 0 15px var(--gold-bright)}.rp-call-ring:before{left:15%;top:12%}.rp-call-ring:after{right:10%;bottom:18%}\n@keyframes rp-call-spin{to{transform:rotate(360deg)}}\n.rp-call-agent-state{position:relative;z-index:2;margin-top:1.15rem;text-align:center}.rp-call-agent-state strong{display:block;font-family:var(--display);font-size:1.35rem;color:var(--gold-pale);font-weight:500}.rp-call-agent-state span{font-family:var(--mono);font-size:.55rem;letter-spacing:.13em;color:var(--muted);text-transform:uppercase}\n.rp-call-state{display:flex;align-items:center;justify-content:center;gap:.5rem;font-family:var(--mono);font-size:.62rem;padding:.55rem 0;position:relative;z-index:2;}\n.rp-call-orb{width:8px;height:8px;border-radius:50%;background:var(--muted)}.rp-call-orb.live{background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 1.6s infinite}.rp-call-orb.busy{background:var(--gold-bright);box-shadow:0 0 12px var(--gold-bright);animation:pulse 1s infinite}\n.rp-call-latency{position:relative;z-index:2;display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;width:min(420px,92%);margin-top:.65rem}.rp-call-latency div{padding:.48rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(0,0,0,.28);text-align:center}.rp-call-latency b{display:block;font-family:var(--mono);font-size:.72rem;color:var(--gold-bright)}.rp-call-latency span{font-family:var(--mono);font-size:.46rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}\n.rp-call-media{position:absolute;right:1rem;top:1rem;width:128px;aspect-ratio:4/3;border:1px solid var(--glass-border);border-radius:12px;background:#050504;overflow:hidden;display:none;z-index:4;box-shadow:var(--shadow)}.rp-call-media.on{display:block}.rp-call-media video{width:100%;height:100%;object-fit:cover}.rp-call-media .tag{position:absolute;left:.35rem;bottom:.3rem;font-family:var(--mono);font-size:.46rem;color:var(--gold-pale);background:rgba(0,0,0,.7);padding:.12rem .3rem;border-radius:4px}\n.rp-call-controls{position:relative;z-index:3;display:flex;align-items:center;justify-content:center;gap:.52rem;margin-top:1rem;flex-wrap:wrap}.rp-call-ctl{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--glass-border);background:rgba(255,255,255,.035);color:var(--ink);cursor:pointer;font-size:1rem;transition:.18s}.rp-call-ctl:hover:not(:disabled){transform:translateY(-2px);border-color:var(--gold-bright);color:var(--gold-bright);box-shadow:0 7px 24px rgba(0,0,0,.38)}.rp-call-ctl.on{background:rgba(246,215,123,.12);border-color:rgba(246,215,123,.46);color:var(--gold-bright)}.rp-call-ctl.danger{background:rgba(255,78,63,.13);border-color:rgba(255,126,110,.35);color:var(--bad)}.rp-call-ctl:disabled{opacity:.32;cursor:not-allowed}.rp-call-start{width:auto;border-radius:100px;padding:0 1.15rem;font-family:var(--mono);font-size:.61rem;text-transform:uppercase;letter-spacing:.08em;background:linear-gradient(135deg,#ffe59a,#b77b16);color:#201401;border-color:#ffdc7d;font-weight:700}\n.rp-call-chat{display:flex;flex-direction:column;min-height:0}.rp-call-chat-head{display:flex;align-items:center;gap:.45rem;padding-bottom:.65rem;border-bottom:1px solid var(--glass-border)}.rp-call-chat-head b{font-family:var(--display);font-size:.94rem;color:var(--gold-pale)}.rp-call-chat-head button{margin-left:auto;background:none;border:0;color:var(--muted);font-family:var(--mono);font-size:.55rem;cursor:pointer}.rp-call-chat-head button:hover{color:var(--gold-bright)}\n.rp-call-transcript{height:500px;overflow-y:auto;display:flex;flex-direction:column;gap:.55rem;padding:.7rem .15rem;scrollbar-color:var(--gold-deep) transparent}.rp-call-transcript.collapsed{display:none}\n.rp-turn{max-width:90%;border:1px solid var(--glass-border);border-radius:14px;padding:.6rem .7rem;font-size:.71rem;line-height:1.5;background:rgba(255,255,255,.025);box-shadow:0 8px 22px rgba(0,0,0,.2)}.rp-turn.user{align-self:flex-end;border-color:rgba(246,215,123,.22);background:linear-gradient(145deg,rgba(201,150,46,.13),rgba(255,255,255,.025));border-bottom-right-radius:4px}.rp-turn.assistant{align-self:flex-start;border-color:rgba(246,215,123,.38);border-bottom-left-radius:4px}.rp-turn .who{font-family:var(--mono);font-size:.5rem;text-transform:uppercase;letter-spacing:.1em;color:var(--gold);margin-bottom:.2rem}\n.rp-call-compose{display:flex;gap:.4rem;margin-top:auto;padding-top:.65rem;border-top:1px solid var(--glass-border)}.rp-call-compose input{flex:1;min-width:0;background:#0c0a07;border:1px solid var(--glass-border);color:var(--ink);border-radius:11px;padding:.58rem .65rem;font-size:.7rem}.rp-call-compose input:focus{outline:none;border-color:var(--gold-bright);box-shadow:0 0 0 3px rgba(246,215,123,.08)}.rp-call-compose button{width:38px;border-radius:11px;border:1px solid rgba(246,215,123,.38);background:rgba(246,215,123,.1);color:var(--gold-bright);cursor:pointer}\n.rp-call-event{min-height:42px;margin-top:.65rem;padding:.5rem .6rem;border:1px solid var(--glass-border);border-radius:10px;background:rgba(0,0,0,.22);font-family:var(--mono);font-size:.54rem;color:var(--muted);line-height:1.45}.rp-call-event b{color:var(--gold-bright)}\n.rp-detail-hero{display:flex;align-items:center;gap:.8rem;padding:.6rem;border:1px solid var(--glass-border);border-radius:12px;background:var(--panel-2);margin-bottom:.8rem;}\n.rp-detail-hero img{width:72px;height:72px;object-fit:contain;}\n.rp-pipeline{display:flex;flex-direction:column;gap:.3rem;margin-top:.5rem;}\n.rp-pipeline-row{display:grid;grid-template-columns:76px 125px 70px 1fr;gap:.5rem;padding:.38rem .5rem;border-left:3px solid var(--glass-border);background:var(--panel-2);border-radius:5px;font-family:var(--mono);font-size:.64rem;}\n.rp-pipeline-row.ok{border-left-color:var(--ok)} .rp-pipeline-row.failed,.rp-pipeline-row.blocked{border-left-color:var(--bad)}\n.node .node-hero{display:flex;align-items:center;gap:.65rem;margin:.45rem 0 .55rem;}\n.node .node-avatar{width:68px;height:68px;object-fit:contain;flex:none;filter:drop-shadow(0 7px 12px rgba(0,0,0,.3));}\n.node .node-health{font-family:var(--mono);font-size:.61rem;line-height:1.45;color:var(--muted);}\n.node .node-health b{color:var(--ink);}\n@media(max-width:1150px){.rp-call-grid{grid-template-columns:250px minmax(360px,1fr)}.rp-call-chat{grid-column:1/-1;min-height:390px}.rp-call-transcript{height:280px}}\n@media(max-width:760px){.rp-call-shell{border-radius:15px}.rp-call-grid{display:block}.rp-call-rail,.rp-call-stage,.rp-call-chat{border-bottom:1px solid var(--glass-border)}.rp-call-stage{min-height:590px}.rp-call-top .session{display:none}.rp-call-visual{width:min(285px,78vw)}.rp-call-transcript{height:320px}.rp-pipeline-row{grid-template-columns:62px 105px 58px 1fr}}\n\n/* \u2500\u2500 TTS voice picker (used inside the generic modal) \u2500\u2500 */\n.rp-voice-picker{display:flex;flex-direction:column;gap:0.6rem;min-height:320px;}\n.rp-voice-picker-toolbar{display:flex;gap:0.4rem;align-items:stretch;}\n.rp-voice-picker-toolbar input[type=search]{flex:1;min-width:0;font-family:var(--mono);font-size:0.78rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:8px;padding:0.5rem 0.7rem;}\n.rp-voice-picker-toolbar input[type=search]: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-voice-picker-status{font-size:0.66rem;color:var(--muted);display:flex;align-items:center;gap:0.5rem;min-height:1.1em;}\n.rp-voice-picker-status.busy{color:var(--gold-bright);}\n.rp-voice-picker-status.bad{color:var(--bad);}\n.rp-voice-picker-status.warn{color:var(--warn,#e0a23a);}\n.rp-voice-picker-status .rp-mono{font-family:var(--mono);}\n.rp-voice-picker-status .rp-spinner{display:inline-block;width:0.8em;height:0.8em;border:2px solid color-mix(in srgb,var(--gold-bright) 30%,transparent);border-top-color:var(--gold-bright);border-radius:50%;animation:rp-vp-spin 0.8s linear infinite;margin-right:0.25rem;}\n@keyframes rp-vp-spin{to{transform:rotate(360deg);}}\n.rp-voice-picker-results{display:flex;flex-direction:column;gap:0.7rem;max-height:55vh;overflow-y:auto;padding-right:0.25rem;}\n.rp-voice-picker-empty{color:var(--muted);font-style:italic;padding:1rem;text-align:center;border:1px dashed var(--glass-border);border-radius:8px;}\n.rp-vp-group{display:flex;flex-direction:column;gap:0.3rem;}\n.rp-vp-group-head{font-family:var(--mono);font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;padding:0.1rem 0.2rem;}\n.rp-vp-item{display:block;text-align:left;width:100%;background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;cursor:pointer;color:var(--ink);font-family:var(--mono);transition:border-color .12s, background .12s, transform .04s;}\n.rp-vp-item:hover{border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 6%,var(--panel-solid));}\n.rp-vp-item:active{transform:translateY(1px);}\n.rp-vp-item:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-vp-row1{display:flex;align-items:baseline;gap:0.5rem;justify-content:space-between;}\n.rp-vp-name{font-size:0.78rem;font-weight:600;color:var(--ink);}\n.rp-vp-meta{font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;flex:none;}\n.rp-vp-id{font-size:0.62rem;color:var(--muted);margin-top:0.1rem;word-break:break-all;}\n.rp-vp-desc{font-size:0.64rem;color:var(--muted);margin-top:0.2rem;line-height:1.4;}\n.rp-voice-picker-foot{font-size:0.6rem;color:var(--muted);text-align:center;padding-top:0.2rem;border-top:1px solid var(--glass-border);}\n.rp-voice-picker-foot .rp-muted{color:var(--muted);}\n\n/* Character and voice configuration workspace */\n.rp-config-workspace{grid-column:1/-1;display:grid;gap:1rem;}\n.rp-config-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;padding:1rem 1.1rem;border:1px solid var(--border-strong);border-radius:18px;background:radial-gradient(circle at 8% 0,rgba(246,215,123,.14),transparent 34%),linear-gradient(135deg,rgba(39,31,17,.82),rgba(8,7,5,.72));box-shadow:inset 0 1px 0 rgba(255,238,194,.1),0 18px 50px rgba(0,0,0,.28);}\n.rp-config-hero h3{font-family:var(--display);font-size:1.25rem;color:var(--gold-pale);margin:0;}\n.rp-config-hero p{max-width:72ch;margin:.25rem 0 0;color:var(--muted);font-size:.68rem;line-height:1.55;}\n.rp-config-hero-actions,.rp-config-toolbar,.rp-config-actions{display:flex;align-items:center;gap:.45rem;flex-wrap:wrap;}\n.rp-config-columns{display:grid;grid-template-columns:1fr 1fr;gap:1rem;align-items:start;}\n.rp-config-library{min-width:0;padding:.9rem;border:1px solid var(--glass-border);border-radius:18px;background:linear-gradient(145deg,rgba(31,26,18,.62),rgba(9,8,6,.7));box-shadow:inset 0 1px 0 var(--glass-hi),0 14px 44px rgba(0,0,0,.24);}\n.rp-config-library-head{display:flex;align-items:flex-start;gap:.7rem;margin-bottom:.7rem;}\n.rp-config-library-head .icon{width:38px;height:38px;display:grid;place-items:center;border-radius:11px;background:rgba(246,215,123,.1);border:1px solid rgba(246,215,123,.22);color:var(--gold-bright);font-size:1rem;flex:none;}\n.rp-config-library-head h4{font-family:var(--display);font-size:1rem;color:var(--gold-pale);margin:0;}\n.rp-config-library-head p{font-size:.58rem;color:var(--muted);margin:.1rem 0 0;line-height:1.4;}\n.rp-config-library-head .count{margin-left:auto;font-family:var(--mono);font-size:.56rem;color:var(--gold);padding:.25rem .5rem;border:1px solid var(--glass-border);border-radius:999px;}\n.rp-config-toolbar{margin-bottom:.65rem;}.rp-config-toolbar input{flex:1;min-width:150px;}\n.rp-config-cards{display:grid;gap:.55rem;max-height:560px;overflow:auto;padding-right:.2rem;scrollbar-color:var(--gold-deep) transparent;}\n.rp-config-card{position:relative;padding:.72rem;border:1px solid var(--glass-border);border-radius:13px;background:rgba(255,255,255,.022);transition:border-color .15s,transform .15s,background .15s;cursor:pointer;overflow:hidden;}\n.rp-config-card:before{content:\"\";position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(var(--gold-pale),var(--gold-deep));opacity:.65;}\n.rp-config-card:hover{transform:translateY(-1px);border-color:rgba(246,215,123,.38);background:rgba(246,215,123,.045);}\n.rp-config-card-head{display:flex;align-items:center;gap:.55rem;min-width:0;}.rp-config-card-head img{width:46px;height:46px;object-fit:contain;filter:drop-shadow(0 8px 12px rgba(0,0,0,.45));flex:none;}\n.rp-config-card-title{min-width:0;flex:1}.rp-config-card-title b{display:block;font-family:var(--display);font-size:.9rem;color:var(--gold-pale);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rp-config-card-title small{display:block;font-family:var(--mono);font-size:.5rem;color:var(--muted);margin-top:.08rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.rp-config-badges{display:flex;gap:.3rem;flex-wrap:wrap;margin-top:.55rem}.rp-config-badge{font-family:var(--mono);font-size:.48rem;letter-spacing:.04em;text-transform:uppercase;padding:.18rem .38rem;border-radius:999px;border:1px solid var(--glass-border);color:var(--muted);background:rgba(0,0,0,.18)}.rp-config-badge.ok{color:var(--ok);border-color:rgba(87,224,156,.28)}.rp-config-badge.gold{color:var(--gold-bright);border-color:rgba(246,215,123,.28)}\n.rp-config-pipeline{display:grid;grid-template-columns:1fr auto 1fr auto 1fr;gap:.32rem;align-items:center;margin-top:.55rem;font-family:var(--mono);font-size:.53rem}.rp-config-pipeline span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:.28rem .4rem;border-radius:7px;background:rgba(0,0,0,.24);border:1px solid var(--glass-border);color:var(--ink)}.rp-config-pipeline i{font-style:normal;color:var(--gold)}\n.rp-config-summary{margin-top:.5rem;color:var(--muted);font-size:.57rem;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}\n.rp-config-card .rp-config-actions{margin-top:.6rem}.rp-config-card .rp-btn{font-size:.53rem;padding:.32rem .5rem;}\n.rp-config-editor{display:grid;gap:.8rem}.rp-config-editor-intro{display:flex;gap:.75rem;padding:.72rem;border:1px solid rgba(246,215,123,.22);border-radius:13px;background:radial-gradient(circle at 0 0,rgba(246,215,123,.12),transparent 48%),rgba(0,0,0,.18)}.rp-config-editor-intro .mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid rgba(246,215,123,.25);border-radius:12px;color:var(--gold-bright);background:rgba(246,215,123,.08);font-size:1rem;flex:none}.rp-config-editor-intro b{display:block;font-family:var(--display);font-size:.95rem;color:var(--gold-pale)}.rp-config-editor-intro span{display:block;color:var(--muted);font-size:.59rem;line-height:1.45;margin-top:.15rem}\n.rp-config-section{padding:.75rem;border:1px solid var(--glass-border);border-radius:13px;background:rgba(255,255,255,.018)}.rp-config-section-head{display:flex;align-items:center;gap:.45rem;margin-bottom:.55rem;font-family:var(--mono);font-size:.58rem;text-transform:uppercase;letter-spacing:.09em;color:var(--gold-bright)}.rp-config-section-head span{margin-left:auto;color:var(--muted);font-size:.48rem;letter-spacing:.03em;text-transform:none}.rp-config-fields{display:grid;grid-template-columns:1fr 1fr;gap:.55rem}.rp-config-fields .wide{grid-column:1/-1}.rp-config-fields textarea{width:100%;resize:vertical;min-height:110px;font-family:var(--mono);font-size:.67rem;line-height:1.5}.rp-config-checks{display:flex;gap:.8rem;flex-wrap:wrap}.rp-config-checks label{display:flex;align-items:center;gap:.35rem;color:var(--ink);font-size:.62rem}.rp-config-checks input{width:auto;margin:0;accent-color:var(--gold-bright)}\n.rp-config-editor-foot{position:sticky;bottom:-1rem;display:flex;justify-content:flex-end;gap:.45rem;padding:.7rem 0 .1rem;background:linear-gradient(transparent,var(--panel) 28%)}\n.rp-modal.config-modal{width:min(980px,100%);max-height:90vh}.rp-modal.config-modal .rp-modal-body{padding:1rem 1.1rem 1.2rem;}\n@media(max-width:900px){.rp-config-columns{grid-template-columns:1fr}.rp-config-hero{align-items:flex-start;flex-direction:column}.rp-config-cards{max-height:430px}}\n@media(max-width:620px){.rp-config-fields{grid-template-columns:1fr}.rp-config-fields .wide{grid-column:auto}.rp-config-pipeline{grid-template-columns:1fr}.rp-config-pipeline i{display:none}.rp-config-hero-actions{width:100%}.rp-config-hero-actions .rp-btn{flex:1}}\n\n/* command sheet */\n.rp-command-shell{display:grid;gap:1rem}.rp-command-env{display:grid;grid-template-columns:repeat(3,minmax(170px,1fr));gap:.65rem;padding:.85rem;border:1px solid var(--glass-border);border-radius:16px;background:var(--panel-2)}\n.rp-command-toolbar{display:flex;gap:.55rem;align-items:center;flex-wrap:wrap}.rp-command-toolbar input{flex:1;min-width:220px}.rp-command-toolbar select{width:auto;min-width:170px}\n.rp-command-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.75rem}.rp-command-card{display:flex;flex-direction:column;gap:.55rem;min-width:0;padding:.85rem;border:1px solid var(--glass-border);border-radius:16px;background:linear-gradient(145deg,rgba(31,26,18,.66),rgba(9,8,6,.76));box-shadow:inset 0 1px 0 var(--glass-hi),0 12px 32px rgba(0,0,0,.22)}\n.rp-command-head{display:flex;align-items:flex-start;justify-content:space-between;gap:.6rem}.rp-command-head b{font-family:var(--mono);font-size:.74rem;color:var(--gold-pale)}.rp-command-tag{font-family:var(--mono);font-size:.52rem;text-transform:uppercase;letter-spacing:.06em;color:var(--gold-bright);border:1px solid var(--border-strong);border-radius:100px;padding:.15rem .42rem;white-space:nowrap}.rp-command-card p{font-size:.64rem;line-height:1.5;color:var(--muted);min-height:2.9em}\n.rp-command-code{position:relative;min-height:86px;padding:.68rem .72rem;border:1px solid rgba(255,224,151,.1);border-radius:11px;background:#050504;overflow:auto}.rp-command-code code{display:block;font-family:var(--mono);font-size:.59rem;line-height:1.55;color:#f2dfab;white-space:pre-wrap;overflow-wrap:anywhere}.rp-command-actions{display:flex;gap:.4rem;align-items:center;margin-top:auto}.rp-command-actions .rp-btn:first-child{flex:1}.rp-command-empty{grid-column:1/-1;padding:2rem;text-align:center;color:var(--muted);border:1px dashed var(--glass-border);border-radius:16px}\n.rp-command-custom{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;padding:.9rem;border:1px solid var(--glass-border);border-radius:16px;background:var(--panel-2)}.rp-command-custom h4,.rp-command-custom .wide{grid-column:1/-1}.rp-command-custom h4{font-family:var(--mono);font-size:.72rem;color:var(--gold-bright)}.rp-command-custom textarea{min-height:92px;resize:vertical}.rp-command-note{font-size:.61rem;color:var(--muted)}\n@media(max-width:800px){.rp-command-env{grid-template-columns:1fr 1fr}.rp-command-grid{grid-template-columns:1fr}}@media(max-width:540px){.rp-command-env,.rp-command-custom{grid-template-columns:1fr}.rp-command-custom h4,.rp-command-custom .wide{grid-column:auto}}\n\n.rp-fleet-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:0.7rem;padding:0;}\n.rp-fleet-card{position:relative;}\n.rp-fleet-card .spark{margin-top:0.5rem;height:36px;}\n.rp-fleet-card .rp-actions{margin-top:0.6rem;}\n.rp-fleet-card .pulse{position:absolute;top:0.75rem;right:0.85rem;display:flex;align-items:center;gap:0.35rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.rp-fleet-card .pulse .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);}\n.rp-fleet-card .pulse.live .dot{background:var(--ok);box-shadow:0 0 8px var(--ok);animation:pulse 2.4s infinite;}\n.rp-fleet-card .pulse.idle .dot{background:var(--muted);}\n.rp-fleet-card .pulse.offline .dot{background:var(--bad);}\n.rp-spark{width:100%;height:36px;display:block;opacity:0.92;}\n\n.rp-setup-grid{display:grid;grid-template-columns:1fr 1fr;gap:0.9rem;}\n@media (max-width:980px){.rp-setup-grid{grid-template-columns:1fr;}}\n\n.rp-form .rp-form{margin:0.5rem 0 0;padding:0.6rem 0.75rem;}\n\n.rp-input-grow{flex:1;min-width:140px;}\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 <span class=\"rp-operator-badge set\" id=\"rp-operator-badge\" role=\"button\" tabindex=\"0\">set operator</span>\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 &amp; Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <div class=\"fleet-ops\">\n <div class=\"fleet-hero\">\n <div><div class=\"eyebrow\">Production command center</div><h1>Fleet intelligence</h1><p>Live readiness, workload, release compliance, hardware pressure, and operator actions across every RoboPark device.</p></div>\n <div class=\"fleet-hero-actions\"><button class=\"rp-btn primary\" id=\"fleet-open-park\">Open Park map</button><button class=\"rp-btn\" id=\"fleet-open-sessions\">Live sessions</button><button class=\"rp-btn\" id=\"fleet-open-incidents\">Incidents</button><button class=\"rp-btn\" id=\"fleet-refresh\">Refresh</button></div>\n </div>\n <div class=\"fleet-kpis\" id=\"fleet-kpis\"></div>\n <div class=\"fleet-insights\">\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Operational readiness</b><span id=\"fleet-readiness-meta\">live</span></div><div id=\"fleet-readiness\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Robot workload</b><span>sessions / triggers</span></div><div class=\"fleet-bars\" id=\"fleet-workload\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Needs attention</b><span id=\"fleet-attention-meta\">0 issues</span></div><div class=\"fleet-attention\" id=\"fleet-attention\"></div></div>\n <div class=\"fleet-panel fleet-release\" id=\"fleet-release\"></div>\n </div>\n </div>\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 \u00B7 <b>ctrl+drag a robot</b> to reposition (persists)</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=\"pk-camera-ops\" id=\"pk-camera-ops\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Fullscreen robot camera operations view\">\n <div class=\"pk-camera-ops-head\">\n <img class=\"avatar\" id=\"pk-camera-ops-avatar\" alt=\"\">\n <div><div class=\"title\" id=\"pk-camera-ops-name\">Robot camera</div><div class=\"sub\" id=\"pk-camera-ops-sub\">RoboVision mesh relay</div></div>\n <div class=\"pk-camera-ops-actions\"><button class=\"rp-btn primary\" id=\"pk-camera-ops-trigger\">Simulate motion</button><button class=\"rp-btn\" id=\"pk-camera-ops-production\">Production mode</button><button class=\"rp-btn danger\" id=\"pk-camera-ops-end\">End session</button><button class=\"rp-btn\" id=\"pk-camera-ops-refresh\">Refresh feed</button><button class=\"rp-btn\" id=\"pk-camera-ops-native\">Browser fullscreen</button><button class=\"rp-btn\" id=\"pk-camera-ops-close\">Close</button></div>\n </div>\n <div class=\"pk-camera-ops-grid\">\n <div class=\"pk-camera-stage\" id=\"pk-camera-stage\">\n <img id=\"pk-camera-ops-img\" alt=\"Live robot camera feed\">\n <div class=\"empty\" id=\"pk-camera-ops-empty\">opening RoboVision camera...</div>\n <div class=\"pk-camera-rec\"><i></i><span id=\"pk-camera-ops-rec\">REC / CONNECTING</span></div>\n <div class=\"pk-camera-clock\" id=\"pk-camera-ops-clock\">--:--:--</div>\n <div class=\"pk-camera-overlay\" id=\"pk-camera-ops-overlay\"></div>\n </div>\n <aside class=\"pk-camera-side\">\n <div class=\"section\"><h3>Live insights</h3><div class=\"pk-camera-insight\" id=\"pk-camera-ops-insights\"></div></div>\n <div class=\"section\"><h3>Conversation pipeline</h3><div class=\"pk-camera-pipeline\" id=\"pk-camera-ops-pipeline\"></div></div>\n <div class=\"section\"><h3>Hardware and media</h3><div class=\"pk-camera-insight\" id=\"pk-camera-ops-hardware\"></div></div>\n <div class=\"section\"><h3>Operator note</h3><div class=\"pk-note\">Video is relayed directly from the robot's RoboVision MJPEG service through the authenticated mesh. Voice remains on LiveKit, avoiding camera ownership conflicts.</div></div>\n </aside>\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 &amp; 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 <!-- Sub-tab nav bar (sticky, just under main tabs) -->\n <div class=\"rp-subtabs\" id=\"rp-subtabs\" role=\"tablist\">\n <button class=\"rp-subtab active\" data-sub=\"overview\" id=\"rpsbtn-overview\" role=\"tab\" aria-selected=\"true\">Overview</button>\n <button class=\"rp-subtab\" data-sub=\"incidents\" id=\"rpsbtn-incidents\" role=\"tab\" aria-selected=\"false\">Incidents <span class=\"badge\" id=\"rps-incidents-badge\">0</span></button>\n <button class=\"rp-subtab\" data-sub=\"live\" id=\"rpsbtn-live\" role=\"tab\" aria-selected=\"false\">Live Sessions <span class=\"badge\" id=\"rps-live-badge\">0</span></button>\n <button class=\"rp-subtab\" data-sub=\"voice\" id=\"rpsbtn-voice\" role=\"tab\" aria-selected=\"false\">Voice Call</button>\n <button class=\"rp-subtab\" data-sub=\"history\" id=\"rpsbtn-history\" role=\"tab\" aria-selected=\"false\">History</button>\n <button class=\"rp-subtab\" data-sub=\"audit\" id=\"rpsbtn-audit\" role=\"tab\" aria-selected=\"false\">Audit Log</button>\n <button class=\"rp-subtab\" data-sub=\"fleet\" id=\"rpsbtn-fleet\" role=\"tab\" aria-selected=\"false\">Fleet</button>\n <button class=\"rp-subtab\" data-sub=\"config\" id=\"rpsbtn-config\" role=\"tab\" aria-selected=\"false\">Characters &amp; Voice</button>\n <button class=\"rp-subtab\" data-sub=\"commands\" id=\"rpsbtn-commands\" role=\"tab\" aria-selected=\"false\">Commands</button>\n <button class=\"rp-subtab\" data-sub=\"setup\" id=\"rpsbtn-setup\" role=\"tab\" aria-selected=\"false\">Setup</button>\n </div>\n\n <!-- \u2550\u2550\u2550 Overview \u2550\u2550\u2550 -->\n <div class=\"rp-sub active\" id=\"rp-sub-overview\">\n <div class=\"rp-kpi-grid\" id=\"rp-overview-kpis\"></div>\n <div class=\"rp-mini-chart-wrap\">\n <h4>Fleet health \u00B7 sessions (last 60 min) <span class=\"now\" id=\"rp-overview-now\">\u2014</span></h4>\n <canvas class=\"rp-mini-chart\" id=\"rp-overview-chart\" aria-label=\"Sessions over time\"></canvas>\n </div>\n <div class=\"rp-section-h\">Top robots by activity <span class=\"meta\" id=\"rp-overview-top-meta\"></span></div>\n <div class=\"rp-top3\" id=\"rp-overview-top\"></div>\n <div class=\"rp-section-h\">Recent activity <span class=\"meta\" id=\"rp-overview-stream-meta\">last 20</span></div>\n <div class=\"rp-stream\" id=\"rp-overview-stream\"></div>\n </div>\n\n <!-- \u2550\u2550\u2550 Live Sessions \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-live\">\n <div class=\"rp-filterbar\">\n <div class=\"rp-field\"><label>Search</label><input id=\"rp-live-search\" placeholder=\"session id, robot\u2026\"></div>\n <div class=\"rp-field\"><label>Robot</label><select id=\"rp-live-robot\"><option value=\"\">all robots</option></select></div>\n <label class=\"rp-btn\" style=\"display:inline-flex;align-items:center;gap:0.35rem;cursor:pointer;\">\n <input type=\"checkbox\" id=\"rp-live-autoref\" checked style=\"width:auto;margin:0;accent-color:var(--gold-bright);\"> auto-refresh\n </label>\n <button class=\"rp-btn\" id=\"rp-live-refresh\">\u21BB Refresh now</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-live-wrap\">\n <table class=\"rp-sess-table\" id=\"rp-live-table\">\n <thead><tr>\n <th>Robot</th><th>Session</th><th>Started</th><th>Duration</th><th>End reason</th><th>Actions</th>\n </tr></thead>\n <tbody id=\"rp-live-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-tbl-empty\" id=\"rp-live-empty\" style=\"display:none;\">No active sessions right now \u2014 sessions appear here the moment a robot joins a LiveKit room.</div>\n </div>\n\n <!-- \u2550\u2550\u2550 History \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-voice\">\n <div class=\"rp-call-shell\">\n <div class=\"rp-call-top\">\n <div><div class=\"eyebrow\">RoboVoice operator channel</div><div class=\"title\">Royal Voice Studio</div></div>\n <div class=\"session\" id=\"rp-call-session\">No active session</div>\n </div>\n <div class=\"rp-call-grid\">\n <aside class=\"rp-call-rail\">\n <div class=\"rp-call-character\" id=\"rp-call-character\"><div><div class=\"name\">Choose a character</div><div class=\"meta\">Preset and voice stack are frozen into session history.</div></div></div>\n <div class=\"rp-call-card\">\n <div class=\"rp-call-device\"><label>Character preset</label><select id=\"rp-call-character-select\"><option value=\"\">select character...</option></select></div>\n <div class=\"rp-call-device\"><label>Voice stack</label><select id=\"rp-call-stack-select\"><option value=\"\">character default</option></select></div>\n <div class=\"rp-config-actions\" style=\"margin-top:.6rem\"><button class=\"rp-btn\" id=\"rp-call-edit-character\">Edit character</button><button class=\"rp-btn\" id=\"rp-call-new-character\">New</button><button class=\"rp-btn\" id=\"rp-call-edit-stack\">Edit stack</button><button class=\"rp-btn\" id=\"rp-call-new-stack\">New stack</button></div>\n </div>\n <div class=\"rp-call-card\">\n <div class=\"rp-section-h\" style=\"margin:0 0 .5rem\">Voice pipeline <span class=\"meta\">selected stack</span></div>\n <div class=\"rp-call-service-grid\" id=\"rp-call-services\">\n <div class=\"rp-call-service\"><span class=\"ico\">STT</span><div><b>Speech recognition</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>\n <div class=\"rp-call-service\"><span class=\"ico\">LLM</span><div><b>Language model</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>\n <div class=\"rp-call-service\"><span class=\"ico\">TTS</span><div><b>Voice synthesis</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>\n </div>\n </div>\n <div class=\"rp-call-card\">\n <div class=\"rp-section-h\" style=\"margin:0 0 .5rem\">Browser devices <span class=\"meta\" id=\"rp-call-device-status\">permission required</span></div>\n <div class=\"rp-call-device\"><label>Microphone</label><select id=\"rp-call-mic-device\"><option value=\"\">system default</option></select></div>\n <div class=\"rp-call-device\"><label>Camera</label><select id=\"rp-call-cam-device\"><option value=\"\">system default</option></select></div>\n <div class=\"rp-call-device\"><label>Speaker output</label><select id=\"rp-call-speaker-device\"><option value=\"\">system default</option></select></div>\n <div class=\"rp-call-device-note\" id=\"rp-call-device-note\">Enable media access to reveal the actual microphone, camera, and speaker names available to this browser.</div>\n <div class=\"rp-call-device-actions\"><button class=\"rp-btn primary\" id=\"rp-call-refresh-devices\">Enable media access</button><button class=\"rp-btn\" id=\"rp-call-test-mic\">Test microphone</button></div>\n </div>\n </aside>\n <main class=\"rp-call-stage\">\n <div class=\"rp-call-media\" id=\"rp-call-media\"><video id=\"rp-call-video\" autoplay muted playsinline></video><span class=\"tag\" id=\"rp-call-media-tag\">CAMERA</span></div>\n <div class=\"rp-call-visual\" id=\"rp-call-visual\">\n <div class=\"rp-call-ring\"></div>\n <img class=\"rp-call-avatar\" id=\"rp-call-avatar\" alt=\"\" onerror=\"this.removeAttribute('src')\">\n <div class=\"rp-call-avatar-fallback\" id=\"rp-call-avatar-fallback\" aria-hidden=\"true\">RP</div>\n <div class=\"rp-call-bars\" id=\"rp-call-bars\"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>\n </div>\n <div class=\"rp-call-agent-state\"><strong id=\"rp-call-agent-state\">Ready when you are</strong><span id=\"rp-call-agent-detail\">select character and begin</span></div>\n <div class=\"rp-call-state\"><span class=\"rp-call-orb\" id=\"rp-call-orb\"></span><span id=\"rp-call-status\">ready to start</span><span class=\"mono\" id=\"rp-call-timer\">00:00</span></div>\n <div class=\"rp-call-latency\">\n <div><b id=\"rp-call-lat-total\">--</b><span>Total response</span></div>\n <div><b id=\"rp-call-lat-stt\">--</b><span>STT to LLM</span></div>\n <div><b id=\"rp-call-lat-tts\">--</b><span>LLM to TTS</span></div>\n </div>\n <div class=\"rp-call-controls\">\n <button class=\"rp-call-ctl rp-call-start\" id=\"rp-call-start\">Start call</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-mute\" title=\"Microphone\" disabled>MIC</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-camera\" title=\"Camera\" disabled>CAM</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-screen\" title=\"Share screen\" disabled>SCR</button>\n <button class=\"rp-call-ctl\" id=\"rp-call-chat-toggle\" title=\"Toggle transcript\">TXT</button>\n <button class=\"rp-call-ctl danger\" id=\"rp-call-end\" title=\"End call\" disabled>END</button>\n </div>\n <div class=\"rp-call-event\" id=\"rp-call-event\"><b>System</b> Voice pipeline is standing by.</div>\n </main>\n <section class=\"rp-call-chat\">\n <div class=\"rp-call-chat-head\"><b>Conversation</b><span class=\"chip\" id=\"rp-call-message-count\">0 messages</span><button id=\"rp-call-collapse\">collapse transcript</button></div>\n <div class=\"rp-call-transcript\" id=\"rp-call-transcript\"><div class=\"rp-tbl-empty\">Your live transcript and typed messages appear here.</div></div>\n <form class=\"rp-call-compose\" id=\"rp-call-compose\"><input id=\"rp-call-message\" autocomplete=\"off\" placeholder=\"Type a message to the character...\" disabled><button type=\"submit\" id=\"rp-call-send\" disabled>SEND</button></form>\n <div id=\"rp-call-audio\" hidden></div>\n </section>\n </div>\n </div>\n </div>\n\n <div class=\"rp-sub\" id=\"rp-sub-history\">\n <div class=\"rp-filterbar\">\n <div class=\"rp-field\"><label>Search</label><input id=\"rp-hist-search\" placeholder=\"session id, robot\u2026\"></div>\n <div class=\"rp-field\"><label>Robot</label><select id=\"rp-hist-robot\"><option value=\"\">all robots</option></select></div>\n <div class=\"rp-field\"><label>End reason</label><select id=\"rp-hist-reason\">\n <option value=\"\">any</option>\n <option value=\"operator\">operator</option>\n <option value=\"silence\">silence</option>\n <option value=\"error\">error</option>\n <option value=\"timeout\">timeout</option>\n <option value=\"completed\">completed</option>\n <option value=\"unknown\">unknown</option>\n </select></div>\n <div class=\"rp-field\"><label>Range</label><select id=\"rp-hist-range\">\n <option value=\"24h\">last 24h</option>\n <option value=\"7d\" selected>last 7 days</option>\n <option value=\"30d\">last 30 days</option>\n <option value=\"all\">all</option>\n </select></div>\n <button class=\"rp-btn\" id=\"rp-hist-refresh\">\u21BB Refresh</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-hist-wrap\">\n <table class=\"rp-history-table\" id=\"rp-hist-table\">\n <thead><tr>\n <th>Session</th><th>Robot</th><th>Started</th><th>Ended</th><th>Duration</th><th>End reason</th><th>Turns</th><th>Pipeline</th><th>Latency</th><th>Actions</th>\n </tr></thead>\n <tbody id=\"rp-hist-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-tbl-empty\" id=\"rp-hist-empty\" style=\"display:none;\">No sessions match the current filters.</div>\n <div class=\"rp-tbl-pager\" id=\"rp-hist-pager\">\n <span id=\"rp-hist-info\">\u2014</span>\n <div>\n <button class=\"rp-btn\" id=\"rp-hist-prev\">\u2039 Prev</button>\n <button class=\"rp-btn\" id=\"rp-hist-next\">Next \u203A</button>\n </div>\n </div>\n </div>\n\n <!-- \u2550\u2550\u2550 Audit Log \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-audit\">\n <div class=\"rp-filterbar\" style=\"gap:0.4rem;\">\n <span class=\"rp-cat-chip active\" data-cat=\"\" id=\"rp-audit-cat-all\">All</span>\n <span class=\"rp-cat-chip\" data-cat=\"robot\" id=\"rp-audit-cat-robot\">\uD83E\uDD16 Robot</span>\n <span class=\"rp-cat-chip\" data-cat=\"device\" id=\"rp-audit-cat-device\">\uD83D\uDCE1 Device</span>\n <span class=\"rp-cat-chip\" data-cat=\"session\" id=\"rp-audit-cat-session\">\uD83C\uDF99 Session</span>\n <span class=\"rp-cat-chip\" data-cat=\"server\" id=\"rp-audit-cat-server\">\uD83D\uDDA5 Server</span>\n <span class=\"rp-cat-chip\" data-cat=\"voice\" id=\"rp-audit-cat-voice\">\uD83D\uDDE3 Voice</span>\n <span class=\"rp-cat-chip\" data-cat=\"character\" id=\"rp-audit-cat-character\">\uD83C\uDFAD Character</span>\n <div style=\"flex:1;\"></div>\n <span class=\"rp-actor-chip\" id=\"rp-audit-byme\" data-actor=\"\" title=\"Filter the audit log to actions attributed to the current operator\">\uD83D\uDC64 by me</span>\n <button class=\"rp-btn\" id=\"rp-audit-refresh\">\u21BB Refresh</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-audit-wrap\">\n <table class=\"rp-audit-table\" id=\"rp-audit-table\">\n <thead><tr>\n <th>Time</th><th>Category</th><th>Action</th><th>Target</th><th>Details</th><th>Operator</th>\n </tr></thead>\n <tbody id=\"rp-audit-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-tbl-empty\" id=\"rp-audit-empty\" style=\"display:none;\">No audit events match the current filter.</div>\n </div>\n\n <!-- \u2550\u2550\u2550 Incidents \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-incidents\">\n <div class=\"rp-section-h\">Liveness <span class=\"meta\" id=\"rp-incidents-meta\">polling mesh + scheduler + devices</span></div>\n <div class=\"rp-incidents-kpis\" id=\"rp-incidents-kpis\">\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-mesh\">\n <div class=\"lab\">mesh <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-mesh-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-mesh-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-mesh-meta\">awaiting first poll\u2026</div>\n </div>\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-sched\">\n <div class=\"lab\">scheduler <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-sched-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-sched-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-sched-meta\">awaiting first poll\u2026</div>\n </div>\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-cam\">\n <div class=\"lab\">cameras <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-cam-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-cam-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-cam-meta\">awaiting first session\u2026</div>\n </div>\n <div class=\"rp-inc-kpi\" id=\"rp-inc-kpi-prod\">\n <div class=\"lab\">production mode <span class=\"pk-kpi-dot\" id=\"rp-inc-kpi-prod-dot\"></span></div>\n <div class=\"v\" id=\"rp-inc-kpi-prod-v\">\u2014</div>\n <div class=\"meta\" id=\"rp-inc-kpi-prod-meta\">awaiting first poll\u2026</div>\n </div>\n </div>\n\n <div class=\"rp-section-h\" style=\"margin-top:1rem;\">Active incidents <span class=\"meta\" id=\"rp-incidents-count\">0</span></div>\n <div class=\"rp-filterbar\" style=\"gap:0.4rem;\">\n <span class=\"rp-cat-chip active\" data-inc-sev=\"\" id=\"rp-inc-sev-all\">All</span>\n <span class=\"rp-cat-chip\" data-inc-sev=\"bad\" id=\"rp-inc-sev-bad\">\u2717 critical</span>\n <span class=\"rp-cat-chip\" data-inc-sev=\"warn\" id=\"rp-inc-sev-warn\">\u26A0 warning</span>\n <span class=\"rp-cat-chip\" data-inc-sev=\"info\" id=\"rp-inc-sev-info\">\u00B7 info</span>\n <div style=\"flex:1\"></div>\n <button class=\"rp-btn\" id=\"rp-inc-refresh\">\u21BB Refresh</button>\n </div>\n <div class=\"rp-tbl-wrap\" id=\"rp-inc-wrap\" style=\"display:none;\">\n <table class=\"rp-inc-table\" id=\"rp-inc-table\">\n <thead><tr>\n <th>Severity</th><th>Robot</th><th>Readiness</th><th>First seen</th><th>Duration</th><th>Message</th><th></th>\n </tr></thead>\n <tbody id=\"rp-inc-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-empty\" id=\"rp-inc-empty\">no active incidents</div>\n\n <div class=\"rp-section-h\" style=\"margin-top:1rem;\">Acknowledged <span class=\"meta\" id=\"rp-inc-ack-count\">0</span></div>\n <div class=\"rp-tbl-wrap\" id=\"rp-inc-ack-wrap\" style=\"display:none;\">\n <table class=\"rp-inc-table\" id=\"rp-inc-ack-table\">\n <thead><tr>\n <th>Acked at</th><th>By</th><th>Robot</th><th>Readiness</th><th>Message</th><th></th>\n </tr></thead>\n <tbody id=\"rp-inc-ack-tbody\"></tbody>\n </table>\n </div>\n <div class=\"rp-empty\" id=\"rp-inc-ack-empty\">no acknowledged incidents</div>\n <div class=\"pk-note\" style=\"margin-top:0.5rem;\">incidents are derived from /api/telemetry readiness codes. Acknowledgments are stored in your browser (localStorage) so multiple operators on different machines each get their own list. When a robot's readiness returns to \"ready\" or \"in_session\" the incident auto-closes.</div>\n </div>\n\n <!-- \u2550\u2550\u2550 Fleet \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-fleet\">\n <div class=\"fleet-ops\" style=\"margin-bottom:1rem\">\n <div class=\"fleet-kpis\" id=\"rp-fleet-kpis\"></div>\n <div class=\"fleet-insights\">\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Operational readiness</b><span>scheduler</span></div><div id=\"rp-fleet-readiness\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Robot workload</b><span>sessions / triggers</span></div><div class=\"fleet-bars\" id=\"rp-fleet-workload\"></div></div>\n <div class=\"fleet-panel\"><div class=\"fleet-panel-head\"><b>Needs attention</b><span id=\"rp-fleet-attention-meta\">0 issues</span></div><div class=\"fleet-attention\" id=\"rp-fleet-attention\"></div></div>\n </div>\n </div>\n <div class=\"rp-section-h\">Robots <span class=\"meta\" id=\"rp-fleet-meta\">live + idle + offline</span></div>\n <div class=\"rp-fleet-grid\" id=\"rp-grid\"></div>\n </div>\n\n <!-- \u2550\u2550\u2550 Setup \u2550\u2550\u2550 -->\n <div class=\"rp-sub\" id=\"rp-sub-setup\">\n <div class=\"rp-form\" id=\"rp-provider-secrets\">\n <h3>Voice provider secret</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-field\" style=\"flex:1\"><label>ElevenLabs API key</label><input type=\"password\" id=\"rp-elevenlabs-key\" autocomplete=\"new-password\" placeholder=\"stored only on the scheduler\"></div>\n <button class=\"rp-btn primary\" id=\"rp-elevenlabs-save\" style=\"height:fit-content\">Save secret</button>\n </div>\n <div class=\"rp-autonote\" id=\"rp-elevenlabs-status\">not configured \u00B7 cached voice speaker tests require this key</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 &amp; 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 <div class=\"rp-field\"><label>Robot network</label><select id=\"rb-network\"><option value=\"lan\">LAN (onsite default)</option><option value=\"tailscale\">Tailscale</option></select></div>\n <div class=\"rp-field\"><label>Mesh token</label><input id=\"rb-token\" spellcheck=\"false\" autocomplete=\"off\" placeholder=\"required in generated command\"></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>complete production one-liner \u2014 run this ON THE ROBOT</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>Site</label><input id=\"rb-site\" placeholder=\"Tel Aviv\"></div>\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 <!-- Local runtime / Docker manager -->\n <div class=\"rp-form\" id=\"rp-runtime\" style=\"grid-template-columns:1fr;\">\n <h3>Runtime health \u00B7 scheduler \u00B7 LiveKit \u00B7 Docker</h3>\n <div class=\"rp-quickrow\">\n <div class=\"rp-autonote\" id=\"rp-runtime-summary\" style=\"flex:1;margin:0;\">not checked</div>\n <button class=\"rp-btn\" id=\"rp-runtime-refresh\">\u21BB Refresh health</button>\n </div>\n <div class=\"rp-tbl-wrap\"><table class=\"rp-history-table\"><thead><tr><th>Container</th><th>State</th><th>Image</th><th>Actions</th></tr></thead><tbody id=\"rp-runtime-containers\"></tbody></table></div>\n <div class=\"rp-autonote\" id=\"rp-runtime-note\">Docker controls are protected by the hub token. Logs are limited to the latest 100 lines.</div>\n </div>\n\n </div>\n\n <!-- Command sheet -->\n <div class=\"rp-sub\" id=\"rp-sub-commands\">\n <div class=\"rp-command-shell\">\n <div class=\"rp-config-hero\">\n <div><h3>RoboPark Command Sheet</h3><p>Production one-liners for the hub, LAN and Tailscale robots, health checks, recovery, Docker, LiveKit, and package operations. Edit the environment once; every generated command below updates immediately.</p></div>\n <div class=\"rp-config-hero-actions\"><button class=\"rp-btn\" id=\"rp-command-reset\">Reset environment</button><button class=\"rp-btn primary\" id=\"rp-command-copy-all\">Copy visible commands</button></div>\n </div>\n <div class=\"rp-command-env\">\n <div class=\"rp-field\"><label>Hub LAN IP</label><input id=\"rp-command-lan\" value=\"192.168.0.9\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Hub Tailscale IP / DNS</label><input id=\"rp-command-tail\" value=\"100.84.147.24\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Site</label><input id=\"rp-command-site\" value=\"Tel Aviv\"></div>\n <div class=\"rp-field\"><label>Robot name</label><input id=\"rp-command-robot\" value=\"bmw\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Enrollment token</label><input id=\"rp-command-enrollment\" placeholder=\"paste robot enrollment token\" spellcheck=\"false\"></div>\n <div class=\"rp-field\"><label>Mesh token</label><input id=\"rp-command-token\" spellcheck=\"false\"></div>\n </div>\n <div class=\"rp-command-toolbar\">\n <input type=\"search\" id=\"rp-command-search\" placeholder=\"Search setup, logs, camera, Docker, LiveKit, npm...\">\n <select id=\"rp-command-category\"><option value=\"\">All categories</option><option>Provision</option><option>Health</option><option>Recovery</option><option>Media</option><option>Packages</option><option>Custom</option></select>\n <span class=\"rp-command-note\" id=\"rp-command-count\"></span>\n </div>\n <div class=\"rp-command-grid\" id=\"rp-command-grid\"></div>\n <form class=\"rp-command-custom\" id=\"rp-command-custom-form\">\n <h4>Add a persistent command</h4>\n <div class=\"rp-field\"><label>Title</label><input id=\"rp-command-custom-title\" required placeholder=\"Restart camera service\"></div>\n <div class=\"rp-field\"><label>Category</label><input id=\"rp-command-custom-category\" value=\"Custom\" placeholder=\"Custom\"></div>\n <div class=\"rp-field wide\"><label>Simple explanation</label><input id=\"rp-command-custom-description\" placeholder=\"When to use this and what success looks like\"></div>\n <div class=\"rp-field wide\"><label>Command</label><textarea id=\"rp-command-custom-command\" required spellcheck=\"false\" placeholder=\"Paste a safe command or one-liner\"></textarea></div>\n <div class=\"wide\"><button class=\"rp-btn primary\" type=\"submit\">Save command to this browser</button> <span class=\"rp-command-note\">Custom commands persist in localStorage on this operator browser.</span></div>\n </form>\n </div>\n </div>\n\n <!-- Character + voice management -->\n <div class=\"rp-sub\" id=\"rp-sub-config\">\n <div class=\"rp-config-workspace\" id=\"rp-templates\">\n <div class=\"rp-config-hero\">\n <div><h3>Character &amp; Voice Studio</h3><p>Build reusable personas and production voice pipelines in one place. Changes are applied centrally and become available immediately in Voice Studio, Fleet, and every Park robot panel.</p></div>\n <div class=\"rp-config-hero-actions\"><button class=\"rp-btn primary\" id=\"rp-new-character\">New character</button><button class=\"rp-btn\" id=\"rp-new-voice-stack\">New voice stack</button></div>\n </div>\n <div class=\"rp-config-columns\">\n <section class=\"rp-config-library\">\n <div class=\"rp-config-library-head\"><span class=\"icon\">VOX</span><div><h4>Voice stacks</h4><p>Speech recognition, language model, synthesis, turn-taking, and wake behavior.</p></div><span class=\"count\" id=\"rp-voice-count\">0 stacks</span></div>\n <div class=\"rp-config-toolbar\"><input id=\"rp-voice-search\" type=\"search\" placeholder=\"Search provider, model, voice, or stack\u2026\"><button class=\"rp-btn primary\" id=\"rp-addvoice\">Create</button></div>\n <div class=\"rp-config-cards\" id=\"rp-voice-list\"></div>\n <input id=\"rp-newvoice-name\" type=\"hidden\"><div id=\"rp-voice-edit\" style=\"display:none\"></div>\n </section>\n <section class=\"rp-config-library\">\n <div class=\"rp-config-library-head\"><span class=\"icon\">AI</span><div><h4>Character presets</h4><p>Persona, prompt, robot avatar, park placement, motors, and default voice stack.</p></div><span class=\"count\" id=\"rp-char-count\">0 presets</span></div>\n <div class=\"rp-config-toolbar\"><input id=\"rp-char-search\" type=\"search\" placeholder=\"Search character, stack, motor, or prompt\u2026\"><button class=\"rp-btn primary\" id=\"rp-addchar\">Create</button></div>\n <div class=\"rp-config-cards\" id=\"rp-char-list\"></div>\n <input id=\"rp-newchar-name\" type=\"hidden\"><input id=\"rp-newchar-stack\" type=\"hidden\"><datalist id=\"rp-newchar-stack-list\"></datalist><div id=\"rp-char-edit\" style=\"display:none\"></div>\n </section>\n </div>\n </div>\n </div>\n</div>\n\n<!-- Generic modal: title + scrollable body, used for session + audit detail views -->\n<div class=\"rp-modal-back\" id=\"rp-modal-back\" role=\"dialog\" aria-modal=\"true\">\n <div class=\"rp-modal\" id=\"rp-modal\">\n <div class=\"rp-modal-head\">\n <h3 id=\"rp-modal-title\">Details</h3>\n <button class=\"x\" id=\"rp-modal-close\" aria-label=\"Close\">\u2715</button>\n </div>\n <div class=\"rp-modal-body\" id=\"rp-modal-body\"></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 function dashboardMeshToken(){\n if(TOKEN)return TOKEN;\n try{var saved=JSON.parse(localStorage.getItem('rp-command-env-v1')||'null');return saved&&String(saved.token||'').trim()||'';}catch(e){return '';}\n }\n var UI_BUILD=(document.querySelector('meta[name=\"robopark-ui-build\"]')||{}).content||'unknown';\n fetch('/fed/ui-build'+QS,{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(info){\n if(!info||!info.build||info.build===UI_BUILD)return;\n var key='rp-ui-reload-'+info.build;if(sessionStorage.getItem(key))return;sessionStorage.setItem(key,'1');\n var url=new URL(location.href);url.searchParams.set('ui',info.build);location.replace(url.toString());\n }).catch(function(){});\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n var lastSchedulerPollAt = 0; // updated by pollRobopark() on every successful /api/telemetry fetch\n var lastFedStatusAt = 0; // updated by poll() on every successful /fed/status fetch\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 schedulerDevicesById = {}; // enrolled device rows + camera/audio inventory\n var schedulerDevicesByName = {}; // same rows, keyed by device name (id != name, e.g. dev_8984a80e vs jaguar2)\n function schedulerDeviceScore(device){\n if(!device) return -1;\n var status=String(device.status||'').trim().toLowerCase();\n var heartbeat=Date.parse(device.last_heartbeat||'');\n var enrolled=Date.parse(device.enrolled_at||device.created_at||'');\n var fresh=Number.isFinite(heartbeat)&&(Date.now()-heartbeat)<45000;\n var score=fresh?1000000000000000:0;\n if(status==='online'||status==='running'||status==='detecting'||status==='connecting') score+=1000000000000;\n if(Number.isFinite(heartbeat)) score+=heartbeat;\n else if(Number.isFinite(enrolled)) score+=enrolled;\n return score;\n }\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.40, ny:0.68, 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 rpAvatarFor(label){\n var key=String(label||'').toLowerCase();\n var presets=(typeof characterPresets!=='undefined' && Array.isArray(characterPresets))?characterPresets:[];\n for(var i=0;i<presets.length;i++){\n var p=presets[i], aliases=[p.id,p.name];\n for(var j=0;j<aliases.length;j++){\n var a=String(aliases[j]||'').toLowerCase();\n if(a && (key===a || key.indexOf(a)>=0 || a.indexOf(key)>=0)) return p.img||'';\n }\n }\n if(key.indexOf('bmw')>=0 || key.indexOf('vixen')>=0) return '/static/robopark/bmw.png';\n var known=['volt','tesla','jaguar','panda'];\n for(var k=0;k<known.length;k++) if(key.indexOf(known[k])>=0) return '/static/robopark/'+known[k]+'.png';\n return '';\n }\n function rpTelemetryForNode(n){\n if(typeof roboparkByName==='undefined' || !roboparkByName) return null;\n var aliases=[n&&n.displayName,n&&n.name,n&&n.nodeId];\n for(var i=0;i<aliases.length;i++){\n var key=String(aliases[i]||'').toLowerCase();\n if(roboparkByName[key]) return roboparkByName[key];\n for(var existing in roboparkByName){\n if(existing===key || (key && existing.indexOf(key)>=0) || (existing && key.indexOf(existing)>=0)) return roboparkByName[existing];\n }\n }\n return null;\n }\n function renderFleetOperations(nodes, prefix, telemetryRows){\n prefix=prefix||'fleet'; nodes=Array.isArray(nodes)?nodes:[]; telemetryRows=Array.isArray(telemetryRows)?telemetryRows:null;\n var rows=[];\n function readinessOf(t,connected){ return String((t&&(t.readiness_code||t.status))||(!connected?'offline':'mesh_online')).toLowerCase(); }\n if(telemetryRows){\n for(var ti=0;ti<telemetryRows.length;ti++){\n var t=telemetryRows[ti], rn=robotName(t), rc=readinessOf(t,true), offline=rc==='offline'||rc==='robot_offline'||rc==='unreachable';\n rows.push({name:rn,connected:!offline,tele:t,hw:null,version:t.version||'',node:null,readiness:rc});\n }\n } else {\n for(var ni=0;ni<nodes.length;ni++){\n var n=nodes[ni], tele=rpTelemetryForNode(n), role=String(n.role||'').toLowerCase();\n if(role!=='satellite'&&role!=='robot'&&!tele) continue;\n rows.push({name:n.displayName||n.name||n.nodeId,connected:!!n.connected,tele:tele,hw:hwByNode[n.nodeId]||null,version:n.version||'',node:n,readiness:readinessOf(tele,!!n.connected)});\n }\n }\n var connected=0,ready=0,active=0,totalSessions=0,totalTriggers=0,latencies=[],drops=[],attention=[];\n for(var i=0;i<rows.length;i++){\n var r=rows[i], t=r.tele||{}, code=r.readiness;\n if(r.connected)connected++;\n if(code==='ready'||code==='in_session')ready++;\n if(code==='in_session'||t.active_session||t.in_session||t.session_active||t.current_session_id||t.status==='running'||t.status==='connecting')active++;\n totalSessions+=Number(t.sessions_total||0);totalTriggers+=Number(t.trigger_count||0);\n if(t.avg_latency_ms!=null)latencies.push(Number(t.avg_latency_ms));if(t.drop_rate!=null)drops.push(Number(t.drop_rate));\n var issues=[];\n if(!r.connected)issues.push('mesh or heartbeat offline');\n if(code!=='ready'&&code!=='in_session'&&code!=='mesh_online')issues.push(String(t.readiness_message||code).replace(/_/g,' '));\n if(t.drop_rate!=null&&Number(t.drop_rate)>=.05)issues.push(Math.round(Number(t.drop_rate)*100)+'% session drop rate');\n if(t.avg_latency_ms!=null&&Number(t.avg_latency_ms)>=3000)issues.push(Math.round(Number(t.avg_latency_ms))+'ms average response');\n if(r.hw&&r.hw.temperatureC!=null&&r.hw.temperatureC>=70)issues.push(Math.round(r.hw.temperatureC)+'C device temperature');\n if(r.hw&&r.hw.cpu&&r.hw.cpu.percent>=90)issues.push(Math.round(r.hw.cpu.percent)+'% CPU utilization');\n if(issues.length)attention.push({name:r.name,message:issues.join(' / '),bad:!r.connected||code==='stuck_session'||code==='join_timeout'||code==='robot_offline'});\n }\n var avgLatency=latencies.length?Math.round(latencies.reduce(function(a,b){return a+b;},0)/latencies.length):null;\n var avgDrop=drops.length?drops.reduce(function(a,b){return a+b;},0)/drops.length:null;\n var kpis=$(prefix+'-kpis');\n if(kpis){\n function k(label,value,detail,cls){return '<div class=\"fleet-kpi '+(cls||'')+'\"><div class=\"label\">'+esc(label)+'</div><div class=\"value\">'+esc(value)+'</div><div class=\"detail\">'+esc(detail)+'</div></div>';}\n kpis.innerHTML=k('Robots online',connected+'/'+rows.length,rows.length-connected+' unavailable',connected===rows.length?'ok':connected?'warn':'bad')+k('Ready',ready,rows.length?Math.round(ready/rows.length*100)+'% fleet readiness':'no robots',ready===rows.length?'ok':ready?'warn':'bad')+k('Live sessions',active,active?'operator oversight active':'fleet standing by',active?'ok':'')+k('Sessions',totalSessions,totalTriggers+' motion triggers','')+k('Response',avgLatency!=null?avgLatency+'ms':'--','fleet average',avgLatency==null?'':avgLatency>=3000?'bad':avgLatency>=1800?'warn':'ok')+k('Drop rate',avgDrop!=null?Math.round(avgDrop*100)+'%':'--','fleet average',avgDrop==null?'':avgDrop>=.15?'bad':avgDrop>=.05?'warn':'ok');\n }\n var read=$(prefix+'-readiness');\n if(read){\n var warn=Math.max(0,connected-ready),bad=Math.max(0,rows.length-connected),den=Math.max(1,rows.length),a1=ready/den*360,a2=(ready+warn)/den*360;\n read.innerHTML='<div class=\"fleet-readiness\"><div class=\"fleet-donut\" style=\"--ready-angle:'+a1+'deg;--warn-angle:'+a2+'deg\"><strong>'+Math.round(ready/den*100)+'%</strong></div><div class=\"fleet-legend\"><div><span><i style=\"background:var(--ok)\"></i>ready / in session</span><b>'+ready+'</b></div><div><span><i style=\"background:var(--warn)\"></i>degraded</span><b>'+warn+'</b></div><div><span><i style=\"background:var(--bad)\"></i>offline</span><b>'+bad+'</b></div><div><span>active conversations</span><b>'+active+'</b></div></div></div>';\n }\n var workload=$(prefix+'-workload');\n if(workload){\n var maxWork=1;for(var wi=0;wi<rows.length;wi++){var wt=rows[wi].tele||{},wv=Number(wt.sessions_total||0)+Number(wt.trigger_count||0);if(wv>maxWork)maxWork=wv;}\n workload.innerHTML=rows.length?rows.slice().sort(function(a,b){var at=a.tele||{},bt=b.tele||{};return (Number(bt.sessions_total||0)+Number(bt.trigger_count||0))-(Number(at.sessions_total||0)+Number(at.trigger_count||0));}).map(function(x){var xt=x.tele||{},sessions=Number(xt.sessions_total||0),triggers=Number(xt.trigger_count||0),work=sessions+triggers;return '<div class=\"fleet-bar-row\"><span class=\"nm\">'+esc(x.name)+'</span><span class=\"track\"><i class=\"fill\" style=\"display:block;width:'+Math.max(3,work/maxWork*100)+'%\"></i></span><span class=\"val\">'+sessions+' / '+triggers+'</span></div>';}).join(''):'<div class=\"pk-note\">No robot workload reported yet.</div>';\n }\n var attentionEl=$(prefix+'-attention'), attentionMeta=$(prefix+'-attention-meta');\n if(attentionMeta)attentionMeta.textContent=attention.length+' issue'+(attention.length===1?'':'s');\n if(attentionEl)attentionEl.innerHTML=attention.length?attention.map(function(a){return '<div class=\"fleet-alert-row '+(a.bad?'bad':'')+'\"><i></i><div><b>'+esc(a.name)+'</b><span>'+esc(a.message)+'</span></div><em>inspect</em></div>';}).join(''):'<div class=\"fleet-alert-row ok\"><i></i><div><b>All systems nominal</b><span>No active fleet readiness, latency, drop, temperature, or CPU alerts.</span></div><em>live</em></div>';\n var release=$(prefix+'-release');\n if(release){\n var versions={};for(var vi=0;vi<nodes.length;vi++){var version=nodes[vi].version||'unknown';versions[version]=(versions[version]||0)+1;}\n var versionKeys=Object.keys(versions).sort().reverse(),current=versionKeys[0]||'unknown';\n release.innerHTML='<div class=\"fleet-panel-head\" style=\"border:0;margin:0;padding:0\"><b>Release compliance</b><span>'+nodes.length+' mesh nodes</span></div>'+versionKeys.map(function(v){return '<span class=\"version-pill '+(v===current?'current':'')+'\">v'+esc(v)+' / '+versions[v]+' node'+(versions[v]===1?'':'s')+'</span>';}).join('')+'<span class=\"pk-note\" style=\"margin-left:auto\">updated '+clock(Date.now())+'</span>';\n }\n }\n function renderFleet(nodes){\n var el = $('fleet');\n // Keep existing MJPEG elements alive while telemetry cards are rebuilt.\n // Replacing an <img> tears down its long-lived stream and made cameras\n // appear to reconnect on every fleet poll.\n var preservedCameras={};\n if(el){Array.prototype.forEach.call(el.querySelectorAll('[data-fleet-camera]'),function(img){var key=img.getAttribute('data-fleet-camera');if(key)preservedCameras[key]=img;});}\n renderFleetOperations(nodes||[],'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 tele=rpTelemetryForNode(n);\n var avatar=rpAvatarFor(tele&&(tele.character_name||tele.character_id)||n.displayName);\n var readiness=tele&&(tele.readiness_code||tele.status)||(!up?'offline':'mesh online');\n var hb=tele&&tele.heartbeat_age_seconds!=null?tele.heartbeat_age_seconds:null;\n var site = n.site?(' \u00B7 '+esc(n.site)):'';\n var cameraRef=n.displayName||n.name||n.nodeId||n.id;\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 + (avatar?('<div class=\"node-hero\"><img class=\"node-avatar\" alt=\"'+esc(n.displayName)+'\" src=\"'+esc(avatar+QS)+'\"><div class=\"node-health\"><b>'+esc(String(readiness).replace(/_/g,' '))+'</b><br>'\n +(tele?('sessions '+esc(tele.sessions_total||0)+' / triggers '+esc(tele.trigger_count||0)+'<br>latency '+esc(tele.avg_latency_ms!=null?Math.round(tele.avg_latency_ms)+'ms':'n/a')+' / drops '+esc(tele.drop_rate!=null?Math.round(tele.drop_rate*100)+'%':'n/a')+'<br>heartbeat '+esc(hb!=null?hb+'s ago':'unavailable')):'scheduler telemetry pending')+'</div></div>'):'');\n if(tele){var fleetStage=tele.current_stage||{},fleetStageStatus=fleetStage.status||tele.current_stage_status||'waiting',fleetStageLabel=fleetStage.label||tele.current_stage_label||'Waiting for pipeline telemetry';html+='<div class=\"fleet-stage-focus '+esc(fleetStageStatus)+'\"><span>Live production stage</span><b>'+esc(fleetStageLabel)+'</b></div>';}\n\n if((tele||String(n.role||'').toLowerCase()==='satellite'||String(n.role||'').toLowerCase()==='robot')&&cameraRef){\n html += '<div class=\"fleet-live-camera\" data-fleet-camera-host=\"'+esc(cameraRef)+'\"><img alt=\"Live camera for '+esc(n.displayName)+'\" data-fleet-camera=\"'+esc(cameraRef)+'\"><span class=\"state\">'+(up?'connecting camera':'robot offline')+'</span></div>';\n }\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>'):'')\n + ((tele||String(n.role||'').toLowerCase()==='satellite')?('<div class=\"fleet-card-actions\"><button class=\"rp-btn primary\" data-fleet-action=\"camera\" data-robot=\"'+esc(n.displayName)+'\">Live camera</button><button class=\"rp-btn\" data-fleet-action=\"sessions\" data-robot=\"'+esc(n.displayName)+'\">Sessions</button><button class=\"rp-btn\" data-fleet-action=\"incidents\" data-robot=\"'+esc(n.displayName)+'\">Health</button>'+(tele&&(tele.character_id||tele.character_preset_id)?'<button class=\"rp-btn\" data-config-edit-character=\"'+esc(tele.character_id||tele.character_preset_id)+'\">Edit character</button>':'')+(tele&&tele.voice_stack_id?'<button class=\"rp-btn\" data-config-edit-stack=\"'+esc(tele.voice_stack_id)+'\">Edit voice</button>':'')+'</div>'):'')+'</div>';\n }\n el.innerHTML = html;\n Array.prototype.forEach.call(el.querySelectorAll('[data-fleet-camera]'),function(placeholder){\n var key=placeholder.getAttribute('data-fleet-camera'),old=preservedCameras[key];\n if(old){placeholder.parentNode.replaceChild(old,placeholder);placeholder=old;}\n var host=placeholder.closest('[data-fleet-camera-host]'),state=host&&host.querySelector('.state');\n function connect(){\n if(!placeholder.isConnected)return;\n placeholder.src='/fed/media/robots/'+encodeURIComponent(key)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();\n }\n placeholder.onload=function(){if(host){host.classList.add('live');host.classList.remove('failed');}if(state)state.textContent='always-on live';};\n placeholder.onerror=function(){if(host){host.classList.remove('live');host.classList.add('failed');}if(state)state.textContent='reconnecting camera';placeholder.removeAttribute('src');setTimeout(connect,4000);};\n if(!placeholder.getAttribute('src'))connect();\n else if(host){host.classList.add('live');if(state)state.textContent='always-on live';}\n });\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(ev){ if(ev&&ev.target&&ev.target.closest&&ev.target.closest('[data-fleet-action],[data-config-edit-stack],[data-config-edit-character]'))return; 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 var fleetButtons=el.querySelectorAll('[data-fleet-action]');\n for(var fb=0;fb<fleetButtons.length;fb++)(function(btn){btn.addEventListener('click',function(ev){ev.stopPropagation();var act=btn.getAttribute('data-fleet-action'),name=btn.getAttribute('data-robot');if(act==='camera'){selectTab('park');Park.openDrawerForRobot(name);}else{selectTab('robopark');setRpSubTab(act==='sessions'?'live':'incidents');}});})(fleetButtons[fb]);\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 // Scheduler-enrolled robots are not necessarily infinicode mesh peers. Add\n // them to the same Fleet/Park collection so a robot running only the\n // preview agent is still visible and selectable in the full dashboard.\n // \u2500\u2500 C3: Operator identity \u2500\u2500\n // The dashboard is a single-tenant surface (one hub = one operator\n // group), so we don't have full RBAC. We DO have a per-action\n // attribution problem: when an operator clicks \"End session\" the\n // audit log needs to know who. The scheduler logs whatever actor\n // string it sees, so we set an X-Operator header on every API\n // call from a name the operator typed into a profile modal on\n // first load. The name is editable from the top bar.\n var OPERATOR_NAME_KEY = 'rp-operator-name-v1';\n var _operatorName = '';\n function getOperatorName(){ return _operatorName || ''; }\n function setOperatorName(name){\n var s = String(name == null ? '' : name).trim();\n if(s.length > 64) s = s.slice(0, 64);\n _operatorName = s;\n try{ if(s) localStorage.setItem(OPERATOR_NAME_KEY, s); else localStorage.removeItem(OPERATOR_NAME_KEY); }catch(e){}\n updateOperatorBadge();\n }\n function loadOperatorName(){\n try{ _operatorName = (localStorage.getItem(OPERATOR_NAME_KEY) || '').trim(); }catch(e){ _operatorName = ''; }\n updateOperatorBadge();\n return _operatorName;\n }\n function operatorHeaders(){\n var n = getOperatorName();\n return n ? {'X-Operator': n} : {};\n }\n function updateOperatorBadge(){\n var el = $('rp-operator-badge');\n if(!el) return;\n var n = getOperatorName();\n el.textContent = n ? n : 'set operator';\n el.classList.toggle('set', !n);\n el.title = n ? ('Operator: '+n+' \u2014 click to change') : 'Click to set your operator name (used in the audit log)';\n }\n function openOperatorModal(){\n var cur = getOperatorName();\n var html = ''\n + '<div class=\"rp-field\"><label>Your name (operator)</label>'\n + '<input id=\"rp-operator-input\" maxlength=\"64\" value=\"'+esc(cur)+'\" placeholder=\"e.g. Jane Operator\" autocomplete=\"off\">'\n + '</div>'\n + '<div class=\"rp-field\"><label style=\"font-weight:400;color:var(--muted)\">'\n + 'This name is sent to the scheduler as the <code>X-Operator</code> header on every action you take (End session, Restart, Set production mode, etc.) '\n + 'and recorded in the audit log. Stored only in your browser\\'s localStorage \u2014 not on the server. '\n + 'Use a real name so postmortems can answer \"who clicked that?\".'\n + '</label></div>';\n showModal('Operator identity', html);\n setTimeout(function(){\n var inp = $('rp-operator-input');\n if(inp){ try{ inp.focus(); inp.select(); }catch(e){} }\n }, 30);\n }\n // First-load prompt: shown if the operator hasn't set a name yet.\n // They can dismiss (no name \u2192 actions still work, audit shows\n // \"operator\") or set one.\n function maybePromptOperator(){\n if(getOperatorName()) return;\n setTimeout(openOperatorModal, 800);\n }\n // click-to-edit from the top bar; the top bar element id is\n // \"rp-operator-badge\" \u2014 wired up near the end of the IIFE.\n document.addEventListener('click', function(e){\n var b = e.target.closest && e.target.closest('#rp-operator-badge');\n if(b){ e.preventDefault(); openOperatorModal(); }\n });\n // Save on Enter / Save-button in the modal\n document.addEventListener('keydown', function(e){\n if(e.target && e.target.id === 'rp-operator-input' && e.key === 'Enter'){\n e.preventDefault();\n var v = e.target.value;\n setOperatorName(v);\n closeModal();\n showOut(v ? ('Operator set to: '+v+' \u2014 your actions will be attributed to '+v+' in the audit log')\n : 'Operator name cleared \u2014 actions will be attributed to \"operator\"');\n }\n });\n\n function getJson(path){\n // QS is \"?token=...\"; a path that already carries its own query string\n // (e.g. \"/robopark/api/sessions?active_only=false&limit=200\") needs \"&\"\n // instead, or the token param lands inside an invalid second \"?\" and\n // the request goes out unauthenticated -> 401.\n var url = path + (path.indexOf('?')>=0 ? QS.replace('?','&') : QS);\n var headers = operatorHeaders();\n return fetch(url, {cache:'no-store', headers: headers}).then(function(r){\n if(!r.ok) throw new Error(path+' '+r.status);\n return r.json();\n });\n }\n function mergeSchedulerRobots(nodes){\n return Promise.all([getJson('/robopark/api/robots'), getJson('/robopark/api/devices')]).then(function(data){\n var rows = data[0], devices = data[1];\n var deviceById = {}, deviceByName = {};\n function norm(v){ return String(v||'').trim().toLowerCase(); }\n // A stale re-enrollment can leave more than one row with the same\n // display name. Keep the live/fresh device, never whichever SQLite\n // happened to return last, because all operator routes are id-keyed.\n (Array.isArray(devices)?devices:[]).forEach(function(device){\n deviceById[device.id] = device;\n if(device.name){\n var key=norm(device.name), current=deviceByName[key];\n if(!current || schedulerDeviceScore(device)>schedulerDeviceScore(current)) deviceByName[key] = device;\n }\n });\n schedulerDevicesById = deviceById;\n schedulerDevicesByName = deviceByName;\n var merged = (nodes||[]).slice();\n var candidates = (Array.isArray(rows)?rows:[]).slice();\n // Enrollment creates a devices row first; include it even when the\n // legacy robots table has not been populated for that device yet.\n (Array.isArray(devices)?devices:[]).forEach(function(device){\n var already = candidates.some(function(row){\n return norm(row.id||row.robot_id)===norm(device.id);\n });\n if(!already) candidates.push({id:device.id, name:device.name});\n });\n candidates.forEach(function(row){\n var id = row.id || row.robot_id;\n if(!id) return;\n var name = row.name || id;\n var device = deviceById[id] || deviceByName[norm(name)] || {};\n var status = String(device.status || row.status || '').toLowerCase();\n var heartbeat = device.last_heartbeat || row.last_heartbeat;\n var heartbeatAt = heartbeat ? Date.parse(heartbeat) : NaN;\n var heartbeatFresh = Number.isFinite(heartbeatAt) && (Date.now()-heartbeatAt) < 45_000;\n var connected = heartbeatFresh || ['online','detecting','connecting','running'].indexOf(status)>=0;\n var keys = [String(id).toLowerCase(), String(name).toLowerCase()];\n var existing = merged.find(function(n){\n return norm(n.schedulerRobotId)===norm(id)\n || norm(n.nodeId)===norm(id)\n || norm(n.displayName)===norm(name);\n });\n if(existing){\n existing.schedulerRobotId = id;\n existing.schedulerDeviceId = device.id || id;\n existing.displayName = existing.displayName || name;\n existing.connected = !!existing.connected || connected;\n existing.lastSeenAt = heartbeatAt && Number.isFinite(heartbeatAt) ? heartbeatAt : existing.lastSeenAt;\n existing.telemKeys = (existing.telemKeys||[]).concat(keys).filter(function(v,i,a){return a.indexOf(v)===i;});\n return;\n }\n merged.push({\n nodeId: 'scheduler-robot-'+id,\n schedulerRobotId: id,\n schedulerDeviceId: device.id || id,\n displayName: name,\n role: 'robot',\n platform: 'RoboPark robot',\n version: 'scheduler',\n capabilities: ['camera','microphone','voice'],\n connected: connected,\n load: 0,\n lastSeenAt: heartbeatAt && Number.isFinite(heartbeatAt) ? heartbeatAt : null,\n telemKeys: keys,\n });\n });\n return merged;\n }).catch(function(){ return nodes||[]; });\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 return mergeSchedulerRobots(s.nodes||[]).then(function(nodes){\n s.nodes = nodes;\n everOk = true; setLive(true); lastStatus = s; lastFedStatusAt = Date.now();\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 // C4: keep the incidents sub-tab's KPI + count fresh even when\n // the user isn't actively on it. The full incident list is\n // re-rendered only when the tab is open (cheap, since the\n // active-poll already pulls /api/telemetry at 3s).\n if(typeof renderLivenessKpis === 'function') renderLivenessKpis();\n if(rpSubTab === 'incidents' && typeof renderIncidents === 'function') renderIncidents();\n });\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 var currentHash=location.hash.replace('#','');\n var keepRpSubroute=name==='robopark' && currentHash.indexOf('robopark/')===0;\n if (!keepRpSubroute && currentHash !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark'){\n pollRobopark();\n initRpSubTabs();\n }\n if (name==='park') Park.activate(); else Park.deactivate();\n }\n var fleetParkBtn=$('fleet-open-park'); if(fleetParkBtn) fleetParkBtn.addEventListener('click',function(){selectTab('park');});\n var fleetSessionsBtn=$('fleet-open-sessions'); if(fleetSessionsBtn) fleetSessionsBtn.addEventListener('click',function(){selectTab('robopark');setRpSubTab('live');});\n var fleetIncidentsBtn=$('fleet-open-incidents'); if(fleetIncidentsBtn) fleetIncidentsBtn.addEventListener('click',function(){selectTab('robopark');setRpSubTab('incidents');});\n var fleetRefreshBtn=$('fleet-refresh'); if(fleetRefreshBtn) fleetRefreshBtn.addEventListener('click',function(){poll();pollRobopark();});\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.indexOf('robopark/')===0) selectTab('robopark');\n else if(initHash==='park') selectTab('park');\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 var headers = Object.assign({}, body?{'content-type':'application/json'}:{}, operatorHeaders());\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: headers,\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 var providerStatus=$('rp-elevenlabs-status');\n if(providerStatus) providerStatus.textContent=s&&s.elevenlabs_configured\n ? 'configured \u00B7 source: '+(s.elevenlabs_source==='environment'?'scheduler environment':'persistent scheduler store')\n : 'not configured \u00B7 cached voice speaker tests require this key';\n }).catch(function(){});\n }\n\n var elevenlabsSave=$('rp-elevenlabs-save');\n if(elevenlabsSave) elevenlabsSave.addEventListener('click',function(){\n var input=$('rp-elevenlabs-key'), key=input?input.value.trim():'';\n if(key.length<8){ showOut('Enter a valid ElevenLabs API key'); return; }\n elevenlabsSave.disabled=true;\n rpAction('POST','/robopark/api/settings/provider-secret',{provider:'elevenlabs',api_key:key}).then(function(){\n input.value=''; showOut('ElevenLabs secret saved on scheduler'); pollSettings();\n }).catch(function(e){ showOut('ElevenLabs secret: '+e.message); }).then(function(){elevenlabsSave.disabled=false;});\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\n function runtimeDockerAction(action, container){\n if(!container) return;\n if(!confirm(action+' '+container+'?')) return;\n rpAction('POST','/robopark/runtime/docker',{action:action,container:container}).then(function(res){ if(action==='logs'){ showOut(container+' logs (latest 100 lines):\\n'+(res&&res.output||'(no logs)')); } else { showOut(action+' queued for '+container); pollRuntimeHealth(); } }).catch(function(e){ showOut('docker '+action+' ('+container+'): '+e.message); });\n }\n function pollRuntimeHealth(){\n var summary=$('rp-runtime-summary'), tbody=$('rp-runtime-containers'); if(!summary||!tbody) return;\n getJson('/robopark/runtime/health').then(function(h){\n var docker=h.docker||{}, scheduler=h.scheduler||{};\n var containers=Array.isArray(h.containers&&h.containers.data)?h.containers.data:[];\n summary.textContent=(docker.ok?'Docker connected':'Docker unavailable')+' \u00B7 '+(scheduler.ok?'scheduler reachable':'scheduler unavailable')+' \u00B7 '+containers.filter(function(c){return /^(up|running)/i.test(String(c.State||''));}).length+'/'+containers.length+' containers running';\n summary.style.color=(docker.ok&&scheduler.ok)?'var(--ok)':'var(--bad)';\n tbody.innerHTML=containers.length?containers.map(function(c){\n var name=c.Names||c.Name||'container', state=c.State||'unknown', running=/^(up|running)/i.test(state);\n return '<tr><td class=\"mono\">'+esc(name)+'</td><td>'+esc(state)+'</td><td class=\"mono\">'+esc(c.Image||'\u2014')+'</td><td class=\"space-x-2\">'+['start','stop','restart','logs'].map(function(a){return '<button class=\"rp-btn '+(a==='stop'?'danger':'')+'\" data-runtime-action=\"'+a+'\" data-runtime-container=\"'+esc(name)+'\"'+((a==='start'&&running)||(a==='stop'&&!running)?' disabled':'')+'>'+a+'</button>';}).join(' ')+'</td></tr>';\n }).join(''):'<tr><td colspan=\"4\">No Docker containers found.</td></tr>';\n if(!docker.ok) $('rp-runtime-note').textContent='Docker unavailable: '+(docker.error||'check Docker Desktop');\n }).catch(function(e){ summary.textContent='runtime health unavailable'; summary.style.color='var(--bad)'; });\n }\n var runtimeRefresh=$('rp-runtime-refresh'); if(runtimeRefresh) runtimeRefresh.addEventListener('click', pollRuntimeHealth);\n var runtimeTable=$('rp-runtime-containers'); if(runtimeTable) runtimeTable.addEventListener('click', function(e){ var b=e.target.closest('[data-runtime-action]'); if(b&&!b.disabled) runtimeDockerAction(b.dataset.runtimeAction,b.dataset.runtimeContainer); });\n function recoverRobot(id){\n if(!confirm('Recover '+id+'? This closes any stale LiveKit session and returns the robot to idle.')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/recover',{}).then(function(res){\n showOut('recovered '+id+' \u2014 queued triggers cleared and robot returned to idle');\n pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('recover ('+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==='recover') recoverRobot(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 return 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(); refreshDrawerPresetOptions(); refreshVoiceCallOptions(); return characterPresets; }).catch(function(){ return []; });\n }\n function loadVoiceStacks(){\n return 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(); refreshDrawerPresetOptions(); refreshVoiceCallOptions(); return voiceStacks; }).catch(function(){ return []; });\n }\n\n // \u2500\u2500 TTS voice catalog (per provider) \u2500\u2500\n // Holds the dropdown options for whichever TTS provider is currently\n // selected in the voice-stack editor. We cache the catalog per-provider\n // in ttsVoiceCache so flipping back and forth between kokoro/elevenlabs\n // is instant. Each entry is { provider, source, count, voices, warning }.\n var ttsVoiceCache = {}; // provider -> TtsVoiceList payload\n var ttsVoiceLoading = {}; // provider -> in-flight Promise (de-dupes concurrent loads)\n function loadTtsVoiceCatalog(provider, opts){\n if(!provider) return Promise.resolve(null);\n opts = opts || {};\n var force = !!opts.force;\n if(ttsVoiceCache[provider] && !force) return Promise.resolve(ttsVoiceCache[provider]);\n if(ttsVoiceLoading[provider] && !force) return ttsVoiceLoading[provider];\n var p = fetch('/robopark/api/voices/'+encodeURIComponent(provider)+QS,{cache:'no-store'})\n .then(function(r){ return r.json(); })\n .then(function(d){\n ttsVoiceCache[provider] = d || {provider:provider, source:'static', count:0, voices:[], warning:'Empty response'};\n return ttsVoiceCache[provider];\n })\n .catch(function(e){\n var fallback = {provider:provider, source:'static', count:0, voices:[], warning:'Could not reach scheduler: '+(e&&e.message||e)};\n ttsVoiceCache[provider] = fallback;\n return fallback;\n })\n .then(function(v){ ttsVoiceLoading[provider]=null; return v; });\n ttsVoiceLoading[provider] = p;\n return p;\n }\n // Build the option list for the inline TTS-voice <select>. The current\n // value (s.tts_voice) is always included as a synthetic option if it\n // isn't in the catalog \u2014 that way editing a stack that was saved with a\n // legacy / unknown voice still shows what the user actually had.\n function fillTtsVoiceSelect(provider, currentVoiceId){\n var sel = $('rpve-tts-voice');\n var note = $('rpve-tts-voice-note');\n if(!sel) return;\n sel.innerHTML = '';\n var cache = ttsVoiceCache[provider];\n if(!cache){\n sel.innerHTML = '<option value=\"\">\u2014 loading voices\u2026 \u2014</option>';\n if(note){ note.className='rp-autonote busy'; note.textContent = 'Loading '+provider+' voice catalog\u2026'; }\n return;\n }\n var voices = Array.isArray(cache.voices) ? cache.voices : [];\n if(!voices.length){\n var ph = currentVoiceId\n ? '<option value=\"'+esc(currentVoiceId)+'\" selected>'+esc(currentVoiceId)+' (current)</option>'\n : '<option value=\"\">\u2014 no voices available \u2014</option>';\n sel.innerHTML = ph;\n } else {\n var html = '';\n var seen = {};\n for(var i=0;i<voices.length;i++){\n var v = voices[i];\n var id = v.id || v.voice_id || '';\n if(!id || seen[id]) continue;\n seen[id] = 1;\n var label = v.name || id;\n var subbits = [];\n if(v.language) subbits.push(v.language);\n if(v.gender) subbits.push(v.gender);\n if(v.category) subbits.push(v.category);\n if(subbits.length) label = label + ' \u00B7 ' + subbits.join(' \u00B7 ');\n html += '<option value=\"'+esc(id)+'\"'+(id===currentVoiceId?' selected':'')+'>'+esc(label)+'</option>';\n }\n // Preserve an out-of-catalog current value so the user can still see\n // what was previously saved.\n if(currentVoiceId && !seen[currentVoiceId]){\n html += '<option value=\"'+esc(currentVoiceId)+'\" selected>'+esc(currentVoiceId)+' (saved value, not in catalog)</option>';\n }\n sel.innerHTML = html;\n }\n // status note (source + warning)\n if(note){\n var src = cache.source || 'static';\n var cnt = cache.count != null ? cache.count : voices.length;\n if(cache.warning){\n note.className='rp-autonote bad';\n note.textContent = '\u26A0 '+cache.warning+(cnt?(' \u2014 showing '+cnt+' voices.'):'');\n } else {\n note.className='rp-autonote';\n note.textContent = cnt+' '+provider+' voices \u00B7 source: '+src+(currentVoiceId?(' \u00B7 current: '+currentVoiceId):'');\n }\n }\n }\n function refreshTtsVoiceSelect(force){\n var provEl = $('rpve-tts-provider');\n var sel = $('rpve-tts-voice');\n if(!provEl || !sel) return;\n var provider = provEl.value || 'elevenlabs';\n var cur = sel.value || (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].value) || '';\n // Prefer the live value from the input field if the user has been\n // editing it. openVoiceStackEdit sets it before this runs, so .value\n // is the saved voice. The above cur is only used as a fallback.\n var savedVoice = (sel && sel.__savedVoice) || cur;\n if(force || !ttsVoiceCache[provider]){\n fillTtsVoiceSelect(provider, savedVoice);\n loadTtsVoiceCatalog(provider, {force:!!force}).then(function(){\n fillTtsVoiceSelect(provider, savedVoice);\n });\n } else {\n fillTtsVoiceSelect(provider, savedVoice);\n }\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 // Rebuild the option list inside the currently-open Park robot drawer so\n // preset/voice dropdowns populate even if the data finishes loading AFTER\n // the drawer was opened. Idempotent: no-op if the drawer is closed.\n function refreshDrawerPresetOptions(){\n var b=$('pk-body'); if(!b) return;\n var setupChar=$('pk-setup-char');\n if(setupChar){\n var curSetup=setupChar.value;\n setupChar.innerHTML='<option value=\"\">\u2014 auto \u2014</option>'+characterPresets.map(function(p){\n var id=p.id||p.preset_id||p.character_id||'';\n var name=(p.name||p.id||p.preset_id||'preset')+(p.description?(' \u2014 '+p.description):'');\n return '<option value=\"'+esc(id)+'\"'+(id===curSetup?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\n }\n var setupVoice=$('pk-setup-voice');\n if(setupVoice){\n var curVoice=setupVoice.value;\n setupVoice.innerHTML='<option value=\"\">\u2014 from character preset \u2014</option>'+voiceStacks.map(function(s){\n var id=s.id||''; var name=(s.name||s.id||'stack')+(s.tts_voice?(' \u00B7 '+s.tts_voice):'');\n return '<option value=\"'+esc(id)+'\"'+(id===curVoice?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\n }\n var effChar=$('pk-eff-char');\n if(effChar){\n var curEffChar=effChar.value;\n effChar.innerHTML=characterPresets.map(function(p){\n var id=p.id||p.preset_id||p.character_id||''; var name=p.name||p.id||p.preset_id||'';\n return '<option value=\"'+esc(id)+'\"'+(id===curEffChar?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\n }\n var effVoice=$('pk-eff-voice');\n if(effVoice){\n var curEffVoice=effVoice.value;\n effVoice.innerHTML=voiceStacks.map(function(s){\n var id=s.id||''; var name=s.name||s.id||'';\n return '<option value=\"'+esc(id)+'\"'+(id===curEffVoice?' selected':'')+'>'+esc(name)+'</option>';\n }).join('');\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 network=$('rb-network')&&$('rb-network').value||'lan';\n var commandEnv=rpCommandEnv();\n var hubHost=network==='tailscale'?commandEnv.tail:commandEnv.lan;\n var hubUrl=/^https?:///i.test(hubHost)?hubHost.replace(/\\/+$/,''):'http://'+hubHost.replace(/\\/+$/,'')+':47913';\n var schedulerUrl=hubUrl+'/robopark';\n var argv=['robopark','add-robot',name];\n if(characterId) argv.push('--character', characterId);\n var site=$('rb-site')&&$('rb-site').value.trim();if(site)argv.push('--site',site);\n if(hubUrl) argv.push('--hub-url', hubUrl);\n var meshToken=$('rb-token')&&$('rb-token').value.trim()||dashboardMeshToken()||'<mesh-token>';\n argv.push('--token', meshToken);\n if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);\n argv.push(network==='tailscale'?'--tailscale':'--lan','--start','--auto-start','--yes');\n return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '\"'+a.replace(/\"/g,'\\\"')+'\"' : a; }).join(' ');\n }\n function buildPadRobotCommand(name,characterId,enrollmentToken){\n var env=rpCommandEnv(),hubHost=env.lan||'192.168.0.9';\n var hubUrl=/^https?:///i.test(hubHost)?hubHost.replace(/\\/+$/,''):'http://'+hubHost.replace(/\\/+$/,'')+':47913';\n var meshToken=env.token||dashboardMeshToken()||'<mesh-token>',site=env.site||'Tel Aviv';\n var argv;\n if(enrollmentToken){\n argv=['sudo','robopark','setup','--robot','--name',name,'--site',site,'--hub-url',hubUrl,'--token',meshToken,'--scheduler-port','8080','--port','47913','--lan','--enrollment-token',enrollmentToken,'--start','--auto-start','--yes'];\n }else{\n argv=['robopark','add-robot',name];\n if(characterId)argv.push('--character',characterId);\n argv.push('--site',site,'--hub-url',hubUrl,'--token',meshToken,'--scheduler-url',hubUrl+'/robopark','--lan','--start','--auto-start','--yes');\n }\n return argv.map(function(a){return /[^a-zA-Z0-9_./:=,-]/.test(a)?'\"'+String(a).replace(/\"/g,'\\\"')+'\"':String(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 rbNetworkEl=$('rb-network'); if(rbNetworkEl) rbNetworkEl.addEventListener('change', refreshRobotCliCommand);\n var rbSiteEl=$('rb-site'); if(rbSiteEl) rbSiteEl.addEventListener('input', refreshRobotCliCommand);\n var rbTokenEl=$('rb-token'); if(rbTokenEl){ rbTokenEl.value=dashboardMeshToken(); rbTokenEl.addEventListener('input', 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)+'/simulate-trigger',{}).then(function(res){\n if(res && res.queued===false) throw new Error(res.reason||'trigger was not queued');\n showOut('motion trigger queued for '+id+' \u2014 waiting for robot heartbeat'); pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('motion trigger ('+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(){ openVoiceStackManager(); });\n $('rp-addchar').addEventListener('click', function(){ openCharacterManager(); });\n var newVoiceStackBtn=$('rp-new-voice-stack'); if(newVoiceStackBtn) newVoiceStackBtn.addEventListener('click',function(){ openVoiceStackManager(); });\n var newCharacterBtn=$('rp-new-character'); if(newCharacterBtn) newCharacterBtn.addEventListener('click',function(){ openCharacterManager(); });\n\n function configOption(value,label,current){ return '<option value=\"'+esc(value)+'\"'+(String(value)===String(current)?' selected':'')+'>'+esc(label||value)+'</option>'; }\n function configNumber(id,fallback){ var el=$(id), n=el?Number(el.value):NaN; return Number.isFinite(n)?n:fallback; }\n function voiceStackDraft(){\n return {\n id:$('cfg-vs-id').value.trim(),name:$('cfg-vs-name').value.trim(),\n stt_provider:$('cfg-vs-stt-provider').value,stt_model:$('cfg-vs-stt-model').value.trim(),stt_language:$('cfg-vs-stt-language').value.trim()||'en',\n llm_provider:$('cfg-vs-llm-provider').value,llm_model:$('cfg-vs-llm-model').value.trim(),llm_api_key:$('cfg-vs-llm-key').value.trim()||null,\n temperature:configNumber('cfg-vs-temperature',0.7),num_ctx:configNumber('cfg-vs-num-ctx',8192),\n tts_provider:$('cfg-vs-tts-provider').value,tts_voice:$('cfg-vs-tts-voice').value.trim(),tts_language:$('cfg-vs-tts-language').value.trim()||'en',\n max_turns:configNumber('cfg-vs-max-turns',20),min_endpointing_delay:configNumber('cfg-vs-endpointing',0.7),\n allow_interruptions:$('cfg-vs-interruptions').checked,wake_word_enabled:$('cfg-vs-wake-enabled').checked,\n wake_word_model:$('cfg-vs-wake-model').value.trim()||null,wake_word_threshold:configNumber('cfg-vs-wake-threshold',0.5),wake_word_timeout:configNumber('cfg-vs-wake-timeout',3)\n };\n }\n function openVoiceStackManager(id,draft){\n var existing=null;\n for(var i=0;i<voiceStacks.length;i++) if(String(voiceStacks[i].id)===String(id)){ existing=voiceStacks[i]; break; }\n var s=draft||existing||{id:'',name:'',stt_provider:'groq',stt_model:'whisper-large-v3-turbo',stt_language:'en',llm_provider:'ollama',llm_model:'gemma3:27b',temperature:0.7,num_ctx:8192,tts_provider:'elevenlabs',tts_voice:'',tts_language:'en',max_turns:20,min_endpointing_delay:0.7,allow_interruptions:true,wake_word_enabled:false,wake_word_threshold:0.5,wake_word_timeout:3};\n var editing=!!existing;\n var html='<div class=\"rp-config-editor\">'\n +'<div class=\"rp-config-editor-intro\"><span class=\"mark\">VOX</span><div><b>'+(editing?'Edit production voice stack':'Create production voice stack')+'</b><span>A stack is the complete conversational audio pipeline. Robot assignments keep referencing this ID when settings change.</span></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Identity <span>stable assignment key</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Stack ID</label><input id=\"cfg-vs-id\" value=\"'+esc(s.id||'')+'\" placeholder=\"english-fast\"'+(editing?' disabled':'')+'></div><div class=\"rp-field\"><label>Display name</label><input id=\"cfg-vs-name\" value=\"'+esc(s.name||'')+'\" placeholder=\"English fast\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Speech recognition <span>audio to text</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>STT provider</label><select id=\"cfg-vs-stt-provider\">'+configOption('groq','Groq',s.stt_provider)+configOption('speaches','Speaches / local',s.stt_provider)+configOption('vibevoice','VibeVoice',s.stt_provider)+'</select></div><div class=\"rp-field\"><label>STT model</label><input id=\"cfg-vs-stt-model\" value=\"'+esc(s.stt_model||'')+'\"></div><div class=\"rp-field\"><label>Language</label><input id=\"cfg-vs-stt-language\" value=\"'+esc(s.stt_language||'en')+'\" placeholder=\"en or auto\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Intelligence <span>prompt execution and response generation</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>LLM provider</label><select id=\"cfg-vs-llm-provider\">'+configOption('ollama','Ollama / Ollama Cloud',s.llm_provider)+configOption('groq','Groq',s.llm_provider)+configOption('gemini','Gemini',s.llm_provider)+configOption('moltbot','Moltbot',s.llm_provider)+'</select></div><div class=\"rp-field\"><label>LLM model</label><input id=\"cfg-vs-llm-model\" value=\"'+esc(s.llm_model||'')+'\"></div><div class=\"rp-field\"><label>Temperature</label><input id=\"cfg-vs-temperature\" type=\"number\" min=\"0\" max=\"2\" step=\"0.1\" value=\"'+esc(s.temperature!=null?s.temperature:0.7)+'\"></div><div class=\"rp-field\"><label>Context size</label><input id=\"cfg-vs-num-ctx\" type=\"number\" min=\"512\" step=\"512\" value=\"'+esc(s.num_ctx!=null?s.num_ctx:8192)+'\"></div><div class=\"rp-field wide\"><label>Provider API key override</label><input id=\"cfg-vs-llm-key\" type=\"password\" value=\"'+esc(s.llm_api_key||'')+'\" placeholder=\"Leave blank to use the ROBOVOICE agent secret\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Voice synthesis <span>text to robot speech</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>TTS provider</label><select id=\"cfg-vs-tts-provider\">'+configOption('elevenlabs','ElevenLabs',s.tts_provider)+configOption('kokoro','Kokoro',s.tts_provider)+configOption('piper','Piper',s.tts_provider)+'</select></div><div class=\"rp-field\"><label>Language</label><input id=\"cfg-vs-tts-language\" value=\"'+esc(s.tts_language||'en')+'\"></div><div class=\"rp-field wide\"><label>Voice ID</label><div class=\"rp-config-actions\"><input id=\"cfg-vs-tts-voice\" style=\"flex:1\" value=\"'+esc(s.tts_voice||'')+'\" placeholder=\"Choose or paste provider voice ID\"><button type=\"button\" class=\"rp-btn\" id=\"cfg-vs-browse\">Browse catalog</button></div></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Conversation behavior <span>latency and turn-taking</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Max turns</label><input id=\"cfg-vs-max-turns\" type=\"number\" min=\"1\" value=\"'+esc(s.max_turns!=null?s.max_turns:20)+'\"></div><div class=\"rp-field\"><label>Endpointing delay (seconds)</label><input id=\"cfg-vs-endpointing\" type=\"number\" min=\"0.1\" step=\"0.1\" value=\"'+esc(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7)+'\"></div><div class=\"rp-config-checks wide\"><label><input id=\"cfg-vs-interruptions\" type=\"checkbox\"'+(s.allow_interruptions!==false?' checked':'')+'>Allow natural interruptions</label><label><input id=\"cfg-vs-wake-enabled\" type=\"checkbox\"'+(s.wake_word_enabled?' checked':'')+'>Require wake word</label></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Wake word <span>optional local activation gate</span></div><div class=\"rp-config-fields\"><div class=\"rp-field wide\"><label>Model path</label><input id=\"cfg-vs-wake-model\" value=\"'+esc(s.wake_word_model||'')+'\" placeholder=\"models/hey_jarvis.onnx\"></div><div class=\"rp-field\"><label>Threshold</label><input id=\"cfg-vs-wake-threshold\" type=\"number\" min=\"0\" max=\"1\" step=\"0.05\" value=\"'+esc(s.wake_word_threshold!=null?s.wake_word_threshold:0.5)+'\"></div><div class=\"rp-field\"><label>Timeout (seconds)</label><input id=\"cfg-vs-wake-timeout\" type=\"number\" min=\"0\" step=\"0.5\" value=\"'+esc(s.wake_word_timeout!=null?s.wake_word_timeout:3)+'\"></div></div></div>'\n +'<div class=\"rp-config-editor-foot\"><button class=\"rp-btn\" id=\"cfg-vs-cancel\">Cancel</button><button class=\"rp-btn primary\" id=\"cfg-vs-save\">'+(editing?'Save changes':'Create stack')+'</button></div></div>';\n showModal(editing?'Edit voice stack \u00B7 '+(s.name||s.id):'New voice stack',html); $('rp-modal').classList.add('config-modal');\n $('cfg-vs-cancel').addEventListener('click',closeModal);\n $('cfg-vs-browse').addEventListener('click',function(){ var next=voiceStackDraft(); openTtsVoicePicker(next.tts_provider,function(voiceId){ next.tts_voice=voiceId; openVoiceStackManager(editing?id:null,next); }); });\n $('cfg-vs-save').addEventListener('click',function(){\n var body=voiceStackDraft(); if(!body.name){ showOut('Voice stack name is required'); return; }\n if(!editing && !body.id) body.id=body.name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,48);\n var btn=$('cfg-vs-save'); btn.disabled=true; btn.textContent='Saving\u2026';\n rpAction(editing?'PUT':'POST',editing?'/robopark/api/voice-stacks/'+encodeURIComponent(id):'/robopark/api/voice-stacks',body).then(function(){ closeModal(); showOut((editing?'Updated ':'Created ')+body.name); return loadVoiceStacks(); }).then(function(){ pollRobopark(); if(Park&&Park.refreshDrawer) Park.refreshDrawer(); }).catch(function(e){ btn.disabled=false; btn.textContent=editing?'Save changes':'Create stack'; showOut('voice stack: '+e.message); });\n });\n }\n\n function characterDraft(){\n return {id:$('cfg-cp-id').value.trim(),name:$('cfg-cp-name').value.trim(),description:$('cfg-cp-description').value.trim()||null,system_prompt:$('cfg-cp-prompt').value.trim()||null,voice_stack_id:$('cfg-cp-stack').value||null,motors:$('cfg-cp-motors').value.split(',').map(function(x){return x.trim();}).filter(Boolean),nx:configNumber('cfg-cp-nx',0.5),ny:configNumber('cfg-cp-ny',0.5),img:$('cfg-cp-img').value.trim()||null};\n }\n function openCharacterManager(id,draft){\n var existing=null; for(var i=0;i<characterPresets.length;i++) if(String(characterPresets[i].id)===String(id)){ existing=characterPresets[i]; break; }\n var p=draft||existing||{id:'',name:'',description:'',system_prompt:'',voice_stack_id:'',motors:['drive'],nx:0.5,ny:0.5,img:''}, editing=!!existing, stackOptions='<option value=\"\">No default stack</option>';\n for(var j=0;j<voiceStacks.length;j++) stackOptions+=configOption(voiceStacks[j].id,voiceStacks[j].name||voiceStacks[j].id,p.voice_stack_id);\n var html='<div class=\"rp-config-editor\">'\n +'<div class=\"rp-config-editor-intro\"><span class=\"mark\">AI</span><div><b>'+(editing?'Edit character preset':'Create character preset')+'</b><span>The persona prompt and default voice stack are frozen into each session record for reliable history and replay.</span></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Identity <span>fleet-facing appearance</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Preset ID</label><input id=\"cfg-cp-id\" value=\"'+esc(p.id||'')+'\" placeholder=\"jaguar\"'+(editing?' disabled':'')+'></div><div class=\"rp-field\"><label>Display name</label><input id=\"cfg-cp-name\" value=\"'+esc(p.name||'')+'\" placeholder=\"JAGUAR\"></div><div class=\"rp-field wide\"><label>Description</label><input id=\"cfg-cp-description\" value=\"'+esc(p.description||'')+'\" placeholder=\"Short operator-facing description\"></div><div class=\"rp-field wide\"><label>Avatar image path</label><input id=\"cfg-cp-img\" value=\"'+esc(p.img||'')+'\" placeholder=\"/static/robopark/jaguar.png\"></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Persona intelligence <span>ROBOVOICE system prompt</span></div><div class=\"rp-config-fields\"><div class=\"rp-field wide\"><label>System prompt</label><textarea id=\"cfg-cp-prompt\" placeholder=\"Define personality, constraints, speaking style, and robot behavior\u2026\">'+esc(p.system_prompt||'')+'</textarea></div></div></div>'\n +'<div class=\"rp-config-section\"><div class=\"rp-config-section-head\">Voice &amp; embodiment <span>pipeline and physical capabilities</span></div><div class=\"rp-config-fields\"><div class=\"rp-field\"><label>Default voice stack</label><select id=\"cfg-cp-stack\">'+stackOptions+'</select></div><div class=\"rp-field\"><label>Motor capabilities</label><input id=\"cfg-cp-motors\" value=\"'+esc((p.motors||[]).join(', '))+'\" placeholder=\"drive, wave, look\"></div><div class=\"rp-field\"><label>Park pad X (0\u20131)</label><input id=\"cfg-cp-nx\" type=\"number\" min=\"0\" max=\"1\" step=\"0.01\" value=\"'+esc(p.nx!=null?p.nx:0.5)+'\"></div><div class=\"rp-field\"><label>Park pad Y (0\u20131)</label><input id=\"cfg-cp-ny\" type=\"number\" min=\"0\" max=\"1\" step=\"0.01\" value=\"'+esc(p.ny!=null?p.ny:0.5)+'\"></div></div></div>'\n +'<div class=\"rp-config-editor-foot\"><button class=\"rp-btn\" id=\"cfg-cp-cancel\">Cancel</button><button class=\"rp-btn primary\" id=\"cfg-cp-save\">'+(editing?'Save changes':'Create character')+'</button></div></div>';\n showModal(editing?'Edit character \u00B7 '+(p.name||p.id):'New character preset',html); $('rp-modal').classList.add('config-modal'); $('cfg-cp-cancel').addEventListener('click',closeModal);\n $('cfg-cp-save').addEventListener('click',function(){ var body=characterDraft(); if(!body.name){ showOut('Character name is required'); return; } if(!editing&&!body.id) body.id=body.name.toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,48); var btn=$('cfg-cp-save');btn.disabled=true;btn.textContent='Saving\u2026';rpAction(editing?'PUT':'POST',editing?'/robopark/api/character-presets/'+encodeURIComponent(id):'/robopark/api/character-presets',body).then(function(){closeModal();showOut((editing?'Updated ':'Created ')+body.name);return Promise.all([loadCharacterPresets(),loadParkSlots()]);}).then(function(){pollRobopark();if(Park&&Park.refreshDrawer)Park.refreshDrawer();}).catch(function(e){btn.disabled=false;btn.textContent=editing?'Save changes':'Create character';showOut('character preset: '+e.message);}); });\n }\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 query=String(($('rp-voice-search')&&$('rp-voice-search').value)||'').trim().toLowerCase(), html='',shown=0;\n for(var i=0;i<voiceStacks.length;i++){ var s=voiceStacks[i], hay=[s.id,s.name,s.stt_provider,s.stt_model,s.llm_provider,s.llm_model,s.tts_provider,s.tts_voice].join(' ').toLowerCase(); if(query&&hay.indexOf(query)<0) continue; shown++; html += '<article class=\"rp-config-card\" data-vsid=\"'+esc(s.id)+'\"><div class=\"rp-config-card-head\"><div class=\"rp-config-card-title\"><b>'+esc(s.name||s.id)+'</b><small>'+esc(s.id)+' \u00B7 '+esc(s.tts_voice||'voice not selected')+'</small></div><span class=\"rp-config-badge '+(s.allow_interruptions?'ok':'')+'\">'+(s.allow_interruptions?'interruptible':'fixed turn')+'</span></div><div class=\"rp-config-pipeline\"><span>'+esc(s.stt_provider||'STT')+' / '+esc(s.stt_model||'default')+'</span><i>\u2192</i><span>'+esc(s.llm_provider||'LLM')+' / '+esc(s.llm_model||'default')+'</span><i>\u2192</i><span>'+esc(s.tts_provider||'TTS')+'</span></div><div class=\"rp-config-badges\"><span class=\"rp-config-badge gold\">'+esc(s.stt_language||'auto')+'</span><span class=\"rp-config-badge\">'+esc(String(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7))+'s endpoint</span><span class=\"rp-config-badge\">'+esc(String(s.max_turns||20))+' turns</span>'+(s.wake_word_enabled?'<span class=\"rp-config-badge ok\">wake word</span>':'')+'</div><div class=\"rp-config-actions\"><button class=\"rp-btn primary\" data-config-edit-stack=\"'+esc(s.id)+'\">Edit stack</button><button class=\"rp-btn\" data-config-use-stack=\"'+esc(s.id)+'\">Open in Voice Studio</button></div></article>'; }\n if(!shown) html='<div class=\"rp-voice-picker-empty\">'+(query?'No voice stacks match this search.':'No voice stacks yet. Create the first production pipeline.')+'</div>';\n el.innerHTML=html;\n var count=$('rp-voice-count'); if(count) count.textContent=voiceStacks.length+' stack'+(voiceStacks.length===1?'':'s');\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 query=String(($('rp-char-search')&&$('rp-char-search').value)||'').trim().toLowerCase(), html='',shown=0;\n for(var i=0;i<characterPresets.length;i++){ var p=characterPresets[i], hay=[p.id,p.name,p.description,p.system_prompt,p.voice_stack_id,(p.motors||[]).join(' ')].join(' ').toLowerCase(); if(query&&hay.indexOf(query)<0) continue; shown++; var avatar=p.img||rpAvatarFor(p.id||p.name); html += '<article class=\"rp-config-card\" data-cpid=\"'+esc(p.id)+'\"><div class=\"rp-config-card-head\">'+(avatar?'<img src=\"'+esc(avatar+QS)+'\" alt=\"'+esc(p.name||p.id)+'\">':'')+'<div class=\"rp-config-card-title\"><b>'+esc(p.name||p.id)+'</b><small>'+esc(p.id)+' \u00B7 '+esc(p.description||'No operator description')+'</small></div><span class=\"rp-config-badge '+(p.system_prompt?'ok':'')+'\">'+(p.system_prompt?'prompt ready':'prompt missing')+'</span></div><div class=\"rp-config-summary\">'+esc(p.system_prompt||'Add a system prompt to define this character\u2019s speaking style, constraints, and behavior.')+'</div><div class=\"rp-config-badges\"><span class=\"rp-config-badge gold\">'+esc(p.voice_stack_id||'default stack')+'</span>'+((p.motors||[]).map(function(m){return '<span class=\"rp-config-badge\">'+esc(m)+'</span>';}).join(''))+'<span class=\"rp-config-badge\">pad '+Math.round((p.nx||0)*100)+' / '+Math.round((p.ny||0)*100)+'</span></div><div class=\"rp-config-actions\"><button class=\"rp-btn primary\" data-config-edit-character=\"'+esc(p.id)+'\">Edit character</button><button class=\"rp-btn\" data-config-use-character=\"'+esc(p.id)+'\">Open in Voice Studio</button>'+(p.voice_stack_id?'<button class=\"rp-btn\" data-config-edit-stack=\"'+esc(p.voice_stack_id)+'\">Edit voice</button>':'')+'</div></article>'; }\n if(!shown) html='<div class=\"rp-voice-picker-empty\">'+(query?'No character presets match this search.':'No character presets yet. Create the first park persona.')+'</div>';\n el.innerHTML=html;\n var count=$('rp-char-count'); if(count) count.textContent=characterPresets.length+' preset'+(characterPresets.length===1?'':'s');\n }\n\n // \u2500\u2500 voice-stack edit panel \u2500\u2500\n var editingVoiceStackId=null;\n function openVoiceStackEdit(id){\n openVoiceStackManager(id); return;\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 // Stash the saved voice so refreshTtsVoiceSelect can keep it selected\n // when the catalog hasn't loaded yet. fillTtsVoiceSelect reads this\n // and includes it as a synthetic option if the catalog is missing it.\n var sel = $('rpve-tts-voice'); if(sel) sel.__savedVoice = s.tts_voice || '';\n refreshTtsVoiceSelect(true);\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-config-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id&&!e.target.closest('[data-config-use-stack]')) openVoiceStackManager(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 // TTS provider change: re-populate the voice <select> for the new provider.\n // We pass force=true so the catalog is refetched if we haven't seen this\n // provider yet; otherwise we use the cached list to repopulate instantly.\n var ttsProvEl = $('rpve-tts-provider');\n if(ttsProvEl) ttsProvEl.addEventListener('change', function(){\n var sel = $('rpve-tts-voice');\n if(sel) sel.__savedVoice = sel.value || sel.__savedVoice || '';\n refreshTtsVoiceSelect(true);\n });\n // \"Browse\u2026\" button: open the voice picker modal for the current provider.\n var ttsBrowseBtn = $('rpve-tts-voice-browse');\n if(ttsBrowseBtn) ttsBrowseBtn.addEventListener('click', function(){\n var provEl = $('rpve-tts-provider');\n var provider = (provEl && provEl.value) || 'elevenlabs';\n openTtsVoicePicker(provider, function(voiceId){\n var sel = $('rpve-tts-voice');\n if(!sel) return;\n // Make sure the chosen id exists as an option (it may have been\n // absent from the inline dropdown because the catalog hadn't loaded\n // yet). If it isn't there, append a synthetic option and select it.\n var exists = false;\n for(var i=0;i<sel.options.length;i++){ if(sel.options[i].value === voiceId){ exists=true; break; } }\n if(!exists){\n var o = document.createElement('option'); o.value=voiceId; o.textContent=voiceId+' (selected via Browse)';\n sel.appendChild(o);\n }\n sel.value = voiceId;\n sel.__savedVoice = voiceId;\n var note = $('rpve-tts-voice-note');\n if(note){ note.className='rp-autonote ok'; note.textContent='\u2713 selected '+voiceId+' from the '+provider+' catalog.'; }\n });\n });\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 openCharacterManager(id); return;\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-config-card'):null; if(!c) return; var id=c.getAttribute('data-cpid'); if(id&&!e.target.closest('[data-config-use-character],[data-config-edit-stack]')) openCharacterManager(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 var voiceSearch=$('rp-voice-search'); if(voiceSearch) voiceSearch.addEventListener('input',fillTemplateVoiceList);\n var charSearch=$('rp-char-search'); if(charSearch) charSearch.addEventListener('input',fillTemplateCharacterList);\n document.addEventListener('click',function(e){\n var stackEdit=e.target.closest&&e.target.closest('[data-config-edit-stack]'); if(stackEdit){ e.preventDefault();e.stopPropagation();openVoiceStackManager(stackEdit.getAttribute('data-config-edit-stack'));return; }\n var charEdit=e.target.closest&&e.target.closest('[data-config-edit-character]'); if(charEdit){ e.preventDefault();e.stopPropagation();openCharacterManager(charEdit.getAttribute('data-config-edit-character'));return; }\n var stackUse=e.target.closest&&e.target.closest('[data-config-use-stack]'); if(stackUse){ e.preventDefault();e.stopPropagation();setRpSubTab('voice');setTimeout(function(){var s=$('rp-call-stack-select');if(s){s.value=stackUse.getAttribute('data-config-use-stack');renderVoiceServices();}},0);return; }\n var charUse=e.target.closest&&e.target.closest('[data-config-use-character]'); if(charUse){ e.preventDefault();e.stopPropagation();setRpSubTab('voice');setTimeout(function(){var s=$('rp-call-character-select');if(s){s.value=charUse.getAttribute('data-config-use-character');renderVoiceCharacter();renderVoiceServices();}},0); }\n });\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', headers: operatorHeaders()}).then(function(r){ return r.json(); }).then(function(d){\n lastSchedulerPollAt = Date.now();\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[String(robotName(x)).toLowerCase()] = x; if(x.name) roboparkByName[String(x.name).toLowerCase()] = x; });\n // C4: also feed lastRobots for the Incidents sub-tab\n lastRobots = robotsFrom(d) || [];\n // push the same map into the Park so the canvas can color robots\n // by live/talking/waiting/ended status. updateTelemetry also\n // re-derives the readiness \u2192 status mapping for every node.\n if(Park && Park.updateTelemetry) Park.updateTelemetry(roboparkByName);\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://&lt;scheduler&gt;:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n renderFleetOperations([], 'rp-fleet', robots);\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 = rpAvatarFor(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||x.current_session_id||x.status==='connecting'||x.status==='running';\n var readiness = x.readiness_code||'unknown';\n var readinessBad = readiness!=='ready' && readiness!=='in_session';\n var readinessClass = readiness==='ready'?'ok':(readiness==='in_session'?'':'bad');\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 + ((x.character_id||x.character_preset_id)?'<button class=\"rp-btn\" data-config-edit-character=\"'+esc(x.character_id||x.character_preset_id)+'\">Edit character</button>':'')\n + (x.voice_stack_id?'<button class=\"rp-btn\" data-config-edit-stack=\"'+esc(x.voice_stack_id)+'\">Edit voice</button>':'')\n + '</div>'\n + '<div class=\"pk-note '+readinessClass+'\" style=\"margin-top:0.5rem;color:'+(readinessBad?'var(--bad)':'var(--ok)')+'\">'+esc(readiness.replace(/_/g,' '))+' \u00B7 '+esc(x.readiness_message||'no readiness detail')+'</div>'\n + (readiness==='stuck_session'||readiness==='stale_session'||readiness==='join_timeout'?'<div class=\"rp-actions\"><button class=\"rp-btn danger\" data-act=\"recover\" data-robot=\"'+idAttr+'\"'+(rpReadOnly?' disabled':'')+'>Recover robot</button></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 // Sessions & Telemetry \u2014 sub-tab control room\n // Six sub-tabs share a single pane-robopark pane:\n // Overview \u00B7 Live Sessions \u00B7 History \u00B7 Audit Log \u00B7 Fleet \u00B7 Setup\n // Each sub-tab owns a poll function + a setInterval handle; the handles\n // are cleared on switch so the browser only fetches when its pane is\n // actually visible.\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\n // \u2500\u2500 shared time / formatting helpers \u2500\u2500\n function rpFmtRelative(ts){\n if(!ts) return '\u2014';\n var t=typeof ts==='number'?ts:Date.parse(ts);\n if(!t||isNaN(t)) return '\u2014';\n var diff=Math.floor((Date.now()-t)/1000);\n if(diff<0) diff=0;\n if(diff<5) return 'just now';\n if(diff<60) return diff+'s ago';\n if(diff<3600) return Math.floor(diff/60)+'m ago';\n if(diff<86400) return Math.floor(diff/3600)+'h ago';\n return Math.floor(diff/86400)+'d ago';\n }\n function rpFmtAbs(ts){\n if(!ts) return '\u2014';\n var t=typeof ts==='number'?ts:Date.parse(ts);\n if(!t||isNaN(t)) return '\u2014';\n var d=new Date(t);\n function p(n){return (n<10?'0':'')+n;}\n return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());\n }\n function rpFmtDuration(sec){\n if(sec==null||isNaN(sec)) return '\u2014';\n sec=Math.max(0,Math.floor(sec));\n var h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;\n if(h) return h+'h '+m+'m';\n if(m) return m+'m '+s+'s';\n return s+'s';\n }\n function rpEndReasonClass(r){\n var x=String(r||'').toLowerCase();\n if(x==='operator'||x==='end_session'||x==='manual') return 'operator';\n if(x==='error'||x==='abnormal'||x==='failed') return 'error';\n if(x==='timeout'||x==='silence_timeout') return 'timeout';\n if(x==='silence') return 'silence';\n if(x==='completed'||x==='normal'||x==='done') return 'completed';\n return '';\n }\n function rpCategoryIcon(cat){\n var c=String(cat||'').toLowerCase();\n if(c==='robot') return '\uD83E\uDD16';\n if(c==='device') return '\uD83D\uDCE1';\n if(c==='session') return '\uD83C\uDF99';\n if(c==='server') return '\uD83D\uDDA5';\n if(c==='voice') return '\uD83D\uDDE3';\n if(c==='character') return '\uD83C\uDFAD';\n if(c==='preview') return '\uD83D\uDC41';\n if(c==='settings') return '\u2699';\n return '\u2726';\n }\n\n // \u2500\u2500 Sub-tab routing \u2500\u2500\n // Command sheet: generate production commands from one saved environment.\n var RP_COMMAND_ENV_KEY='rp-command-env-v1';\n var RP_CUSTOM_COMMANDS_KEY='rp-custom-commands-v1';\n var rpVisibleCommands=[];\n function rpCommandDefaults(){return {lan:'192.168.0.9',tail:'100.84.147.24',site:'Tel Aviv',robot:'bmw',enrollment:'',token:dashboardMeshToken()};}\n function rpLoadStoredJson(key,fallback){try{var value=JSON.parse(localStorage.getItem(key)||'null');return value&&typeof value==='object'?value:fallback;}catch(e){return fallback;}}\n function rpCommandEnv(){\n var d=rpCommandDefaults(),s=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});\n function value(name){var input=$('rp-command-'+name),raw=input?input.value:(s[name]!=null?s[name]:d[name]);return String(raw==null?'':raw).trim();}\n return {lan:value('lan'),tail:value('tail'),site:value('site'),robot:value('robot'),enrollment:value('enrollment'),token:value('token')};\n }\n function rpSaveCommandEnv(){try{localStorage.setItem(RP_COMMAND_ENV_KEY,JSON.stringify(rpCommandEnv()));}catch(e){}}\n function rpShellArg(value){var text=String(value||'');return /^[A-Za-z0-9._:/-]+$/.test(text)?text:'\"'+text.replace(/\"/g,'\\\"')+'\"';}\n function rpCommandCatalog(){\n var e=rpCommandEnv(),lan='http://'+e.lan,tail='http://'+e.tail,enroll=e.enrollment?' --enrollment-token '+rpShellArg(e.enrollment):'';\n return [\n {id:'hub-up',category:'Provision',title:'Start production hub and full dashboard',description:'Run elevated on livekit-1. Starts the mesh hub, full Park control center, scheduler proxy, LAN discovery, supervision, and updates.',command:'infinicode serve --hub --name livekit-1 --port 47913 --token '+rpShellArg(e.token)+' --lan --dashboard --scheduler-url http://127.0.0.1:8080 --auto-update --supervised'},\n {id:'robot-lan',category:'Provision',title:'Provision LAN robot',description:'One command for a Linux robot on the hub LAN. Installs the supervised mesh, RoboVision, and preview runtime.',command:'sudo robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+lan+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --lan --start --auto-start --yes'},\n {id:'robot-tail',category:'Provision',title:'Provision Tailscale robot',description:'One command for a Windows or Linux robot reaching the hub through Tailscale.',command:'robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+tail+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --tailscale --start --auto-start --yes'},\n {id:'operator-tail',category:'Provision',title:'Connect operator MCP over Tailscale',description:'Connects an operator workstation and coding agents to the production mesh.',command:'infinicode mcp --lan --tailscale --seed '+tail+':47913 --name dev-laptop --token '+rpShellArg(e.token)+' --mesh-port 47913'},\n {id:'hub-ui',category:'Health',title:'Open full production control center',description:'Opens the full Park dashboard through the hub Tailscale address.',command:'Start-Process \"'+tail+':47913/?token='+encodeURIComponent(e.token)+'#park\"'},\n {id:'hub-build',category:'Health',title:'Verify current hub UI',description:'Shows the installed version and dashboard build marker after npm update or restart.',command:'infinicode --version; Invoke-RestMethod \"http://127.0.0.1:47913/fed/ui-build?token='+encodeURIComponent(e.token)+'\"'},\n {id:'hub-ports',category:'Health',title:'Inspect hub ports on Windows',description:'Lists processes owning the control center, scheduler, and LiveKit ports without changing them.',command:'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -in 47913,8080,7880,7881,7882,7883 } | Sort-Object LocalPort | Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize'},\n {id:'scheduler-health',category:'Health',title:'Check scheduler through hub',description:'Confirms the hub proxy can reach the scheduler and return robot inventory.',command:'Invoke-RestMethod \"'+tail+':47913/robopark/api/robots?token='+encodeURIComponent(e.token)+'\" | ConvertTo-Json -Depth 6'},\n {id:'mesh-status',category:'Health',title:'Check mesh nodes',description:'Lists connected hub, robot, and operator nodes.',command:'Invoke-RestMethod \"'+tail+':47913/fed/status?token='+encodeURIComponent(e.token)+'\" | ConvertTo-Json -Depth 6'},\n {id:'pi-runtime',category:'Health',title:'Follow robot runtime logs',description:'Live systemd logs for the selected robot unified runtime.',command:'sudo journalctl -u robopark-robot-runtime-'+e.robot+'.service -f'},\n {id:'pi-ports',category:'Health',title:'Inspect robot service ports',description:'Checks mesh, camera, motion webhook, and audio listeners on a Linux robot.',command:\"sudo ss -ltnp | grep -E ':(47913|5000|5057|8000)'\"},\n {id:'camera-inventory',category:'Media',title:'Read RoboVision media inventory',description:'Returns the real cameras, microphones, and speakers detected by RoboVision.',command:'curl -s http://127.0.0.1:5000/api/media/inventory'},\n {id:'audio-inventory',category:'Media',title:'Read RoboVision audio devices',description:'Returns the microphone and speaker inventory used by the robot audio server.',command:'curl -s http://127.0.0.1:8000/devices'},\n {id:'camera-devices',category:'Media',title:'List valid Linux capture devices',description:'Shows V4L2 devices and capabilities so metadata-only nodes are not selected as cameras.',command:'v4l2-ctl --list-devices; for d in /dev/video*; do echo \"--- $d\"; v4l2-ctl -d \"$d\" --all 2>/dev/null | grep -E \"Card type|Video Capture|Device Caps\"; done'},\n {id:'camera-preview',category:'Media',title:'Open direct RoboVision camera feed',description:'Opens the robot MJPEG feed locally for camera-only diagnosis.',command:'xdg-open http://127.0.0.1:5000/video_feed'},\n {id:'robot-restart',category:'Recovery',title:'Clean restart selected robot',description:'Stops stale supervised processes, then recreates the complete LAN runtime.',command:'sudo robopark stop && sudo robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+lan+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --lan --start --auto-start --yes'},\n {id:'robot-status',category:'Recovery',title:'Inspect robot service failure',description:'Shows service state and the latest 150 log lines after a failed start.',command:'sudo systemctl status robopark-robot-runtime-'+e.robot+'.service --no-pager; sudo journalctl -u robopark-robot-runtime-'+e.robot+'.service -n 150 --no-pager'},\n {id:'docker-health',category:'Health',title:'Inspect Docker and LiveKit containers',description:'Shows container state, health, ports, and names on the hub.',command:'docker ps -a --format \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Image}}\"'},\n {id:'docker-restart',category:'Recovery',title:'Restart Docker Desktop service',description:'Run from elevated PowerShell only when Docker Desktop is stuck starting.',command:'Restart-Service com.docker.service; Start-Process -FilePath \"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe\"'},\n {id:'tailscale-status',category:'Health',title:'Check Tailscale routes and Serve',description:'Confirms the node is connected and shows any active Serve proxy.',command:'tailscale status; tailscale serve status'},\n {id:'npm-version',category:'Packages',title:'Check installed and published versions',description:'Compares this device with the npm latest tag.',command:'infinicode --version; npm view infinicode version'},\n {id:'npm-update',category:'Packages',title:'Update InfiniCode and RoboPark',description:'Installs the current published production package globally.',command:'npm install -g infinicode@latest --force'},\n {id:'npm-cache',category:'Packages',title:'Repair npm metadata cache',description:'Use when a new version returns ETARGET. Verify metadata before clearing anything.',command:'npm cache verify; npm view infinicode dist-tags --json'}\n ];\n }\n function rpCustomCommands(){var list=rpLoadStoredJson(RP_CUSTOM_COMMANDS_KEY,[]);return Array.isArray(list)?list:[];}\n function rpSaveCustomCommands(list){try{localStorage.setItem(RP_CUSTOM_COMMANDS_KEY,JSON.stringify(list));}catch(e){showOut('Could not save custom command: '+e.message);}}\n function rpLegacyCopy(value){var area=document.createElement('textarea');area.value=value;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();try{document.execCommand('copy');showOut('command copied');}catch(e){showOut('Copy failed: select the command manually');}area.remove();}\n function rpCopyCommand(text){var value=String(text||'');if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(value).then(function(){showOut('command copied');}).catch(function(){rpLegacyCopy(value);});}else rpLegacyCopy(value);}\n function renderCommandSheet(){\n var grid=$('rp-command-grid');if(!grid)return;\n var search=String(($('rp-command-search')||{}).value||'').toLowerCase().trim(),category=String(($('rp-command-category')||{}).value||'').toLowerCase();\n var custom=rpCustomCommands().map(function(x){return {id:String(x.id),category:String(x.category||'Custom'),title:String(x.title||'Custom command'),description:String(x.description||''),command:String(x.command||''),custom:true};});\n rpVisibleCommands=rpCommandCatalog().concat(custom).filter(function(x){if(category&&x.category.toLowerCase()!==category)return false;return !search||(x.title+' '+x.category+' '+x.description+' '+x.command).toLowerCase().indexOf(search)>=0;});\n var count=$('rp-command-count');if(count)count.textContent=rpVisibleCommands.length+' command'+(rpVisibleCommands.length===1?'':'s');\n if(!rpVisibleCommands.length){grid.innerHTML='<div class=\"rp-command-empty\">No commands match this filter.</div>';return;}\n grid.innerHTML=rpVisibleCommands.map(function(x){return '<article class=\"rp-command-card\"><div class=\"rp-command-head\"><b>'+esc(x.title)+'</b><span class=\"rp-command-tag\">'+esc(x.category)+'</span></div><p>'+esc(x.description)+'</p><pre class=\"rp-command-code\"><code>'+esc(x.command)+'</code></pre><div class=\"rp-command-actions\"><button class=\"rp-btn primary\" data-command-copy=\"'+esc(x.id)+'\">Copy command</button>'+(x.custom?'<button class=\"rp-btn danger\" data-command-delete=\"'+esc(x.id)+'\">Delete</button>':'')+'</div></article>';}).join('');\n }\n function initCommandSheet(){\n var fields=['lan','tail','site','robot','enrollment','token'],defaults=rpCommandDefaults(),saved=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});\n fields.forEach(function(name){var input=$('rp-command-'+name);if(!input)return;input.value=name==='token'&&TOKEN?TOKEN:(saved[name]!=null?saved[name]:defaults[name]);input.addEventListener('input',function(){rpSaveCommandEnv();renderCommandSheet();});});\n var search=$('rp-command-search'),category=$('rp-command-category'),grid=$('rp-command-grid'),reset=$('rp-command-reset'),copyAll=$('rp-command-copy-all'),form=$('rp-command-custom-form');\n if(search)search.addEventListener('input',renderCommandSheet);if(category)category.addEventListener('change',renderCommandSheet);\n if(reset)reset.addEventListener('click',function(){try{localStorage.removeItem(RP_COMMAND_ENV_KEY);}catch(e){}fields.forEach(function(name){var input=$('rp-command-'+name);if(input)input.value=defaults[name];});renderCommandSheet();showOut('command environment reset');});\n if(copyAll)copyAll.addEventListener('click',function(){rpCopyCommand(rpVisibleCommands.map(function(x){return '# '+x.title+'\\n'+x.command;}).join('\\n\\n'));});\n if(grid)grid.addEventListener('click',function(ev){var copy=ev.target.closest('[data-command-copy]'),del=ev.target.closest('[data-command-delete]');if(copy){var id=copy.getAttribute('data-command-copy'),item=rpVisibleCommands.find(function(x){return x.id===id;});if(item)rpCopyCommand(item.command);}if(del){var removeId=del.getAttribute('data-command-delete');rpSaveCustomCommands(rpCustomCommands().filter(function(x){return String(x.id)!==removeId;}));renderCommandSheet();showOut('custom command deleted');}});\n if(form)form.addEventListener('submit',function(ev){ev.preventDefault();var title=$('rp-command-custom-title').value.trim(),command=$('rp-command-custom-command').value.trim();if(!title||!command)return;var list=rpCustomCommands();list.push({id:'custom-'+Date.now(),title:title,category:$('rp-command-custom-category').value.trim()||'Custom',description:$('rp-command-custom-description').value.trim(),command:command});rpSaveCustomCommands(list);$('rp-command-custom-title').value='';$('rp-command-custom-description').value='';$('rp-command-custom-command').value='';renderCommandSheet();showOut('custom command saved in this browser');});\n renderCommandSheet();\n }\n initCommandSheet();\n\n var rpSubTab = 'overview';\n var rpIntervals = {}; // sub-tab name -> setInterval handle\n var rpHistoryAll = []; // full recent-session list (we paginate client-side)\n function setRpSubTab(name){\n if(!name) return;\n rpSubTab = name;\n var btns = document.querySelectorAll('.rp-subtab');\n for(var i=0;i<btns.length;i++){\n var b=btns[i], sub=b.getAttribute('data-sub');\n var isActive = sub===name;\n b.classList.toggle('active', isActive);\n b.setAttribute('aria-selected', isActive?'true':'false');\n }\n var subs = document.querySelectorAll('.rp-sub');\n for(var j=0;j<subs.length;j++){\n var s=subs[j];\n s.classList.toggle('active', s.id==='rp-sub-'+name);\n }\n // stop every sub-tab interval, then start only the active one\n var names = ['overview','incidents','live','voice','history','audit','fleet','config','commands','setup'];\n for(var k=0;k<names.length;k++){\n var n=names[k];\n if(rpIntervals[n]){ clearInterval(rpIntervals[n]); rpIntervals[n]=null; }\n }\n if(name==='overview'){ pollOverview(); rpIntervals.overview = setInterval(pollOverview, 4000); }\n if(name==='incidents'){ pollIncidents(); rpIntervals.incidents = setInterval(pollIncidents, 4000); }\n if(name==='live'){ pollLiveSessions(); startLiveAutoRefresh(); }\n if(name==='voice'){ refreshVoiceCallOptions(); }\n if(name==='history'){ rpHistoryPage=0; pollHistory(); }\n if(name==='audit'){ pollAuditLog(); rpIntervals.audit = setInterval(pollAuditLog, 6000); }\n if(name==='fleet'){ pollFleet(); rpIntervals.fleet = setInterval(pollFleet, 5000); }\n if(name==='commands'){ renderCommandSheet(); }\n if(name==='setup'){ pollSettings(); loadCharacterPresets(); loadVoiceStacks(); pollRuntimeHealth(); }\n // remember last sub-tab in URL hash\n try{ history.replaceState(null,'','#robopark/'+name); }catch(e){}\n }\n function buildRpSubTabs(){\n var btns = document.querySelectorAll('.rp-subtab');\n for(var i=0;i<btns.length;i++){\n (function(btn){\n btn.addEventListener('click', function(){ setRpSubTab(btn.getAttribute('data-sub')); });\n })(btns[i]);\n }\n // restore from hash like #robopark/history\n var h=String(location.hash||'').replace('#','');\n var m = h.match(/^robopark\\/(.+)$/);\n var initial = (m && m[1]) ? m[1] : 'overview';\n if(['overview','incidents','live','voice','history','audit','fleet','config','commands','setup'].indexOf(initial)<0) initial='overview';\n setRpSubTab(initial);\n }\n\n // \u2500\u2500 Modal helper \u2500\u2500\n function showModal(title, bodyHtml){\n var back = $('rp-modal-back');\n if(!back) return;\n $('rp-modal-title').textContent = title || 'Details';\n $('rp-modal-body').innerHTML = bodyHtml || '';\n back.classList.add('open');\n }\n function closeModal(){\n var back = $('rp-modal-back');\n if(!back) return;\n back.classList.remove('open');\n var modal=$('rp-modal'); if(modal) modal.classList.remove('config-modal');\n $('rp-modal-body').innerHTML = '';\n }\n // wire the close handlers (once)\n (function(){\n var closeBtn = $('rp-modal-close');\n if(closeBtn) closeBtn.addEventListener('click', closeModal);\n var back = $('rp-modal-back');\n if(back) back.addEventListener('click', function(e){ if(e.target===back) closeModal(); });\n document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeModal(); });\n })();\n\n // \u2500\u2500 TTS voice picker modal \u2500\u2500\n // A robust, searchable, debounced-fetched picker for any TTS provider that\n // exposes /api/voices/<provider> on the scheduler. Designed specifically\n // for ElevenLabs (hundreds of voices, needs search + loading + error\n // states) but works for any provider \u2014 Kokoro, Piper, etc.\n //\n // Usage: openTtsVoicePicker('elevenlabs', function(voiceId){ ... });\n // The callback receives the picked voice_id and the modal closes itself.\n // If the user dismisses the modal (Esc, backdrop, \u2715) the callback is NOT\n // called. The catalog is cached in ttsVoiceCache[provider] and re-used\n // on subsequent opens; a manual \"Refresh\" button forces a refetch.\n var ttsPickerState = {\n provider: null,\n voices: [],\n filter: '',\n loading: false,\n error: null,\n onPick: null,\n };\n function openTtsVoicePicker(provider, onPick){\n ttsPickerState.provider = provider || 'elevenlabs';\n ttsPickerState.onPick = (typeof onPick === 'function') ? onPick : null;\n ttsPickerState.filter = '';\n showModal('Browse '+ttsPickerState.provider+' voices', buildTtsPickerBody());\n // Focus the search box on the next paint so the modal animation has\n // started and the input is visible.\n setTimeout(function(){\n var inp = $('rp-voice-picker-search');\n if(inp){ try{ inp.focus(); inp.select && inp.select(); }catch(e){} }\n }, 30);\n // Re-render after the catalog is loaded (may already be cached).\n renderTtsPicker();\n loadTtsVoiceCatalog(ttsPickerState.provider, {force:false}).then(function(){\n // The modal may have been closed while the fetch was in flight.\n if(ttsPickerState.provider !== provider) return;\n renderTtsPicker();\n });\n }\n function buildTtsPickerBody(){\n // Toolbar (search + refresh) + scrollable results area. The two\n // containers (#rp-voice-picker-results + #rp-voice-picker-status) are\n // re-rendered in place by renderTtsPicker(), so opening the modal\n // repeatedly is cheap and we don't leak listeners.\n return ''\n + '<div class=\"rp-voice-picker\">'\n + '<div class=\"rp-voice-picker-toolbar\">'\n + '<input type=\"search\" id=\"rp-voice-picker-search\" placeholder=\"Search by name, id, language, or category\u2026\" autocomplete=\"off\" spellcheck=\"false\">'\n + '<button type=\"button\" class=\"rp-btn\" id=\"rp-voice-picker-refresh\" title=\"Refetch the voice catalog from the scheduler\">\u21BB Refresh</button>'\n + '</div>'\n + '<div class=\"rp-voice-picker-status\" id=\"rp-voice-picker-status\"></div>'\n + '<div class=\"rp-voice-picker-results\" id=\"rp-voice-picker-results\"></div>'\n + '<div class=\"rp-voice-picker-foot\">'\n + '<span class=\"rp-muted\">Click a voice to select it \u00B7 Esc to cancel</span>'\n + '</div>'\n + '</div>';\n }\n function renderTtsPicker(){\n var status = $('rp-voice-picker-status');\n var results = $('rp-voice-picker-results');\n if(!status || !results) return; // modal was closed\n var cache = ttsVoiceCache[ttsPickerState.provider];\n var voices = (cache && Array.isArray(cache.voices)) ? cache.voices : [];\n var warning = cache && cache.warning;\n var source = cache && cache.source;\n\n // \u2500\u2500 status row (loading / error / counts / warning) \u2500\u2500\n if(ttsPickerState.loading && !cache){\n status.className = 'rp-voice-picker-status busy';\n status.innerHTML = '<span class=\"rp-spinner\"></span> Loading '+esc(ttsPickerState.provider)+' voice catalog\u2026';\n results.innerHTML = '';\n return;\n }\n if(!cache){\n status.className = 'rp-voice-picker-status bad';\n status.innerHTML = '\u26A0 No catalog data for '+esc(ttsPickerState.provider)+'. Click Refresh to retry.';\n results.innerHTML = '';\n return;\n }\n if(warning && !voices.length){\n status.className = 'rp-voice-picker-status bad';\n status.innerHTML = '\u26A0 '+esc(warning);\n results.innerHTML = '';\n return;\n }\n var bits = [];\n bits.push('<b>'+voices.length+'</b> '+esc(ttsPickerState.provider)+' voice'+(voices.length===1?'':'s'));\n if(source) bits.push('\u00B7 source: <span class=\"rp-mono\">'+esc(source)+'</span>');\n if(warning) bits.push('<span style=\"color:var(--bad)\">\u26A0 '+esc(warning)+'</span>');\n status.className = warning ? 'rp-voice-picker-status warn' : 'rp-voice-picker-status';\n status.innerHTML = bits.join(' ');\n\n // \u2500\u2500 filtered results \u2500\u2500\n var q = String(ttsPickerState.filter || '').toLowerCase().trim();\n var filtered = voices;\n if(q){\n filtered = voices.filter(function(v){\n var blob = [\n v.id, v.name, v.language, v.gender, v.category, v.description\n ].map(function(x){return x==null?'':String(x);}).join(' ').toLowerCase();\n return blob.indexOf(q) >= 0;\n });\n }\n if(!filtered.length){\n results.innerHTML = '<div class=\"rp-voice-picker-empty\">'\n + (q ? ('No voices match \u201C'+esc(q)+'\u201D.')\n : ('No voices available for this provider.'))\n + '</div>';\n return;\n }\n // Group by category for a tidy scannable list. Providers that don't\n // set a category (Kokoro static list, etc.) all land in \"Other\".\n var groups = {};\n for(var i=0;i<filtered.length;i++){\n var v = filtered[i];\n var key = v.category || (v.language ? ('Language: '+v.language) : 'Other');\n if(!groups[key]) groups[key] = [];\n groups[key].push(v);\n }\n var groupNames = Object.keys(groups).sort(function(a,b){\n if(a==='Other') return 1;\n if(b==='Other') return -1;\n return a.localeCompare(b);\n });\n var html = '';\n for(var g=0;g<groupNames.length;g++){\n var name = groupNames[g];\n html += '<div class=\"rp-vp-group\"><div class=\"rp-vp-group-head\">'+esc(name)+' \u00B7 '+groups[name].length+'</div>';\n for(var j=0;j<groups[name].length;j++){\n var voice = groups[name][j];\n var id = voice.id || voice.voice_id || '';\n if(!id) continue;\n var meta = [];\n if(voice.language) meta.push(voice.language);\n if(voice.gender) meta.push(voice.gender);\n var sub = meta.length ? ('<span class=\"rp-vp-meta\">'+esc(meta.join(' \u00B7 '))+'</span>') : '';\n var desc = voice.description ? ('<div class=\"rp-vp-desc\">'+esc(voice.description)+'</div>') : '';\n html += '<button type=\"button\" class=\"rp-vp-item\" data-voice-id=\"'+esc(id)+'\">'\n + '<div class=\"rp-vp-row1\"><span class=\"rp-vp-name\">'+esc(voice.name||id)+'</span>'+sub+'</div>'\n + '<div class=\"rp-vp-id\">'+esc(id)+'</div>'\n + desc\n + '</button>';\n }\n html += '</div>';\n }\n results.innerHTML = html;\n }\n // Click delegation for picker items + the refresh button + the search\n // input. Bound once on the document so re-opening the modal doesn't\n // stack listeners.\n document.addEventListener('click', function(e){\n var btn = e.target.closest && e.target.closest('.rp-vp-item');\n if(btn){\n var vid = btn.getAttribute('data-voice-id');\n closeModal();\n if(vid && ttsPickerState.onPick){ ttsPickerState.onPick(vid); }\n return;\n }\n var r = e.target.closest && e.target.closest('#rp-voice-picker-refresh');\n if(r){\n // Re-fetch the catalog, ignore cache, then re-render.\n loadTtsVoiceCatalog(ttsPickerState.provider, {force:true}).then(function(){ renderTtsPicker(); });\n return;\n }\n });\n document.addEventListener('input', function(e){\n if(e.target && e.target.id === 'rp-voice-picker-search'){\n ttsPickerState.filter = e.target.value;\n renderTtsPicker();\n }\n });\n\n // \u2500\u2500 Live Sessions sub-tab \u2500\u2500\n var rpLiveAuto = true;\n var rpLiveClockTimer = null;\n function startLiveAutoRefresh(){\n if(rpIntervals.live){ clearInterval(rpIntervals.live); rpIntervals.live=null; }\n if(rpLiveClockTimer){ clearInterval(rpLiveClockTimer); rpLiveClockTimer=null; }\n if(rpLiveAuto){\n rpIntervals.live = setInterval(pollLiveSessions, 3000);\n rpLiveClockTimer = setInterval(updateLiveDurations, 1000);\n }\n }\n function updateLiveDurations(){\n var tbody = $('rp-live-tbody'); if(!tbody) return;\n var cells = tbody.querySelectorAll('td[data-started]');\n for(var i=0;i<cells.length;i++){\n var t = parseInt(cells[i].getAttribute('data-started'),10);\n if(!t) continue;\n cells[i].textContent = rpFmtDuration(Math.floor((Date.now()-t)/1000));\n }\n }\n function pollLiveSessions(){\n getJson('/robopark/api/sessions?active_only=true').then(function(rows){\n renderLiveSessions(Array.isArray(rows)?rows:[]);\n }).catch(function(e){ /* soft fail */ });\n }\n function renderLiveSessions(rows){\n var tbody = $('rp-live-tbody'), empty = $('rp-live-empty'), wrap = $('rp-live-wrap');\n var searchEl=$('rp-live-search'), robotEl=$('rp-live-robot');\n var search = searchEl ? String(searchEl.value||'').toLowerCase() : '';\n var robotF = robotEl ? String(robotEl.value||'') : '';\n // refresh the robot dropdown whenever the set changes\n if(robotEl){\n var known={}; for(var i=0;i<rows.length;i++){ var rid=String(rows[i].robot_id||''); if(rid) known[rid]=1; }\n var present = robotEl.querySelectorAll('option');\n var have={};\n for(var j=1;j<present.length;j++){ have[present[j].value]=1; if(!known[present[j].value]){ robotEl.removeChild(present[j]); } }\n for(var rid in known){ if(!have[rid]){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; robotEl.appendChild(o); } }\n }\n var filtered = rows.filter(function(s){\n if(robotF && String(s.robot_id||'')!==robotF) return false;\n if(search){\n var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();\n if(blob.indexOf(search)<0) return false;\n }\n return true;\n });\n if(!filtered.length){ tbody.innerHTML=''; wrap.style.display='none'; empty.style.display='block'; }\n else { empty.style.display='none'; wrap.style.display='block'; }\n var html='';\n for(var i=0;i<filtered.length;i++){\n var s = filtered[i];\n var rid = s.robot_id || '';\n var sid = s.session_id || '';\n var started = s.started_at ? Date.parse(s.started_at) : null;\n var elapsed = started ? Math.floor((Date.now()-started)/1000) : null;\n html += '<tr>'\n + '<td><b>'+esc(rid)+'</b></td>'\n + '<td><span class=\"sid\" title=\"'+esc(sid)+'\">'+esc(sid)+'</span></td>'\n + '<td><span class=\"t-rel\"><span class=\"live-dot\"></span>'+esc(rpFmtRelative(started))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(started))+'</span></td>'\n + '<td data-started=\"'+started+'\">'+esc(rpFmtDuration(elapsed))+'</td>'\n + '<td><span class=\"rp-end-badge active\">active</span></td>'\n + '<td style=\"display:flex;gap:0.3rem;\">'\n + '<button class=\"rp-btn\" data-act=\"open-robot\" data-robot=\"'+esc(rid)+'\">Open robot</button>'\n + '<button class=\"rp-btn danger\" data-act=\"end-session\" data-sid=\"'+esc(sid)+'\" data-robot=\"'+esc(rid)+'\">End</button>'\n + '</td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n // update the tab badge\n var badge = $('rps-live-badge'); if(badge) badge.textContent = filtered.length;\n }\n function endSessionFromTable(sessionId, robotId){\n if(!confirm('End session '+sessionId+' for '+robotId+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+robotId);\n pollLiveSessions();\n pollRobopark();\n pollRuntimeHealth();\n }).catch(function(e){ showOut('end-session: '+e.message); });\n }\n\n // wire live tab interactions\n (function(){\n var s=$('rp-live-search'); if(s) s.addEventListener('input', function(){ pollLiveSessions(); });\n var r=$('rp-live-robot'); if(r) r.addEventListener('change', function(){ pollLiveSessions(); });\n var a=$('rp-live-autoref'); if(a) a.addEventListener('change', function(){ rpLiveAuto = !!a.checked; startLiveAutoRefresh(); });\n var rb=$('rp-live-refresh'); if(rb) rb.addEventListener('click', pollLiveSessions);\n var tb=$('rp-live-tbody'); if(tb) tb.addEventListener('click', function(e){\n var b = e.target.closest ? e.target.closest('.rp-btn') : null;\n if(!b) return;\n var act = b.getAttribute('data-act');\n if(act==='end-session'){ endSessionFromTable(b.getAttribute('data-sid'), b.getAttribute('data-robot')); }\n if(act==='open-robot'){ var rid=b.getAttribute('data-robot'); if(rid){ setRpSubTab('fleet'); } }\n });\n })();\n\n // \u2500\u2500 History sub-tab \u2500\u2500\n var rpVoiceCall={room:null,LK:null,session:null,eventToken:null,muted:false,camera:false,screen:false,ending:false,seen:{},messages:0,timer:null,startedAt:0,audioContext:null,analyser:null,audioRaf:0,transcriptCollapsed:false};\n function refreshVoiceCallOptions(){\n var cs=$('rp-call-character-select'), ss=$('rp-call-stack-select');\n if(!cs||!ss) return;\n var cv=cs.value, sv=ss.value;\n cs.innerHTML='<option value=\"\">select character...</option>'+(characterPresets||[]).map(function(c){return '<option value=\"'+esc(c.id)+'\">'+esc(c.name||c.id)+'</option>';}).join('');\n ss.innerHTML='<option value=\"\">character default</option>'+(voiceStacks||[]).map(function(s){return '<option value=\"'+esc(s.id)+'\">'+esc(s.name||s.id)+' - '+esc(s.stt_provider)+' / '+esc(s.llm_model)+' / '+esc(s.tts_provider)+'</option>';}).join('');\n if(cv) cs.value=cv; if(sv) ss.value=sv;\n paintVoiceCallCharacter(); paintVoiceCallStack();\n }\n function paintVoiceCallCharacter(){\n var sel=$('rp-call-character-select'), box=$('rp-call-character'); if(!sel||!box) return;\n var chosen=null; for(var i=0;i<characterPresets.length;i++) if(characterPresets[i].id===sel.value){chosen=characterPresets[i];break;}\n if(!chosen){ box.innerHTML='<div><div class=\"name\">Choose a character</div><div class=\"meta\">Preset and voice stack are frozen into session history.</div></div>'; $('rp-call-avatar').removeAttribute('src'); $('rp-call-avatar-fallback').textContent='RP'; return; }\n var img=chosen.img||rpAvatarFor(chosen.id||chosen.name);\n box.innerHTML=(img?'<img alt=\"'+esc(chosen.name)+'\" src=\"'+esc(img+QS)+'\">':'')+'<div><div class=\"name\">'+esc(chosen.name||chosen.id)+'</div><div class=\"meta\">'+esc(chosen.description||'Ready for an operator voice call')+'</div></div>';\n $('rp-call-avatar-fallback').textContent=String(chosen.name||chosen.id||'RP').slice(0,2).toUpperCase();\n if(img)$('rp-call-avatar').src=img+QS;else $('rp-call-avatar').removeAttribute('src');\n var stack=$('rp-call-stack-select'); if(stack && !stack.value && chosen.voice_stack_id) stack.value=chosen.voice_stack_id;\n paintVoiceCallStack();\n }\n function selectedVoiceStack(){\n var sid=$('rp-call-stack-select').value, chosen=null;\n if(!sid){var cid=$('rp-call-character-select').value;for(var i=0;i<characterPresets.length;i++)if(characterPresets[i].id===cid){sid=characterPresets[i].voice_stack_id||'';break;}}\n for(var j=0;j<voiceStacks.length;j++)if(voiceStacks[j].id===sid){chosen=voiceStacks[j];break;} return chosen;\n }\n function paintVoiceCallStack(){\n var wrap=$('rp-call-services'); if(!wrap)return; var s=selectedVoiceStack();\n if(!s){wrap.innerHTML='<div class=\"rp-call-service\"><span class=\"ico\">STT</span><div><b>Speech recognition</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div><div class=\"rp-call-service\"><span class=\"ico\">LLM</span><div><b>Language model</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div><div class=\"rp-call-service\"><span class=\"ico\">TTS</span><div><b>Voice synthesis</b><small>choose a stack</small></div><span class=\"ready\">standby</span></div>';return;}\n function svc(code,name,provider,model){return '<div class=\"rp-call-service\"><span class=\"ico\">'+code+'</span><div><b>'+esc(name)+'</b><small>'+esc((provider||'default')+' / '+(model||'default'))+'</small></div><span class=\"ready\">ready</span></div>';}\n wrap.innerHTML=svc('STT','Speech recognition',s.stt_provider,s.stt_model)+svc('LLM','Language model',s.llm_provider,s.llm_model)+svc('TTS','Voice synthesis',s.tts_provider,s.tts_voice);\n }\n function setVoiceCallStatus(text,state){\n var s=$('rp-call-status'), o=$('rp-call-orb'); if(s)s.textContent=text; if(o)o.className='rp-call-orb '+(state||'');\n }\n function setVoiceAgentState(title,detail){if($('rp-call-agent-state'))$('rp-call-agent-state').textContent=title;if($('rp-call-agent-detail'))$('rp-call-agent-detail').textContent=detail||'';}\n function setVoiceEvent(title,detail){var e=$('rp-call-event');if(e)e.innerHTML='<b>'+esc(title)+'</b> '+esc(detail||'');}\n function ensureVoiceLiveKit(){\n if(window.LivekitClient) return Promise.resolve(window.LivekitClient);\n return new Promise(function(resolve,reject){\n var old=document.querySelector('script[data-rp-voice-livekit]');\n if(old){ old.addEventListener('load',function(){resolve(window.LivekitClient);}); old.addEventListener('error',reject); return; }\n var script=document.createElement('script'); script.dataset.rpVoiceLivekit='1'; script.src='/vendor/livekit-client.umd.js'+QS;\n script.onload=function(){resolve(window.LivekitClient);}; script.onerror=function(){reject(new Error('LiveKit browser library failed to load'));}; document.head.appendChild(script);\n });\n }\n function appendVoiceTurn(role,text){\n var wrap=$('rp-call-transcript'); if(!wrap)return;\n if(wrap.querySelector('.rp-tbl-empty')) wrap.innerHTML='';\n var div=document.createElement('div'); div.className='rp-turn '+role;\n div.innerHTML='<div class=\"who\">'+esc(role==='user'?'operator':'character')+'</div><div>'+esc(text)+'</div>';\n wrap.appendChild(div); wrap.scrollTop=wrap.scrollHeight;\n rpVoiceCall.messages++; if($('rp-call-message-count'))$('rp-call-message-count').textContent=rpVoiceCall.messages+' message'+(rpVoiceCall.messages===1?'':'s');\n }\n var VOICE_DEVICE_PREFS_KEY='rp-voice-device-prefs-v1';\n function voiceDevicePrefs(){try{return JSON.parse(localStorage.getItem(VOICE_DEVICE_PREFS_KEY)||'{}')||{};}catch(_){return {};}}\n function saveVoiceDevicePrefs(){try{localStorage.setItem(VOICE_DEVICE_PREFS_KEY,JSON.stringify({audioinput:$('rp-call-mic-device').value||'',videoinput:$('rp-call-cam-device').value||'',audiooutput:$('rp-call-speaker-device').value||''}));}catch(_){}}\n function voiceDeviceMessage(text,state){var note=$('rp-call-device-note');if(note){note.textContent=text;note.className='rp-call-device-note '+(state||'');}}\n function voiceMediaSupportError(){\n if(!window.isSecureContext && location.hostname!=='localhost' && location.hostname!=='127.0.0.1') return 'Browser microphone access is blocked on insecure HTTP. Open this control center over HTTPS (or localhost), then scan again.';\n if(!navigator.mediaDevices) return 'This browser does not expose media devices. Use current Edge or Chrome over HTTPS.';\n if(!navigator.mediaDevices.getUserMedia) return 'Microphone capture is unavailable in this browser context.';\n return '';\n }\n function fillDeviceSelect(id,devices,kind){\n var sel=$(id);if(!sel)return 0;var prefs=voiceDevicePrefs(),current=sel.value||prefs[kind]||'',rows=devices.filter(function(d){return d.kind===kind;});\n var fallback=kind==='audioinput'?'System default microphone':kind==='videoinput'?'System default camera':'System default speaker';\n sel.innerHTML='<option value=\"\">'+fallback+'</option>'+rows.filter(function(d){return d.deviceId&&d.deviceId!=='default';}).map(function(d,i){var label=d.label||((kind==='audioinput'?'Microphone ':kind==='videoinput'?'Camera ':'Speaker ')+(i+1));return '<option value=\"'+esc(d.deviceId)+'\">'+esc(label)+'</option>';}).join('');\n if(current){sel.value=current;if(sel.value!==current)sel.value='';} return rows.length;\n }\n function refreshBrowserDevices(requestPermission,includeCamera){\n var unsupported=voiceMediaSupportError();\n if(unsupported){$('rp-call-device-status').textContent='blocked';voiceDeviceMessage(unsupported,'bad');return requestPermission?Promise.reject(new Error(unsupported)):Promise.resolve([]);}\n $('rp-call-device-status').textContent=requestPermission?'requesting access\u2026':'scanning\u2026';\n var permission=Promise.resolve();\n if(requestPermission){\n var selected=$('rp-call-mic-device').value, audio=selected?{deviceId:{exact:selected}}:true;\n permission=navigator.mediaDevices.getUserMedia({audio:audio}).then(function(stream){stream.getTracks().forEach(function(t){t.stop();});if(includeCamera)return navigator.mediaDevices.getUserMedia({video:true}).then(function(cam){cam.getTracks().forEach(function(t){t.stop();});}).catch(function(){return null;});});\n }\n return permission.then(function(){return navigator.mediaDevices.enumerateDevices();}).then(function(devices){\n var mic=fillDeviceSelect('rp-call-mic-device',devices,'audioinput'),cam=fillDeviceSelect('rp-call-cam-device',devices,'videoinput'),speaker=fillDeviceSelect('rp-call-speaker-device',devices,'audiooutput');\n saveVoiceDevicePrefs();$('rp-call-device-status').textContent=mic+' mic \u00B7 '+cam+' cam \u00B7 '+speaker+' out';\n voiceDeviceMessage('Ready: '+mic+' microphone'+(mic===1?'':'s')+', '+cam+' camera'+(cam===1?'':'s')+', and '+speaker+' speaker output'+(speaker===1?'':'s')+' detected. Device choices are saved in this browser.','ok');return devices;\n }).catch(function(e){var message=e&&e.name==='NotAllowedError'?'Media permission was denied. Allow microphone access in the browser address bar and scan again.':(e&&e.message||'Media device scan failed');$('rp-call-device-status').textContent='permission failed';voiceDeviceMessage(message,'bad');setVoiceEvent('Browser media',message);throw new Error(message);});\n }\n function switchVoiceDevice(kind,id){saveVoiceDevicePrefs();if(!rpVoiceCall.room||!id)return Promise.resolve();return rpVoiceCall.room.switchActiveDevice(kind,id).catch(function(e){setVoiceEvent('Device switch failed',e.message);voiceDeviceMessage('Could not switch device: '+e.message,'bad');});}\n function applyVoiceOutputDevice(){saveVoiceDevicePrefs();var id=$('rp-call-speaker-device').value;if(!id)return;var els=$('rp-call-audio').querySelectorAll('audio'),supported=false;for(var i=0;i<els.length;i++)if(typeof els[i].setSinkId==='function'){supported=true;els[i].setSinkId(id).catch(function(e){voiceDeviceMessage('Speaker selection failed: '+e.message,'bad');});}if(!supported&&els.length)voiceDeviceMessage('This browser cannot route call audio to a selected speaker. System default output is active.','warn');}\n function testVoiceMicrophone(){\n var btn=$('rp-call-test-mic');btn.disabled=true;btn.textContent='Listening\u2026';\n refreshBrowserDevices(true,false).then(function(){var id=$('rp-call-mic-device').value,constraints=id?{deviceId:{exact:id}}:true;return navigator.mediaDevices.getUserMedia({audio:constraints});}).then(function(stream){\n var C=window.AudioContext||window.webkitAudioContext,ctx=new C(),src=ctx.createMediaStreamSource(stream),an=ctx.createAnalyser(),data=new Uint8Array(128),peak=0;an.fftSize=256;src.connect(an);var started=Date.now();\n return new Promise(function(resolve){function sample(){an.getByteTimeDomainData(data);var sum=0;for(var i=0;i<data.length;i++){var v=(data[i]-128)/128;sum+=v*v;}peak=Math.max(peak,Math.sqrt(sum/data.length));voiceDeviceMessage('Microphone test live \u00B7 level '+Math.round(peak*300)+'%','ok');if(Date.now()-started<2500)requestAnimationFrame(sample);else{stream.getTracks().forEach(function(t){t.stop();});ctx.close().catch(function(){});resolve(peak);}}sample();});\n }).then(function(peak){voiceDeviceMessage(peak>.01?'Microphone test passed \u00B7 signal detected.':'Microphone opened but no signal was detected. Check the selected input and gain.',peak>.01?'ok':'warn');}).catch(function(e){voiceDeviceMessage(e.message||'Microphone test failed','bad');}).finally(function(){btn.disabled=false;btn.textContent='Test microphone';});\n }\n function startVoiceVisualizer(track){\n stopVoiceVisualizer();try{var C=window.AudioContext||window.webkitAudioContext;if(!C||!track||!track.mediaStreamTrack)return;var ctx=new C(),an=ctx.createAnalyser(),source=ctx.createMediaStreamSource(new MediaStream([track.mediaStreamTrack]));an.fftSize=128;source.connect(an);rpVoiceCall.audioContext=ctx;rpVoiceCall.analyser=an;var data=new Uint8Array(an.frequencyBinCount),bars=$('rp-call-bars').querySelectorAll('i');\n function draw(){an.getByteFrequencyData(data);var sum=0;for(var x=0;x<data.length;x++)sum+=data[x];var avg=sum/data.length;for(var i=0;i<bars.length;i++){var idx=Math.floor(i*data.length/bars.length);bars[i].style.height=(12+Math.min(70,data[idx]*.28))+'px';}$('rp-call-visual').classList.toggle('speaking',avg>10);rpVoiceCall.audioRaf=requestAnimationFrame(draw);}draw();\n }catch(_){}\n }\n function stopVoiceVisualizer(){if(rpVoiceCall.audioRaf)cancelAnimationFrame(rpVoiceCall.audioRaf);rpVoiceCall.audioRaf=0;if(rpVoiceCall.audioContext)try{rpVoiceCall.audioContext.close();}catch(_){}rpVoiceCall.audioContext=null;rpVoiceCall.analyser=null;var bars=$('rp-call-bars');if(bars)Array.prototype.forEach.call(bars.querySelectorAll('i'),function(b){b.style.height='16px';});}\n function updateVoiceTimer(){if(!rpVoiceCall.startedAt)return;var sec=Math.floor((Date.now()-rpVoiceCall.startedAt)/1000),m=Math.floor(sec/60),s=sec%60;$('rp-call-timer').textContent=(m<10?'0':'')+m+':'+(s<10?'0':'')+s;}\n function updateAgentFromParticipant(participant){if(!participant)return;var a=participant.attributes||{},state=a['lk.agent.state']||a['agent.state']||'';if(state==='speaking')setVoiceAgentState('Speaking','character audio is streaming');else if(state==='thinking')setVoiceAgentState('Thinking','language model is composing');else if(state==='listening')setVoiceAgentState('Listening','speak naturally');else if(state==='initializing')setVoiceAgentState('Preparing agent','loading voice pipeline');}\n function handleVoiceData(payload,participant,kind,topic){\n try{var msg=JSON.parse(new TextDecoder().decode(payload));\n if(topic==='response_latency'){var total=Number(msg.total_ms)||0;$('rp-call-lat-total').textContent=total?total+'ms':'--';$('rp-call-lat-stt').textContent=msg.stt_to_llm_ms!=null?Math.round(msg.stt_to_llm_ms)+'ms':'--';$('rp-call-lat-tts').textContent=msg.llm_to_tts_ms!=null?Math.round(msg.llm_to_tts_ms)+'ms':'--';setVoiceEvent('Response complete',total+'ms round trip');return;}\n if(topic==='wakeword_state'){setVoiceEvent('Wake word',msg.state==='active'?'active - listening now':'waiting for wake word');return;}\n if(topic==='model_loading_status'){if(msg.is_loading){setVoiceAgentState('Loading model','cold start in progress');setVoiceEvent('Model status','loading model into memory');}return;}\n if(topic==='tool_status'){var names=msg.tool_names||[];setVoiceEvent('Tool activity',names.length?names.join(', '):'tool cycle complete');return;}\n if(topic && topic!=='lk-chat-topic')return;var text=String(msg.message||msg.text||'').trim();if(!text)return;var role=msg.role==='user'?'user':'assistant',key=role+'|'+text;if(rpVoiceCall.seen[key])return;rpVoiceCall.seen[key]=1;appendVoiceTurn(role,text);saveVoiceTurn(role,text,Object.keys(rpVoiceCall.seen).length);\n }catch(_){}\n }\n function attachVoiceMedia(track,tag){var video=$('rp-call-video'),box=$('rp-call-media');try{track.attach(video);box.classList.add('on');$('rp-call-media-tag').textContent=tag;}catch(_){} }\n function toggleVoiceCamera(){if(!rpVoiceCall.room)return;var next=!rpVoiceCall.camera,opts=$('rp-call-cam-device').value?{deviceId:$('rp-call-cam-device').value}:undefined;$('rp-call-camera').disabled=true;rpVoiceCall.room.localParticipant.setCameraEnabled(next,opts).then(function(){rpVoiceCall.camera=next;$('rp-call-camera').classList.toggle('on',next);$('rp-call-camera').textContent=next?'CAM ON':'CAM';if(next){var pub=rpVoiceCall.room.localParticipant.getTrackPublication(rpVoiceCall.LK.Track.Source.Camera);if(pub&&pub.track)attachVoiceMedia(pub.track,'CAMERA');refreshBrowserDevices(false);}else if(!rpVoiceCall.screen){$('rp-call-media').classList.remove('on');$('rp-call-video').srcObject=null;}}).catch(function(e){setVoiceEvent('Camera',e.message);}).finally(function(){$('rp-call-camera').disabled=false;});}\n function toggleVoiceScreen(){if(!rpVoiceCall.room)return;var next=!rpVoiceCall.screen;$('rp-call-screen').disabled=true;rpVoiceCall.room.localParticipant.setScreenShareEnabled(next).then(function(){rpVoiceCall.screen=next;$('rp-call-screen').classList.toggle('on',next);$('rp-call-screen').textContent=next?'SCR ON':'SCR';if(next){var pub=rpVoiceCall.room.localParticipant.getTrackPublication(rpVoiceCall.LK.Track.Source.ScreenShare);if(pub&&pub.track)attachVoiceMedia(pub.track,'SCREEN');}else if(!rpVoiceCall.camera){$('rp-call-media').classList.remove('on');$('rp-call-video').srcObject=null;}}).catch(function(e){setVoiceEvent('Screen share',e.message);}).finally(function(){$('rp-call-screen').disabled=false;});}\n function sendVoiceMessage(text){if(!rpVoiceCall.room||!text)return Promise.resolve();var payload=new TextEncoder().encode(JSON.stringify({message:text,role:'user',timestamp:Date.now()}));return rpVoiceCall.room.localParticipant.publishData(payload,{reliable:true,topic:'lk-chat-topic'}).then(function(){var key='user|'+text;if(!rpVoiceCall.seen[key]){rpVoiceCall.seen[key]=1;appendVoiceTurn('user',text);saveVoiceTurn('user',text,Object.keys(rpVoiceCall.seen).length);}});}\n function saveVoiceTurn(role,text,sequence){\n if(!rpVoiceCall.session||!rpVoiceCall.eventToken)return;\n fetch('/robopark/api/sessions/'+encodeURIComponent(rpVoiceCall.session)+'/transcript'+QS,{\n method:'POST',headers:Object.assign({'content-type':'application/json','X-RoboPark-Session-Token':rpVoiceCall.eventToken},operatorHeaders()),\n body:JSON.stringify({role:role,text:text,is_final:true,sequence:sequence,source:'control_center'})\n }).catch(function(){});\n }\n function startVoiceCall(){\n var cid=$('rp-call-character-select').value, sid=$('rp-call-stack-select').value;\n if(!cid){setVoiceCallStatus('select a character first','');return;}\n if(rpVoiceCall.room)return;\n setVoiceCallStatus('requesting microphone...','busy');setVoiceAgentState('Opening channel','requesting browser media permission'); $('rp-call-start').disabled=true;\n refreshBrowserDevices(true,false).then(function(devices){if(!devices.some(function(d){return d.kind==='audioinput';}))throw new Error('No browser microphone was detected');return rpAction('POST','/robopark/api/sessions/voice-call',{character_preset_id:cid,voice_stack_id:sid||null,client_name:getOperatorName()||'RoboPark Operator'});})\n .then(function(call){\n if(!call)throw new Error('scheduler returned no call');\n rpVoiceCall.session=call.session_id; rpVoiceCall.eventToken=call.event_token; rpVoiceCall.seen={};rpVoiceCall.messages=0;rpVoiceCall.ending=false;\n $('rp-call-session').textContent=call.session_id; $('rp-call-transcript').innerHTML='<div class=\"rp-tbl-empty\">Connecting to '+esc(call.character&&call.character.name||cid)+'...</div>';\n return ensureVoiceLiveKit().then(function(LK){\n var room=new LK.Room({adaptiveStream:true,dynacast:true}); rpVoiceCall.room=room;rpVoiceCall.LK=LK;\n room.on(LK.RoomEvent.TrackSubscribed,function(track){ if(track.kind==='audio'){var el=track.attach();el.autoplay=true;$('rp-call-audio').appendChild(el);applyVoiceOutputDevice();startVoiceVisualizer(track);setVoiceAgentState('Agent online','voice channel connected');} });\n room.on(LK.RoomEvent.TranscriptionReceived,function(segments,participant){\n for(var i=0;i<(segments||[]).length;i++){\n var seg=segments[i], final=seg.final===true||seg.isFinal===true; if(!final||!String(seg.text||'').trim())continue;\n var key=String(seg.id||'')+'|'+String(seg.text); if(rpVoiceCall.seen[key])continue; rpVoiceCall.seen[key]=1;\n var identity=participant&&participant.identity||''; var role=identity.indexOf('operator:')===0?'user':'assistant';\n appendVoiceTurn(role,String(seg.text)); saveVoiceTurn(role,String(seg.text),Object.keys(rpVoiceCall.seen).length);\n }\n });\n room.on(LK.RoomEvent.DataReceived,handleVoiceData);\n if(LK.RoomEvent.ParticipantAttributesChanged)room.on(LK.RoomEvent.ParticipantAttributesChanged,function(changed,p){updateAgentFromParticipant(p);});\n room.on(LK.RoomEvent.ParticipantConnected,function(p){updateAgentFromParticipant(p);setVoiceEvent('Agent connected',p.name||p.identity||'ROBOVOICE worker');});\n room.on(LK.RoomEvent.Disconnected,function(){ if(!rpVoiceCall.ending) finishVoiceCall('connection_lost'); });\n return room.connect(call.server_url,call.token).then(function(){\n var mic=$('rp-call-mic-device').value,opts=mic?{deviceId:mic}:undefined;\n setVoiceEvent('LiveKit connected','publishing '+(mic?'selected microphone':'system default microphone'));\n return room.localParticipant.setMicrophoneEnabled(true,opts).then(function(){return true;}).catch(function(e){\n var message=e&&e.message||'Browser microphone could not be published';\n rpVoiceCall.muted=true;\n voiceDeviceMessage('Voice channel is connected, but the microphone is blocked: '+message+'. Allow access, then press RETRY MIC.','bad');\n setVoiceEvent('Microphone blocked',message);\n if(window.console)console.error('RoboPark microphone publish failed',e);\n return false;\n });\n });\n }).then(function(micActive){\n setVoiceCallStatus(micActive?'secure channel connected':'channel connected - microphone blocked',micActive?'live':'busy');setVoiceAgentState(micActive?'Listening':'Microphone required',micActive?'speak naturally':'allow browser microphone access, then retry');if(micActive)setVoiceEvent('Connected','waiting for ROBOVOICE agent');rpVoiceCall.startedAt=Date.now();rpVoiceCall.timer=setInterval(updateVoiceTimer,1000);updateVoiceTimer();\n $('rp-call-mute').disabled=false;$('rp-call-camera').disabled=false;$('rp-call-screen').disabled=false;$('rp-call-end').disabled=false;$('rp-call-message').disabled=false;$('rp-call-send').disabled=false;\n $('rp-call-mute').textContent=micActive?'MIC':'RETRY MIC';$('rp-call-mute').classList.toggle('on',!!micActive);\n return fetch('/robopark/api/sessions/'+encodeURIComponent(rpVoiceCall.session)+'/joined'+QS,{method:'POST',headers:{'X-RoboPark-Session-Token':rpVoiceCall.eventToken}}).catch(function(){});\n });\n }).catch(function(e){\n if(window.console)console.error('RoboPark voice call failed',e);\n setVoiceCallStatus('call failed','');setVoiceAgentState('Unable to connect',e&&e.message||String(e));setVoiceEvent('Call failed',e&&e.message||String(e)); $('rp-call-start').disabled=false;\n var failedSession=rpVoiceCall.session,failedRoom=rpVoiceCall.room;rpVoiceCall.ending=true;rpVoiceCall.room=null;rpVoiceCall.session=null;rpVoiceCall.eventToken=null;\n if(failedSession)rpAction('POST','/robopark/api/sessions/'+encodeURIComponent(failedSession)+'/end?reason=connection_failed').catch(function(){});\n if(failedRoom){try{failedRoom.disconnect();}catch(_){}}\n setTimeout(function(){rpVoiceCall.ending=false;},300);\n });\n }\n function toggleVoiceMute(){\n if(!rpVoiceCall.room)return;var enable=rpVoiceCall.muted,mic=$('rp-call-mic-device').value,opts=enable&&mic?{deviceId:mic}:undefined,btn=$('rp-call-mute');btn.disabled=true;\n rpVoiceCall.room.localParticipant.setMicrophoneEnabled(enable,opts).then(function(){rpVoiceCall.muted=!enable;btn.textContent=enable?'MIC':'MIC OFF';btn.classList.toggle('on',enable);setVoiceCallStatus(enable?'secure channel connected':'microphone muted','live');setVoiceAgentState(enable?'Listening':'Microphone muted',enable?'speak naturally':'the call remains connected');voiceDeviceMessage(enable?'Microphone published to the active call.':'Microphone muted; the voice channel remains connected.',enable?'ok':'warn');}).catch(function(e){rpVoiceCall.muted=true;btn.textContent='RETRY MIC';btn.classList.remove('on');setVoiceCallStatus('channel connected - microphone blocked','busy');voiceDeviceMessage('Microphone retry failed: '+(e&&e.message||String(e)),'bad');setVoiceEvent('Microphone blocked',e&&e.message||String(e));}).finally(function(){btn.disabled=false;});\n }\n function finishVoiceCall(reason){\n if(rpVoiceCall.ending)return; rpVoiceCall.ending=true; var session=rpVoiceCall.session, room=rpVoiceCall.room;\n rpVoiceCall.room=null;if(rpVoiceCall.timer)clearInterval(rpVoiceCall.timer);rpVoiceCall.timer=null;stopVoiceVisualizer();if(room){try{room.disconnect();}catch(_){}}\n if(session) rpAction('POST','/robopark/api/sessions/'+encodeURIComponent(session)+'/end?reason='+encodeURIComponent(reason||'operator'));\n setVoiceCallStatus('call ended','');setVoiceAgentState('Call complete','session transcript saved');setVoiceEvent('Session closed',reason||'operator');$('rp-call-start').disabled=false;['rp-call-mute','rp-call-camera','rp-call-screen','rp-call-end'].forEach(function(id){$(id).disabled=true;$(id).classList.remove('on');});$('rp-call-message').disabled=true;$('rp-call-send').disabled=true;$('rp-call-media').classList.remove('on');$('rp-call-video').srcObject=null;$('rp-call-mute').textContent='MIC';$('rp-call-camera').textContent='CAM';$('rp-call-screen').textContent='SCR';$('rp-call-session').textContent='No active session';$('rp-call-timer').textContent='00:00';rpVoiceCall.session=null;rpVoiceCall.eventToken=null;rpVoiceCall.muted=false;rpVoiceCall.camera=false;rpVoiceCall.screen=false;rpVoiceCall.startedAt=0;setTimeout(function(){rpVoiceCall.ending=false;},300);\n }\n (function(){\n var cs=$('rp-call-character-select'),ss=$('rp-call-stack-select'),start=$('rp-call-start'),mute=$('rp-call-mute'),end=$('rp-call-end');\n if(cs)cs.addEventListener('change',paintVoiceCallCharacter); if(start)start.addEventListener('click',startVoiceCall);\n if(ss)ss.addEventListener('change',paintVoiceCallStack);if(mute)mute.addEventListener('click',toggleVoiceMute);if(end)end.addEventListener('click',function(){finishVoiceCall('operator');});\n $('rp-call-camera').addEventListener('click',toggleVoiceCamera);$('rp-call-screen').addEventListener('click',toggleVoiceScreen);$('rp-call-refresh-devices').addEventListener('click',function(){refreshBrowserDevices(true,true).catch(function(){});});$('rp-call-test-mic').addEventListener('click',testVoiceMicrophone);\n $('rp-call-edit-character').addEventListener('click',function(){var id=$('rp-call-character-select').value;if(id)openCharacterManager(id);else showOut('Choose a character to edit');});\n $('rp-call-new-character').addEventListener('click',function(){openCharacterManager();});\n $('rp-call-edit-stack').addEventListener('click',function(){var s=selectedVoiceStack();if(s)openVoiceStackManager(s.id);else showOut('Choose a voice stack to edit');});\n $('rp-call-new-stack').addEventListener('click',function(){openVoiceStackManager();});\n $('rp-call-mic-device').addEventListener('change',function(){switchVoiceDevice('audioinput',this.value);});$('rp-call-cam-device').addEventListener('change',function(){switchVoiceDevice('videoinput',this.value);});$('rp-call-speaker-device').addEventListener('change',applyVoiceOutputDevice);\n function toggleTranscript(){rpVoiceCall.transcriptCollapsed=!rpVoiceCall.transcriptCollapsed;$('rp-call-transcript').classList.toggle('collapsed',rpVoiceCall.transcriptCollapsed);$('rp-call-collapse').textContent=rpVoiceCall.transcriptCollapsed?'expand transcript':'collapse transcript';}\n $('rp-call-chat-toggle').addEventListener('click',toggleTranscript);$('rp-call-collapse').addEventListener('click',toggleTranscript);\n $('rp-call-compose').addEventListener('submit',function(e){e.preventDefault();var input=$('rp-call-message'),text=input.value.trim();if(!text)return;$('rp-call-send').disabled=true;sendVoiceMessage(text).then(function(){input.value='';}).catch(function(err){setVoiceEvent('Message failed',err.message);}).finally(function(){$('rp-call-send').disabled=!rpVoiceCall.room;});});\n refreshBrowserDevices(false);\n if(navigator.mediaDevices&&navigator.mediaDevices.addEventListener)navigator.mediaDevices.addEventListener('devicechange',function(){refreshBrowserDevices(false).catch(function(){});});\n })();\n\n var rpHistoryFilter = { robot: '', endReason: '', range: '7d', search: '' };\n var rpHistoryPage = 0;\n var RP_HISTORY_PAGE_SIZE = 25;\n function pollHistory(){\n getJson('/robopark/api/sessions?active_only=false&limit=500').then(function(rows){\n rpHistoryAll = Array.isArray(rows)?rows:[];\n renderHistory(rpHistoryAll);\n }).catch(function(e){\n var tbody=$('rp-hist-tbody'); if(tbody) tbody.innerHTML='';\n var empty=$('rp-hist-empty'); if(empty){ empty.style.display='block'; empty.textContent='history unreachable: '+(e&&e.message||''); }\n });\n }\n function rpHistoryWithinRange(s, range){\n if(range==='all'||!range) return true;\n if(!s.started_at) return true; // include rows with no timestamp when no filter set\n var t = Date.parse(s.started_at); if(isNaN(t)) return true;\n var now = Date.now();\n if(range==='24h') return (now-t) <= 86400e3;\n if(range==='7d') return (now-t) <= 7*86400e3;\n if(range==='30d') return (now-t) <= 30*86400e3;\n return true;\n }\n function renderHistory(rows){\n rows = rows || rpHistoryAll;\n var tbody = $('rp-hist-tbody'), empty = $('rp-hist-empty'), wrap = $('rp-hist-wrap');\n var info = $('rp-hist-info');\n // refresh robot dropdown from rows\n var rsel = $('rp-hist-robot');\n if(rsel){\n var known={};\n for(var i=0;i<rows.length;i++){ var r=String(rows[i].robot_id||''); if(r) known[r]=1; }\n // remove stale (preserve first \"all\" option)\n var opts=rsel.querySelectorAll('option');\n for(var j=opts.length-1;j>=1;j--){ if(!known[opts[j].value]) rsel.removeChild(opts[j]); }\n for(var rid in known){\n var present=false; for(var k=1;k<opts.length;k++){ if(opts[k].value===rid){ present=true; break; } }\n if(!present){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; rsel.appendChild(o); }\n }\n }\n var f = rpHistoryFilter;\n var filtered = rows.filter(function(s){\n if(f.robot && String(s.robot_id||'')!==f.robot) return false;\n if(f.endReason && String(s.end_reason||'').toLowerCase()!==f.endReason) return false;\n if(f.search){\n var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();\n if(blob.indexOf(f.search)<0) return false;\n }\n if(!rpHistoryWithinRange(s, f.range)) return false;\n return true;\n });\n var totalPages = Math.max(1, Math.ceil(filtered.length / RP_HISTORY_PAGE_SIZE));\n if(rpHistoryPage >= totalPages) rpHistoryPage = totalPages-1;\n if(rpHistoryPage < 0) rpHistoryPage = 0;\n var start = rpHistoryPage * RP_HISTORY_PAGE_SIZE;\n var slice = filtered.slice(start, start+RP_HISTORY_PAGE_SIZE);\n if(!slice.length){\n tbody.innerHTML = '';\n wrap.style.display='none';\n empty.style.display='block';\n empty.textContent = filtered.length ? 'no results on this page' : 'no sessions match the current filters';\n } else {\n empty.style.display='none';\n wrap.style.display='block';\n }\n var html='';\n for(var i=0;i<slice.length;i++){\n var s = slice[i];\n var sid = s.session_id || '';\n var rid = s.robot_id || '';\n var started = s.started_at ? Date.parse(s.started_at) : null;\n var ended = s.ended_at ? Date.parse(s.ended_at) : null;\n var dur = s.duration_seconds!=null ? s.duration_seconds : (started&&ended?Math.floor((ended-started)/1000):null);\n var reason = s.end_reason || '';\n var reasonCls = rpEndReasonClass(reason);\n var turns = s.transcript_count!=null ? s.transcript_count : 0;\n var pipe = s.error_count ? (s.error_count+' errors') : ((s.pipeline_event_count||0)+' events');\n var lat = s.avg_latency_ms!=null ? Math.round(s.avg_latency_ms)+'ms' : '\u2014';\n html += '<tr data-sid=\"'+esc(sid)+'\">'\n + '<td><span class=\"sid\" title=\"'+esc(sid)+'\">'+esc(sid)+'</span></td>'\n + '<td><b>'+esc(rid)+'</b></td>'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(started))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(started))+'</span></td>'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(ended))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(ended))+'</span></td>'\n + '<td>'+esc(rpFmtDuration(dur))+'</td>'\n + '<td><span class=\"rp-end-badge '+(reasonCls||'')+'\">'+esc(reason||'\u2014')+'</span></td>'\n + '<td>'+esc(turns)+'</td>'\n + '<td class=\"'+(s.error_count?'bad':'ok')+'\">'+esc(pipe)+'</td>'\n + '<td>'+esc(lat)+'</td>'\n + '<td><button class=\"rp-btn\" data-act=\"view\" data-sid=\"'+esc(sid)+'\">View</button></td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n if(info) info.textContent = 'showing '+(slice.length?(start+1):0)+'\u2013'+(start+slice.length)+' of '+filtered.length+' \u00B7 page '+(rpHistoryPage+1)+' / '+totalPages;\n var prev=$('rp-hist-prev'), next=$('rp-hist-next');\n if(prev) prev.disabled = rpHistoryPage<=0;\n if(next) next.disabled = rpHistoryPage>=totalPages-1;\n }\n function renderSessionDetail(s){\n function row(k,v){ return '<div class=\"kv\"><div class=\"k\">'+esc(k)+'</div><div class=\"v\">'+esc(v==null?'\u2014':v)+'</div></div>'; }\n var avatar=s.character_img||rpAvatarFor(s.character_id||s.character_name||s.robot_id), summary=s.summary||{};\n var html='<div class=\"rp-detail-hero\">'+(avatar?'<img alt=\"'+esc(s.character_name||s.robot_id)+'\" src=\"'+esc(avatar+QS)+'\">':'')\n +'<div><div style=\"font-size:1rem;color:var(--gold-bright);font-weight:700\">'+esc(s.character_name||s.robot_id||'Session')+'</div>'\n +'<div class=\"meta\">'+esc(summary.turns||0)+' turns \u00B7 '+esc(summary.pipeline_errors||0)+' pipeline errors</div></div></div>';\n html += row('Session ID', s.session_id)+row('Robot / source', s.robot_id)+row('Room', s.room_name);\n html += row('Started', rpFmtAbs(s.started_at)+' ('+rpFmtRelative(s.started_at)+')')+row('Ended',rpFmtAbs(s.ended_at)+' ('+rpFmtRelative(s.ended_at)+')');\n html += row('Duration',rpFmtDuration(s.duration_seconds))+row('End reason',s.end_reason||'active')+row('Join latency',s.latency_ms!=null?s.latency_ms+'ms':'\u2014');\n html += row('Operator',s.initiated_by||'robot motion')+row('Character',s.character_name||s.character_id||'\u2014')+row('Voice stack',s.voice_stack_name||s.voice_stack_id||'\u2014');\n html += '<div class=\"rp-section-h\" style=\"margin-top:1rem\">Transcript <span class=\"meta\">'+esc(summary.user_turns||0)+' user / '+esc(summary.assistant_turns||0)+' assistant</span></div>';\n var turns=(s.transcript||[]).filter(function(t){return t.is_final;});\n html += '<div class=\"rp-call-transcript\" style=\"height:auto;max-height:320px\">'+(turns.length?turns.map(function(t){return '<div class=\"rp-turn '+esc(t.role)+'\"><div class=\"who\">'+esc(t.speaker||t.role)+' \u00B7 '+esc(clock(t.timestamp))+'</div><div>'+esc(t.text)+'</div></div>';}).join(''):'<div class=\"rp-tbl-empty\">No transcript was recorded for this session.</div>')+'</div>';\n html += '<div class=\"rp-section-h\" style=\"margin-top:1rem\">Pipeline timeline <span class=\"meta\">'+esc((s.pipeline_events||[]).length)+' events</span></div>';\n html += '<div class=\"rp-pipeline\">'+((s.pipeline_events||[]).length?s.pipeline_events.map(function(e){return '<div class=\"rp-pipeline-row '+esc(e.status)+'\"><span>'+esc(clock(e.timestamp))+'</span><b>'+esc(e.stage)+'</b><span>'+esc(e.status)+'</span><span>'+esc(e.message||'')+'</span></div>';}).join(''):'<div class=\"rp-tbl-empty\">No pipeline events were recorded.</div>')+'</div>';\n return html;\n }\n function openSessionDetailModal(s){\n if(!s)return; showModal('Session '+(s.session_id||''),'<div class=\"rp-tbl-empty\">Loading transcript and pipeline timeline...</div>');\n getJson('/robopark/api/sessions/'+encodeURIComponent(s.session_id)+'/detail').then(function(detail){$('rp-modal-body').innerHTML=renderSessionDetail(detail);}).catch(function(e){$('rp-modal-body').innerHTML='<div class=\"rp-tbl-empty\">Session detail unavailable: '+esc(e&&e.message||e)+'</div>';});\n }\n (function(){\n var s=$('rp-hist-search'); if(s) s.addEventListener('input', function(){ rpHistoryFilter.search = String(s.value||'').toLowerCase(); rpHistoryPage=0; renderHistory(); });\n var r=$('rp-hist-robot'); if(r) r.addEventListener('change', function(){ rpHistoryFilter.robot = r.value||''; rpHistoryPage=0; renderHistory(); });\n var re=$('rp-hist-reason'); if(re) re.addEventListener('change', function(){ rpHistoryFilter.endReason = re.value||''; rpHistoryPage=0; renderHistory(); });\n var ra=$('rp-hist-range'); if(ra) ra.addEventListener('change', function(){ rpHistoryFilter.range = ra.value||'7d'; rpHistoryPage=0; renderHistory(); });\n var rb=$('rp-hist-refresh'); if(rb) rb.addEventListener('click', pollHistory);\n var prev=$('rp-hist-prev'); if(prev) prev.addEventListener('click', function(){ rpHistoryPage=Math.max(0,rpHistoryPage-1); renderHistory(); });\n var next=$('rp-hist-next'); if(next) next.addEventListener('click', function(){ rpHistoryPage=rpHistoryPage+1; renderHistory(); });\n var tb=$('rp-hist-tbody'); if(tb) tb.addEventListener('click', function(e){\n var b = e.target.closest ? e.target.closest('.rp-btn') : null; if(!b) return;\n if(b.getAttribute('data-act')==='view'){\n var sid = b.getAttribute('data-sid');\n for(var i=0;i<rpHistoryAll.length;i++){ if(String(rpHistoryAll[i].session_id||'')===sid){ openSessionDetailModal(rpHistoryAll[i]); return; } }\n }\n });\n })();\n\n // \u2500\u2500 Audit Log sub-tab \u2500\u2500\n var rpAuditFilter = { category: '', operator: '' };\n var rpAuditLast = [];\n function pollAuditLog(){\n var parts = [];\n if(rpAuditFilter.category) parts.push('category='+encodeURIComponent(rpAuditFilter.category));\n parts.push('limit=200');\n // Note: we filter by operator client-side (the /api/history endpoint\n // doesn't take an actor= filter). The filter chip toggles a\n // post-fetch predicate in renderAuditLog.\n var qs = '?' + parts.join('&');\n getJson('/robopark/api/history'+qs).then(function(rows){\n renderAuditLog(Array.isArray(rows)?rows:[]);\n }).catch(function(){\n var tbody=$('rp-audit-tbody'); if(tbody) tbody.innerHTML='';\n var empty=$('rp-audit-empty'); if(empty){ empty.style.display='block'; empty.textContent='audit log unreachable'; }\n });\n }\n function renderAuditLog(rows){\n // Apply the \"by me\" filter (client-side; the API doesn't take an\n // actor= filter today). The chip toggles rpAuditFilter.operator.\n var myName = (getOperatorName()||'').toLowerCase();\n var meOnly = !!rpAuditFilter.operator;\n if(meOnly && myName){\n rows = rows.filter(function(r){ return String(r.actor||'').toLowerCase() === myName; });\n }\n // keep the chip label in sync with the current operator\n var chip = $('rp-audit-byme');\n if(chip){\n if(!myName){\n chip.classList.add('disabled');\n chip.title = 'Set your operator name (click the badge in the top bar) to use this filter';\n } else {\n chip.classList.remove('disabled');\n chip.title = 'Showing only actions attributed to \"'+myName+'\"';\n chip.textContent = '\uD83D\uDC64 by me ('+myName+')';\n }\n chip.classList.toggle('active', meOnly);\n }\n var tbody=$('rp-audit-tbody'), empty=$('rp-audit-empty'), wrap=$('rp-audit-wrap');\n if(!rows.length){\n tbody.innerHTML='';\n wrap.style.display='none';\n empty.style.display='block';\n if(meOnly) empty.textContent = 'no audit events attributed to '+myName+' in this window';\n else empty.textContent = 'no audit events yet';\n return;\n }\n empty.style.display='none';\n wrap.style.display='block';\n var html='';\n for(var i=0;i<rows.length;i++){\n var ev=rows[i];\n var ts = ev.timestamp;\n var cat = ev.category || '\u2014';\n var act = ev.action || '\u2014';\n var ent = (ev.entity_type||'')+(ev.entity_id?(' \u00B7 '+ev.entity_id):'');\n var det = ev.details || '';\n var actor = ev.actor || '\u2014';\n if(typeof det==='object') det = JSON.stringify(det);\n var detShort = String(det);\n if(detShort.length>80) detShort = detShort.slice(0,77)+'\u2026';\n // Highlight rows attributed to the current operator so they pop\n var isMine = myName && String(actor).toLowerCase() === myName;\n var actorCellCls = isMine ? 'rp-actor-mine' : 'rp-actor-other';\n var rowCls = isMine ? 'rp-audit-row-mine' : '';\n html += '<tr class=\"'+rowCls+'\" data-idx=\"'+i+'\">'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(ts))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(ts))+'</span></td>'\n + '<td><span class=\"rp-end-badge\">'+esc(rpCategoryIcon(cat))+' '+esc(cat)+'</span></td>'\n + '<td><b>'+esc(act)+'</b></td>'\n + '<td>'+esc(ent||'\u2014')+'</td>'\n + '<td style=\"max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\" title=\"'+esc(String(det).replace(/\"/g,'&quot;'))+'\">'+esc(detShort)+'</td>'\n + '<td class=\"'+actorCellCls+'\" title=\"'+esc(actor)+'\">'\n + (isMine ? '\uD83D\uDC64 ' : '')\n + '<b>'+esc(actor)+'</b>'\n + '</td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n // stash for modal\n rpAuditLast = rows;\n }\n function openAuditDetailModal(ev){\n if(!ev) return;\n function row(k,v){ return '<div class=\"kv\"><div class=\"k\">'+esc(k)+'</div><div class=\"v\">'+esc(v==null?'\u2014':v)+'</div></div>'; }\n var html = '';\n html += row('Time', rpFmtAbs(ev.timestamp) + ' ('+rpFmtRelative(ev.timestamp)+')');\n html += row('Category', rpCategoryIcon(ev.category)+' '+(ev.category||'\u2014'));\n html += row('Action', ev.action);\n html += row('Entity type', ev.entity_type);\n html += row('Entity ID', ev.entity_id);\n html += row('Actor / source', ev.actor);\n if(ev.details){\n var dStr = typeof ev.details==='string' ? ev.details : JSON.stringify(ev.details,null,2);\n html += '<div class=\"kv\"><div class=\"k\">Details</div><div class=\"v\"><pre>'+esc(dStr)+'</pre></div></div>';\n }\n showModal('Audit \u00B7 '+(ev.action||'event')+' \u00B7 '+(ev.entity_id||ev.entity_type||''), html);\n }\n (function(){\n var chips = document.querySelectorAll('.rp-cat-chip');\n for(var i=0;i<chips.length;i++){\n (function(c){\n c.addEventListener('click', function(){\n for(var j=0;j<chips.length;j++) chips[j].classList.remove('active');\n c.classList.add('active');\n rpAuditFilter.category = c.getAttribute('data-cat')||'';\n pollAuditLog();\n });\n })(chips[i]);\n }\n var rb=$('rp-audit-refresh'); if(rb) rb.addEventListener('click', pollAuditLog);\n var byme=$('rp-audit-byme'); if(byme) byme.addEventListener('click', function(){\n if(!getOperatorName()){ openOperatorModal(); return; }\n rpAuditFilter.operator = rpAuditFilter.operator ? '' : getOperatorName();\n pollAuditLog();\n });\n var tb=$('rp-audit-tbody'); if(tb) tb.addEventListener('click', function(e){\n var tr = e.target.closest ? e.target.closest('tr') : null; if(!tr) return;\n var idx = parseInt(tr.getAttribute('data-idx'),10);\n if(!isNaN(idx) && rpAuditLast[idx]) openAuditDetailModal(rpAuditLast[idx]);\n });\n })();\n\n // \u2500\u2500 C4: Incidents sub-tab \u2500\u2500\n // Surfaces every active incident (any robot whose readiness_code is\n // in a non-ready set) and lets an operator acknowledge each one.\n // Acknowledgements live in localStorage, scoped per browser, so\n // multiple operators on different machines each keep their own\n // \"in-progress\" list \u2014 but every Ack also writes an audit event\n // through the operator-actor middleware (C3), so the action shows\n // up in the central history log under the operator's name.\n //\n // Severity map per readiness_code:\n // stuck_session | stale_session | join_timeout | device_missing |\n // server_unavailable | heartbeat_stale \u2192 critical (bad)\n // production_off | robot_production_off | production mode off \u2192 warning\n // not_joined | unknown | anything else that isn't 'ready' / 'in_session' \u2192 info\n var INCIDENT_SEVERITY = {\n stuck_session: 'bad',\n stale_session: 'bad',\n join_timeout: 'bad',\n device_missing: 'bad',\n server_unavailable: 'bad',\n heartbeat_stale: 'bad',\n production_off: 'warn',\n robot_production_off:'warn',\n not_joined: 'info',\n unknown: 'info',\n };\n var INCIDENT_BAD_CODES = ['stuck_session','stale_session','join_timeout','device_missing','server_unavailable','heartbeat_stale'];\n var INCIDENT_ACK_KEY = 'rp-incident-acks-v1';\n // Active incidents: derived live from the most recent telemetry\n // snapshot in lastRobots. Acknowledged list is in localStorage,\n // keyed by robotId|readiness so the same robot entering\n // the same state again reopens the incident.\n var _incidentSevFilter = '';\n var _incidentAcks = {}; // key -> {t, by}\n function _loadIncidentAcks(){\n try{\n var raw = localStorage.getItem(INCIDENT_ACK_KEY);\n _incidentAcks = raw ? JSON.parse(raw) : {};\n }catch(e){ _incidentAcks = {}; }\n }\n function _saveIncidentAcks(){\n try{ localStorage.setItem(INCIDENT_ACK_KEY, JSON.stringify(_incidentAcks)); }catch(e){}\n }\n function _incidentKey(robotId, code){ return robotId + '|' + code; }\n function _ackIncident(robotId, code, readinessMessage){\n var key = _incidentKey(robotId, code);\n _incidentAcks[key] = { t: Date.now(), by: getOperatorName() || 'operator', message: readinessMessage || '' };\n _saveIncidentAcks();\n // Mirror the ack to the audit log so it's visible centrally too.\n // rpAction carries the X-Operator header (C3), so the audit row\n // will be attributed to the current operator.\n rpAction('POST','/robopark/api/incidents/ack',{ robot_id: robotId, code: code, message: readinessMessage || '' })\n .catch(function(e){ /* soft-fail; the local ack still stands */ });\n renderIncidents();\n }\n function _unackIncident(robotId, code){\n var key = _incidentKey(robotId, code);\n delete _incidentAcks[key];\n _saveIncidentAcks();\n renderIncidents();\n }\n // Compute the list of currently-active incidents from the last\n // /api/telemetry snapshot. Includes nodes that aren't even in the\n // fleet (id starts with 'pad:') \u2014 we still want to know if a slot\n // is empty for an extended period.\n function _collectActiveIncidents(){\n var list = [];\n var seen = {};\n for(var i=0;i<(lastRobots||[]).length;i++){\n var r = lastRobots[i];\n var code = r.readiness_code || (r.connected ? 'ready' : 'not_joined');\n if(!code || code === 'ready' || code === 'in_session') continue;\n var sev = INCIDENT_SEVERITY[code] || 'info';\n if(sev === 'info' && code === 'not_joined') continue; // too noisy; skip\n var key = _incidentKey(r.id, code);\n seen[key] = true;\n // find first-seen: persisted across reloads via a parallel map\n var firstSeenKey = 'rp-incident-first-seen-v1';\n var firstSeen = {};\n try{ firstSeen = JSON.parse(localStorage.getItem(firstSeenKey) || '{}'); }catch(e){}\n var fs = firstSeen[key] || Date.now();\n if(!firstSeen[key]){ firstSeen[key] = Date.now(); try{ localStorage.setItem(firstSeenKey, JSON.stringify(firstSeen)); }catch(e){} }\n list.push({\n robotId: r.id,\n robotName: r.name || r.id,\n code: code,\n severity: sev,\n message: r.readiness_message || code,\n firstSeen: fs,\n ack: _incidentAcks[key] || null,\n });\n }\n // prune stale firstSeen entries so localStorage doesn't grow forever\n try{\n var fsAll = JSON.parse(localStorage.getItem('rp-incident-first-seen-v1') || '{}');\n var keys = Object.keys(fsAll);\n var pruned = {};\n for(var k=0;k<keys.length;k++){ if(seen[keys[k]]) pruned[keys[k]] = fsAll[keys[k]]; }\n localStorage.setItem('rp-incident-first-seen-v1', JSON.stringify(pruned));\n }catch(e){}\n return list;\n }\n function renderIncidents(){\n var incidents = _collectActiveIncidents();\n // severity filter\n if(_incidentSevFilter){\n incidents = incidents.filter(function(i){ return i.severity === _incidentSevFilter; });\n }\n // sort: bad \u2192 warn \u2192 info, then by firstSeen desc\n var sevOrder = { bad: 0, warn: 1, info: 2 };\n incidents.sort(function(a,b){\n var so = (sevOrder[a.severity]||9) - (sevOrder[b.severity]||9);\n if(so !== 0) return so;\n return b.firstSeen - a.firstSeen;\n });\n var tbody = $('rp-inc-tbody'), empty = $('rp-inc-empty'), wrap = $('rp-inc-wrap');\n var badge = $('rps-incidents-badge');\n var total = _collectActiveIncidents().length;\n if(badge) badge.textContent = total;\n if(!incidents.length){\n if(tbody) tbody.innerHTML = '';\n if(wrap) wrap.style.display='none';\n if(empty){ empty.style.display='block'; empty.textContent = total ? 'no incidents match this filter' : 'no active incidents \u2014 every robot is ready'; }\n } else {\n if(empty) empty.style.display='none';\n if(wrap) wrap.style.display='block';\n var html = '';\n for(var i=0;i<incidents.length;i++){\n var inc = incidents[i];\n var sevClass = inc.severity === 'bad' ? 'rp-inc-sev-bad' : (inc.severity === 'warn' ? 'rp-inc-sev-warn' : 'rp-inc-sev-info');\n var sevMark = inc.severity === 'bad' ? '\u2717' : (inc.severity === 'warn' ? '\u26A0' : '\u00B7');\n var dur = fmtDur(Date.now() - inc.firstSeen);\n var fsRel = rpFmtRelative(inc.firstSeen);\n var fsAbs = rpFmtAbs(inc.firstSeen);\n var ackTag = inc.ack ? '<span class=\"rp-inc-acked-tag\" title=\"acked by '+esc(inc.ack.by)+' at '+rpFmtAbs(inc.ack.t)+'\">\u2713 acked</span>' : '';\n html += '<tr class=\"'+sevClass+'\">'\n + '<td><span class=\"rp-inc-sev\">'+sevMark+' '+esc(inc.severity)+'</span></td>'\n + '<td><b>'+esc(inc.robotName)+'</b> <span class=\"rp-inc-id\">'+esc(inc.robotId)+'</span></td>'\n + '<td><span class=\"mono\">'+esc(inc.code)+'</span></td>'\n + '<td><span class=\"t-rel\">'+esc(fsRel)+'</span><span class=\"t-abs\">'+esc(fsAbs)+'</span></td>'\n + '<td>'+esc(dur)+'</td>'\n + '<td>'+esc(inc.message)+' '+ackTag+'</td>'\n + '<td style=\"white-space:nowrap;\">';\n if(!inc.ack){\n html += '<button class=\"rp-btn\" data-inc-act=\"ack\" data-robot=\"'+esc(inc.robotId)+'\" data-code=\"'+esc(inc.code)+'\">Acknowledge</button>';\n } else {\n html += '<button class=\"rp-btn ghost\" data-inc-act=\"unack\" data-robot=\"'+esc(inc.robotId)+'\" data-code=\"'+esc(inc.code)+'\" title=\"'+esc('Acked by '+inc.ack.by+' at '+rpFmtAbs(inc.ack.t))+'\">undo</button>';\n }\n html += '</td></tr>';\n }\n tbody.innerHTML = html;\n }\n // also render the acknowledged list\n renderIncidentsAcknowledged();\n }\n function renderIncidentsAcknowledged(){\n var acks = Object.keys(_incidentAcks).map(function(k){\n var parts = k.split('|');\n return { key: k, robotId: parts[0], code: parts.slice(1).join('|'), t: _incidentAcks[k].t, by: _incidentAcks[k].by, message: _incidentAcks[k].message };\n });\n // drop acks whose underlying incident has cleared\n var stillActive = {};\n var active = _collectActiveIncidents();\n for(var i=0;i<active.length;i++){ stillActive[active[i].robotId+'|'+active[i].code] = true; }\n acks = acks.filter(function(a){ return !stillActive[a.key]; });\n // sort newest-first\n acks.sort(function(a,b){ return b.t - a.t; });\n var tbody = $('rp-inc-ack-tbody'), empty = $('rp-inc-ack-empty'), wrap = $('rp-inc-ack-wrap');\n var counter = $('rp-inc-ack-count');\n if(counter) counter.textContent = acks.length;\n if(!acks.length){\n if(tbody) tbody.innerHTML = '';\n if(wrap) wrap.style.display='none';\n if(empty) empty.style.display='block';\n return;\n }\n if(empty) empty.style.display='none';\n if(wrap) wrap.style.display='block';\n var html = '';\n for(var i=0;i<acks.length;i++){\n var a = acks[i];\n html += '<tr class=\"rp-inc-acked-row\">'\n + '<td><span class=\"t-rel\">'+esc(rpFmtRelative(a.t))+'</span><span class=\"t-abs\">'+esc(rpFmtAbs(a.t))+'</span></td>'\n + '<td><b>'+esc(a.by)+'</b></td>'\n + '<td>'+esc(a.robotId)+'</td>'\n + '<td><span class=\"mono\">'+esc(a.code)+'</span></td>'\n + '<td>'+esc(a.message||'')+'</td>'\n + '<td style=\"white-space:nowrap;\"><button class=\"rp-btn ghost\" data-inc-act=\"unack\" data-robot=\"'+esc(a.robotId)+'\" data-code=\"'+esc(a.code)+'\">remove</button></td>'\n + '</tr>';\n }\n tbody.innerHTML = html;\n }\n function fmtDur(ms){\n if(ms < 0) ms = 0;\n var s = Math.floor(ms/1000);\n if(s < 60) return s+'s';\n var m = Math.floor(s/60);\n if(m < 60) return m+'m '+(s%60)+'s';\n var h = Math.floor(m/60);\n if(h < 24) return h+'h '+(m%60)+'m';\n var d = Math.floor(h/24);\n return d+'d '+(h%24)+'h';\n }\n var lastRobots = [];\n function pollIncidents(){\n // KPI tiles: per-source liveness.\n renderLivenessKpis();\n // Reuse the most recent telemetry snapshot; if the user is on\n // the incidents tab, also kick a fresh fetch.\n if(rpSubTab === 'incidents'){\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store', headers: operatorHeaders()}).then(function(r){ return r.json(); }).then(function(d){\n lastRobots = robotsFrom(d) || [];\n renderIncidents();\n }).catch(function(){ renderIncidents(); });\n } else {\n renderIncidents();\n }\n }\n function renderLivenessKpis(){\n var now = Date.now();\n // 1. mesh (/fed/status) \u2014 lastStatus is set by /fed/status poll;\n // poll() writes to lastStatus whenever the fetch resolves.\n var mesh = (typeof lastStatus === 'object' && lastStatus) ? lastStatus : null;\n var meshT = mesh ? (lastFedStatusAt || null) : null;\n paintKpi('mesh', !!mesh, meshT, meshT ? fmtDur(now - meshT) + ' ago' : 'no successful poll', meshT ? (now - meshT < 15000 ? 'ok' : (now - meshT < 30000 ? 'warn' : 'bad')) : 'bad');\n // 2. scheduler (/robopark/api/telemetry) \u2014 track on a successful fetch\n var schedT = lastSchedulerPollAt || null;\n paintKpi('sched', !!schedT, schedT, schedT ? fmtDur(now - schedT) + ' ago' : 'no successful poll', schedT ? (now - schedT < 8000 ? 'ok' : (now - schedT < 20000 ? 'warn' : 'bad')) : 'bad');\n // 3. cameras \u2014 at least one camera source is \"ok\" across the fleet\n var camOk = 0, camTotal = 0;\n for(var i=0;i<lastRobots.length;i++){ var r=lastRobots[i]; if(r.id && r.id.indexOf('pad:')!==0){ camTotal++; if(r.connected && r.current_session_id) camOk++; } }\n var camDot = camTotal === 0 ? 'warn' : (camOk > 0 ? 'ok' : 'warn');\n paintKpi('cam', camTotal>0, camTotal, camOk + ' of ' + camTotal + ' have a live session', camDot);\n // 4. production mode \u2014 read the global + per-device effective flag\n var pmOn = (typeof settings !== 'undefined' && settings && settings.production_mode === true);\n var pmDot = pmOn ? 'ok' : 'warn';\n paintKpi('prod', pmOn, pmOn ? 'on' : 'off', pmOn ? 'global switch on' : 'global switch OFF \u2014 incidents will be informational only', pmDot);\n }\n function paintKpi(name, ok, value, meta, severity){\n var v = $('rp-inc-kpi-'+name+'-v');\n var m = $('rp-inc-kpi-'+name+'-meta');\n var d = $('rp-inc-kpi-'+name+'-dot');\n if(v) v.textContent = (value === null || value === undefined) ? '\u2014' : String(value);\n if(m) m.textContent = meta || '';\n if(d){\n d.className = 'pk-kpi-dot '+(severity === 'ok' ? 'ok' : severity === 'warn' ? 'warn' : severity === 'bad' ? 'bad' : '');\n }\n }\n // hook the acknowledge / undo buttons (delegated, idempotent)\n document.addEventListener('click', function(e){\n var b = e.target.closest && e.target.closest('[data-inc-act]');\n if(!b) return;\n if(rpReadOnly){ showOut('read-only node'); return; }\n var act = b.getAttribute('data-inc-act');\n var rid = b.getAttribute('data-robot');\n var code = b.getAttribute('data-code');\n if(!rid || !code) return;\n if(act === 'ack'){\n // find the message from the current incidents list\n var msg = '';\n var list = _collectActiveIncidents();\n for(var i=0;i<list.length;i++){ if(list[i].robotId === rid && list[i].code === code){ msg = list[i].message; break; } }\n _ackIncident(rid, code, msg);\n } else if(act === 'unack'){\n _unackIncident(rid, code);\n }\n });\n // severity-filter chips\n (function(){\n var chips = document.querySelectorAll('[data-inc-sev]');\n for(var i=0;i<chips.length;i++){\n (function(c){\n c.addEventListener('click', function(){\n for(var j=0;j<chips.length;j++) chips[j].classList.remove('active');\n c.classList.add('active');\n _incidentSevFilter = c.getAttribute('data-inc-sev') || '';\n renderIncidents();\n });\n })(chips[i]);\n }\n var rb = $('rp-inc-refresh'); if(rb) rb.addEventListener('click', pollIncidents);\n })();\n // Load the ack list from localStorage on first run.\n _loadIncidentAcks();\n\n // \u2500\u2500 Fleet sub-tab (uses the existing #rp-grid) \u2500\u2500\n // The Fleet sub-tab reuses the existing #rp-grid render in pollRobopark().\n // We add sparklines by overlaying on top of the existing cards.\n function pollFleet(){\n pollRobopark();\n }\n function drawRobotSparkline(canvas, sessions){\n if(!canvas || !canvas.getContext) return;\n var ctx = canvas.getContext('2d');\n var w = canvas.width = canvas.clientWidth;\n var h = canvas.height = canvas.clientHeight;\n ctx.clearRect(0,0,w,h);\n if(!sessions || sessions.length<2){\n ctx.fillStyle = 'rgba(158,147,180,0.4)';\n ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';\n ctx.fillText('no history', 6, h/2+3);\n return;\n }\n var counts = sessions.map(function(s){\n return (s.duration_seconds!=null?s.duration_seconds:(s.ended_at&&s.started_at?(Date.parse(s.ended_at)-Date.parse(s.started_at))/1000:0));\n });\n var mx = Math.max.apply(null, counts); if(mx<=0) mx=1;\n var pad = 2;\n var stepX = (w - pad*2) / (counts.length - 1);\n ctx.beginPath();\n for(var i=0;i<counts.length;i++){\n var x = pad + i*stepX;\n var y = h - pad - (counts[i]/mx) * (h - pad*2);\n if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);\n }\n // gold gradient line\n try{\n var grad = ctx.createLinearGradient(0,0,0,h);\n grad.addColorStop(0, '#FFD86B');\n grad.addColorStop(1, '#E6B23C');\n ctx.strokeStyle = grad;\n }catch(e){ ctx.strokeStyle = '#E6B23C'; }\n ctx.lineWidth = 1.4;\n ctx.lineJoin = 'round';\n ctx.stroke();\n // fill underneath\n ctx.lineTo(pad+(counts.length-1)*stepX, h-pad);\n ctx.lineTo(pad, h-pad);\n ctx.closePath();\n ctx.fillStyle = 'rgba(230,178,60,0.18)';\n ctx.fill();\n }\n\n // \u2500\u2500 Overview sub-tab \u2500\u2500\n function pollOverview(){\n Promise.all([\n getJson('/robopark/api/sessions?active_only=false&limit=200').catch(function(){ return []; }),\n getJson('/robopark/api/sessions/stats').catch(function(){ return {}; }),\n getJson('/robopark/api/telemetry').catch(function(){ return {robots:[],totals:{}}; }),\n getJson('/robopark/api/history?limit=20').catch(function(){ return []; })\n ]).then(function(results){\n renderOverview({\n sessions: Array.isArray(results[0])?results[0]:[],\n stats: results[1]||{},\n telemetry: results[2]||{robots:[],totals:{}},\n history: Array.isArray(results[3])?results[3]:[]\n });\n }).catch(function(){ /* soft fail */ });\n }\n function renderOverview(data){\n var sessions = data.sessions||[];\n var stats = data.stats||{};\n var telemetry = data.telemetry||{};\n var totals = telemetry.totals || {};\n var robots = Array.isArray(telemetry.robots)?telemetry.robots:[];\n // active sessions\n var active = 0; for(var i=0;i<sessions.length;i++){ if(!sessions[i].ended_at) active++; }\n var totalTrig = totals.trigger_count!=null?totals.trigger_count:stats.total_sessions;\n var today = stats.today_sessions!=null?stats.today_sessions:0;\n var avgLat = (function(){\n var vals=[];\n for(var i=0;i<robots.length;i++){ if(robots[i].avg_latency_ms!=null) vals.push(robots[i].avg_latency_ms); }\n if(!vals.length) return null;\n return Math.round(vals.reduce(function(a,b){return a+b;},0)/vals.length);\n })();\n var dropRate = totals.drop_rate!=null?totals.drop_rate:null;\n var total = stats.total_sessions!=null?stats.total_sessions:0;\n // uptime: ratio of robots connected \u2014 best effort (we don't have an uptime API,\n // so we fall back to \"robot count online\" derived from telemetry robots)\n // Render the KPI strip\n function kpi(l,v,cls,sub){\n return '<div class=\"rp-kpi\"><div class=\"l\">'+esc(l)+'</div><div class=\"v '+esc(cls||'')+'\">'+esc(v)+'</div>'+(sub?('<div class=\"s\">'+esc(sub)+'</div>'):'')+'</div>';\n }\n var html = '';\n html += kpi('Triggers (total)', totalTrig!=null?totalTrig:'\u2014', 'info', robots.length+' robot'+(robots.length===1?'':'s'));\n html += kpi('Today', today, today>0?'ok':'', 'sessions today');\n html += kpi('Active', active, active>0?'ok':'', 'live right now');\n html += kpi('Avg latency', avgLat!=null?(avgLat+'ms'):'\u2014', avgLat==null?'':avgLat>=500?'bad':avgLat>=200?'warn':'ok', 'across all robots');\n html += kpi('Drop rate', dropRate!=null?Math.round(dropRate*100)+'%':'\u2014', dropRate==null?'':dropRate>=0.2?'bad':dropRate>=0.05?'warn':'ok', 'failed / total');\n html += kpi('Total sessions', total, '', stats.avg_duration_seconds!=null?('avg '+rpFmtDuration(stats.avg_duration_seconds)):'');\n $('rp-overview-kpis').innerHTML = html;\n // mini chart: bucket sessions by hour for the last 60 minutes from started_at\n var buckets = new Array(12).fill(0); // 12 buckets \u00D7 5min = 60min\n var now = Date.now();\n for(var i=0;i<sessions.length;i++){\n var t = sessions[i].started_at ? Date.parse(sessions[i].started_at) : 0;\n if(!t) continue;\n var diff = now - t;\n if(diff<0 || diff>3600e3) continue;\n var idx = 11 - Math.floor(diff/(5*60e3));\n if(idx>=0 && idx<12) buckets[idx]++;\n }\n drawOverviewChart($('rp-overview-chart'), buckets);\n // top 3 robots by session count\n var ranked = robots.slice().sort(function(a,b){ return (b.sessions_total||0)-(a.sessions_total||0); });\n var top3 = ranked.slice(0,3);\n var top = $('rp-overview-top'), topMeta = $('rp-overview-top-meta');\n if(topMeta) topMeta.textContent = ranked.length?('of '+ranked.length+' robots'):'';\n if(!top3.length){ top.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1;\">No robot telemetry yet.</div>'; }\n else {\n var thtml='';\n for(var i=0;i<top3.length;i++){\n var x = top3[i];\n var name = x.name || x.id || 'robot';\n var avatar = rpAvatarFor(x.character_name||x.character_id||name);\n thtml += '<div class=\"rp-card\" style=\"padding:0.55rem 0.7rem;\">'\n + '<div class=\"rp-card-top\">'\n + (avatar?'<div class=\"rp-card-img\"><img alt=\"'+esc(name)+'\" src=\"'+esc(avatar+QS)+'\"></div>':'')\n + '<div style=\"min-width:0;flex:1;\"><h4 style=\"margin:0;font-size:0.78rem;\">'+esc(name)+'</h4>'\n + '<div class=\"rp-metrics\" style=\"grid-template-columns:repeat(3,1fr);gap:0.2rem 0.5rem;margin-top:0.3rem;\">'\n + '<div class=\"rp-m\"><span class=\"v\" style=\"font-size:0.9rem;\">'+(x.sessions_total||0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\" style=\"font-size:0.9rem;\">'+(x.trigger_count||0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\" style=\"font-size:0.9rem;\">'+(x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014')+'</span><span class=\"l\">latency</span></div>'\n + '</div></div></div></div>';\n }\n top.innerHTML = thtml;\n }\n // recent activity stream\n var stream = $('rp-overview-stream'), smeta = $('rp-overview-stream-meta');\n if(smeta) smeta.textContent = 'last '+(data.history?data.history.length:0);\n var hist = data.history || [];\n if(!hist.length){ stream.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">no recent activity</span></div>'; }\n else {\n var hhtml='';\n for(var i=0;i<hist.length;i++){\n var ev = hist[i];\n var ts = ev.timestamp;\n var cat = ev.category || '';\n var act = ev.action || '';\n var ent = (ev.entity_type||'')+(ev.entity_id?(' \u00B7 '+ev.entity_id):'');\n var det = ev.details || '';\n if(typeof det==='object') det = JSON.stringify(det);\n hhtml += '<div class=\"line\"><span class=\"t\">'+esc(clock(ts))+'</span>'\n + '<span class=\"ic\">'+esc(rpCategoryIcon(cat))+'</span>'\n + '<span class=\"n\" title=\"'+esc(cat)+'\">'+esc(String(cat).slice(0,8))+'</span>'\n + '<span class=\"d\"><b>'+esc(act)+'</b> '+esc(ent)+(det?(' \u2014 '+esc(String(det).slice(0,60))):'')+'</span></div>';\n }\n stream.innerHTML = hhtml;\n }\n // update overview \"now\" indicator\n var nowEl = $('rp-overview-now'); if(nowEl) nowEl.textContent = sessions.length+' recent \u00B7 '+active+' active';\n }\n function drawOverviewChart(canvas, buckets){\n if(!canvas || !canvas.getContext) return;\n var ctx = canvas.getContext('2d');\n var dpr = Math.max(1, Math.min(2, window.devicePixelRatio||1));\n var w = canvas.clientWidth, h = canvas.clientHeight;\n canvas.width = w*dpr; canvas.height = h*dpr;\n ctx.setTransform(dpr,0,0,dpr,0,0);\n ctx.clearRect(0,0,w,h);\n var padL=20, padR=8, padT=8, padB=14;\n var plotW = w - padL - padR, plotH = h - padT - padB;\n var mx = Math.max(1, Math.max.apply(null, buckets));\n // y grid (4 lines)\n ctx.strokeStyle = 'rgba(255,255,255,0.07)';\n ctx.lineWidth = 1;\n for(var g=0;g<=4;g++){\n var y = padT + (plotH * g/4);\n ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(padL+plotW,y); ctx.stroke();\n ctx.fillStyle = 'rgba(158,147,180,0.6)';\n ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';\n ctx.fillText(Math.round(mx-(mx*g/4)), 2, y+3);\n }\n // bars\n var n = buckets.length, barW = plotW/n;\n for(var i=0;i<n;i++){\n var v = buckets[i];\n var bh = (v/mx)*plotH;\n var x = padL + i*barW + 1;\n var y = padT + (plotH - bh);\n try{\n var grad = ctx.createLinearGradient(0,y,0,y+bh);\n grad.addColorStop(0, 'rgba(255,216,107,0.95)');\n grad.addColorStop(1, 'rgba(230,178,60,0.55)');\n ctx.fillStyle = grad;\n }catch(e){ ctx.fillStyle = '#E6B23C'; }\n ctx.fillRect(x, y, Math.max(1,barW-2), bh);\n }\n // x axis labels\n ctx.fillStyle = 'rgba(158,147,180,0.7)';\n ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';\n ctx.fillText('-60m', padL, h-2);\n var mid = Math.round(n/2);\n ctx.fillText('-30m', padL + mid*barW, h-2);\n ctx.fillText('now', padL + (n-1)*barW - 16, h-2);\n }\n\n // \u2500\u2500 init sub-tabs when the robopark tab is opened \u2500\u2500\n // We call buildRpSubTabs() the first time the user switches to the\n // Sessions & Telemetry main tab. From then on, setRpSubTab(name) reuses\n // it whenever the user picks a different sub-tab.\n var rpSubTabsInited = false;\n function initRpSubTabs(){\n if(rpSubTabsInited) return;\n rpSubTabsInited = true;\n buildRpSubTabs();\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 _lastScan = {};\n // Latest per-robot scheduler telemetry, keyed by scheduler robot name\n // (e.g. \"jaguar\") or id. Updated by pollRobopark() on every refresh\n // (every 3s) via Park.updateTelemetry(...). The Park node \u2192 telemetry\n // match is done in lookupTelemetry() by name.\n var _telemetryByName = {};\n // Remember the most recent in-session and ended timestamps per robot\n // so the \"ended\" ring can fade out for ~4s after a session ends,\n // and the \"live/talking\" status can persist briefly across polls.\n var _statusMem = {}; // name -> { lastActiveAt, lastEndedAt, prevReadiness }\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, pipelineTimer=null, deviceSelectorsReady=false;\n var previewOn=false, previewTimer=null; // on-demand operator camera preview\n var visionOn=false, visionRetryTimer=null; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)\n var lkRoom=null, micAnalyser=null, micMonitorSource=null, micMonitorGain=null, audioCtx=null, lkAudioEls=[], micMonitorConnecting=false, micMonitorState='no active conversation session', micMonitorAudible=false, micMonitorVolume=0.8;\n var imgs={}; // preloaded robot concept avatars\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // Plaza footprint and parking lot (upper-right corner of the plaza)\n var P_X0=0.2, P_Y0=-0.10, P_X1=PARK_W-0.2, P_Y1=PARK_D-0.45;\n // Plaza outline as a polygon \u2014 top edge stays straight, bottom-left dips up where the pond\n // cuts into the plaza, and the right edge curves slightly inward to match the aerial.\n var PLAZA_POLY=[\n [0.4, -0.10],\n [8.6, -0.10],\n [8.6, 1.7 ],\n [14.6, 1.7 ],\n [14.6, 5.5 ],\n [3.0, 5.5 ],\n [2.5, 5.0 ],\n [0.4, 4.5 ]\n ];\n var LOT_X0=9.0, LOT_Y0=-0.30, LOT_X1=14.8, LOT_Y1=1.6;\n // Beach strip at the very bottom edge of the park\n var BEACH_Y0=PARK_D-0.45, BEACH_Y1=PARK_D+0.3;\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: two staggered rows on the plaza, between the robot pad columns.\n // Robot pads in normalized 0..1: HUB(0.06,0.16), VOLT(0.18,0.26), TESLA(0.38,0.30),\n // JAGUAR(0.58,0.34), VIXEN(0.40,0.68), PANDA(0.82,0.52)\n // Back row of palms at y=0.22, between the robots at the top of the plaza.\n // Front row at y=0.62, between/around the lower robots (VIXEN/PANDA).\n // Plus 2 grass palms near the upper-left for the tent area.\n var PALMS=[\n [0.30,0.18],[0.50,0.18],[0.70,0.18],[0.90,0.18],\n [0.18,0.55],[0.28,0.55],[0.50,0.55],[0.65,0.55],\n [0.05,0.42],[0.05,0.62]\n ];\n // Pond / curved water feature at the lower-left of the plaza.\n var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};\n // Street traffic along the top road. Each car has x, y (world), color,\n // a per-car base speed (world units / second), a direction (+1 = right,\n // -1 = left), and a per-frame jitter range so cars never settle into\n // a stable cluster. y is one of two lane bands (\u2248 -0.65 for outbound,\n // \u2248 -1.18 for inbound) so cars don't overlap.\n //\n // Stability fix: the previous version always re-spawned a wrapped car\n // at x = -0.5 (or PARK_W + 0.5) \u2014 every car in the same lane teleported\n // to the same spot, so the slowest car instantly caught the fastest\n // one and the whole lane collapsed into a clump. We now (a) spawn\n // each car at a random x within the visible road, and (b) add a\n // \u00B15% per-frame speed jitter so cars gradually drift apart. Speeds\n // are also more spread (2.2 \u2192 4.6) so the natural ordering stays\n // stable long enough to look like real traffic.\n var ROAD_Y0=-1.5, ROAD_Y1=-0.35;\n var CARS=[\n {x:1.5, y:-0.65, color:'#f2c14e', speed:2.4, dir:+1},\n {x:6.5, y:-0.65, color:'#e0a23c', speed:3.6, dir:+1},\n {x:11.5, y:-0.65, color:'#d8d8e0', speed:4.4, dir:+1},\n {x:4.0, y:-1.18, color:'#7fb4ff', speed:2.6, dir:-1},\n {x:9.0, y:-1.18, color:'#ff7e6e', speed:3.4, dir:-1},\n {x:0.5, y:-1.18, color:'#5b8bd6', speed:4.2, dir:-1},\n ];\n // Per-car phase offsets so headlights / taillights / wheels don't all\n // blink in lockstep \u2014 gives a natural \"different cars, different\n // speeds\" feel.\n var _carPhase = CARS.map(function(_, i){ return i * 0.7; });\n // \u2500\u2500 Walking people \u2014 looping sprites that stroll along fixed plaza paths.\n // Each walker is a small canvas-drawn figure (head + body + two\n // swinging legs + swinging arms) with a vertical bob and a slight\n // body lean. A path is a list of [wx, wy] world points; the walker\n // eases between them at a per-walker speed, and \"greets\" any robot\n // pad it passes closely by pausing for ~1.4s in a wave pose while\n // the robot's pulse fires. Kept simple on purpose: no canvas\n // pathfinding, no random spawn, no state machine beyond a\n // pauseUntil timer. \u2500\u2500\n // Paths are aligned with the plaza chessboard so walkers appear to\n // step across the tiles. Coordinates are world units; the iso()\n // projection in drawWalker() handles the perspective.\n //\n // Six walkers total, distributed so at any moment at least a few are\n // visible across the plaza. segDurMs is how long a single segment\n // should take at this walker's pace \u2014 converting to a per-frame\n // step value happens in tickWalkers() so speeds are frame-rate\n // independent. ~3-4 seconds per segment reads as a calm stroll;\n // shorter = brisker.\n var WALKER_PATHS = [\n // Top of plaza \u2014 slow stroller between the hub and front row of robots. L\u2192R.\n { segDurMs: 5200, shirt:'#5b9bd5', pants:'#2a3f5c', pauseAt:[[3.5,0.4]] },\n // Lower half \u2014 loops past VIXEN and PANDA pads. R\u2192L.\n { segDurMs: 4200, shirt:'#c95b6f', pants:'#3a2a3a', pauseAt:[[11.0,5.1]] },\n // Mid-plaza \u2014 wanders past TESLA. L\u2192R.\n { segDurMs: 4600, shirt:'#7fb069', pants:'#3d4f2c', pauseAt:[[5.6,1.6]] },\n // Back of plaza \u2014 a quiet walker along the upper tiles. R\u2192L.\n { segDurMs: 6000, shirt:'#d68a3a', pants:'#4a3a26', pauseAt:[[8.0,-0.1]] },\n // Diagonal \u2014 crosses the plaza between JAGUAR and PANDA. L\u2192R.\n { segDurMs: 5000, shirt:'#a26fb0', pants:'#3a2a4a', pauseAt:[[7.0,3.4]] },\n // Slow elder at the front edge of the plaza. R\u2192L.\n { segDurMs: 7000, shirt:'#e8c39a', pants:'#5a4a2a', pauseAt:[[9.5,2.6]] },\n ];\n // Per-walker offsets so they don't all start at the same phase of\n // their loop \u2014 makes the plaza feel populated at all times rather\n // than empty for the first few seconds. Offsets are fractions of\n // total path length (0..1) so they spread evenly.\n var WALKER_PHASE_OFFSETS = [0.00, 0.18, 0.42, 0.66, 0.82, 0.30];\n // Build the world-space path for each walker spec. Path idx chooses\n // the route; all paths live on the plaza chessboard so walkers look\n // like they step across the tiles.\n function buildWalkerPath(spec){\n if(spec.idx === 0){\n return [[1.0,0.3],[3.5,0.4],[7.5,0.4],[9.8,0.3],[9.8,1.6],[1.0,1.6]];\n }\n if(spec.idx === 1){\n return [[13.0,3.6],[11.0,5.1],[8.0,5.4],[5.0,5.2],[3.0,4.6],[1.4,3.4]];\n }\n if(spec.idx === 2){\n return [[0.4,2.2],[3.0,2.4],[5.6,1.6],[8.4,1.6],[11.2,2.0],[13.4,2.2]];\n }\n if(spec.idx === 3){\n return [[13.5,-0.1],[11.0,-0.05],[8.0,-0.1],[5.0,-0.05],[2.4,0.1],[2.4,0.9]];\n }\n if(spec.idx === 4){\n return [[0.4,4.0],[3.0,3.6],[7.0,3.4],[10.0,3.8],[12.6,4.2],[12.6,5.2]];\n }\n // idx 5\n return [[13.4,2.6],[9.5,2.6],[6.0,2.8],[3.0,2.6],[0.4,2.4],[0.4,1.6]];\n }\n // Cumulative arc-length table for each walker's polyline, used to\n // convert a phase offset (0..1) into a (seg, segT) starting position\n // and to drive the per-frame step so all walkers cover ground at a\n // consistent world-space pace regardless of segment length.\n function buildPathLengths(path){\n var lens = [0];\n var total = 0;\n for(var i=0; i<path.length-1; i++){\n var dx = path[i+1][0]-path[i][0], dy = path[i+1][1]-path[i][1];\n total += Math.sqrt(dx*dx + dy*dy);\n lens.push(total);\n }\n return { lens: lens, total: total || 1 };\n }\n function pathPosAt(pathInfo, dist){\n // Returns {seg, t} for the given arc-length distance along the path.\n var d = ((dist % pathInfo.total) + pathInfo.total) % pathInfo.total;\n for(var i=0; i<pathInfo.lens.length-1; i++){\n if(d <= pathInfo.lens[i+1]){\n var segLen = pathInfo.lens[i+1] - pathInfo.lens[i] || 1;\n return { seg: i, t: (d - pathInfo.lens[i]) / segLen };\n }\n }\n return { seg: 0, t: 0 };\n }\n var walkers = WALKER_PATHS.map(function(spec, idx){\n var path = buildWalkerPath({idx:idx});\n var info = buildPathLengths(path);\n var phase = WALKER_PHASE_OFFSETS[idx % WALKER_PHASE_OFFSETS.length] || 0;\n var startPos = pathPosAt(info, phase * info.total);\n return {\n idx: idx,\n spec: spec,\n path: path,\n info: info,\n distance: startPos.seg + startPos.t, // current distance (in segments) along the path\n pauseUntil: 0, // perf.now() ms; walker holds while now < pauseUntil\n nextGreet: 0, // index into spec.pauseAt, for the next pause to fire\n };\n });\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 // \u2500\u2500 Per-robot scheduler telemetry \u2192 status \u2500\u2500\n // pollRobopark() pushes the latest /robopark/api/telemetry rows into\n // Park.updateTelemetry(); we key them by robot name. layout() then\n // looks up each robot node by its slot aliases and attaches a\n // status + telemetry field used by drawRobot() to drive the\n // status animations.\n function updateTelemetry(map){\n _telemetryByName = map || {};\n }\n function lookupTelemetry(node){\n if(!node) return null;\n // The hub is never a per-robot telemetry row \u2014 only robot slots are.\n if(node.kind !== 'robot') return null;\n // Try every alias the slot was built with: slot.label, slot.id,\n // slot.sub, slot.keys, and the matched mesh node's displayName.\n var slot = null;\n for(var i=0;i<SLOTS.length;i++){ if(SLOTS[i].label === node.label){ slot = SLOTS[i]; break; } }\n var aliases = [];\n if(slot) aliases = aliases.concat(slotAliases(slot));\n if(node.name) aliases.push(String(node.name).toLowerCase());\n if(node.telemKeys) for(var tk=0; tk<node.telemKeys.length; tk++) aliases.push(node.telemKeys[tk]);\n // Try each alias as a case-insensitive key against the telemetry map.\n for(var ai=0; ai<aliases.length; ai++){\n var a = aliases[ai];\n if(!a) continue;\n for(var key in _telemetryByName){\n if(!Object.prototype.hasOwnProperty.call(_telemetryByName, key)) continue;\n var kl = String(key).toLowerCase();\n if(kl === a || kl.indexOf(a) >= 0 || a.indexOf(kl) >= 0) return _telemetryByName[key];\n }\n }\n return null;\n }\n // Derive a coarse status string + a few extra flags from a\n // scheduler telemetry row. Status is one of: 'offline' | 'ready'\n // | 'live' | 'talking' | 'ended'. We also expose the raw readiness\n // string so the existing label can still read it.\n function computeStatus(tele, node){\n if(!node || node.kind !== 'robot') return { status:'idle', readiness:null };\n if(!node.connected) return { status:'offline', readiness:null };\n var mem = _statusMem[node.id] || (_statusMem[node.id] = { lastActiveAt:0, lastEndedAt:0, prevReadiness:null });\n var readiness = tele ? (tele.readiness_code || 'unknown') : null;\n var hasSession = !!(tele && (tele.current_session_id || tele.active_session || tele.in_session || tele.session_active));\n var sessionAge = tele ? (tele.session_age_seconds != null ? tele.session_age_seconds : null) : null;\n var activityAge = tele ? (tele.session_activity_age_seconds != null ? tele.session_activity_age_seconds : null) : null;\n var now = performance.now();\n // mark transitions\n if(readiness === 'in_session' && mem.prevReadiness !== 'in_session'){\n mem.lastActiveAt = now;\n }\n if(mem.prevReadiness === 'in_session' && readiness !== 'in_session'){\n mem.lastEndedAt = now;\n }\n mem.prevReadiness = readiness;\n // decide status\n var status = 'ready';\n if(hasSession){\n // active session. \"talking\" if the session has had activity\n // within the last 2.5s; otherwise \"live\" (idle but connected).\n if(activityAge != null && activityAge <= 2.5) status = 'talking';\n else status = 'live';\n } else if(readiness === 'in_session'){\n // server says in_session but we don't have the session id on\n // this row \u2014 treat as live.\n status = 'live';\n } else if(readiness && /stuck_session|stale_session/.test(readiness)){\n status = 'live'; // still a session going on, just unhealthy\n } else if(readiness === 'ready'){\n // Show \"ended\" ring for ~4s after a session ends, then drop back\n // to the calm \"ready\" (waiting) state.\n if(mem.lastEndedAt && (now - mem.lastEndedAt) < 4000) status = 'ended';\n else status = 'ready';\n } else if(readiness && /production_off|production_off_robot|device_missing|server_unavailable|heartbeat_stale/.test(readiness)){\n status = 'ready'; // still ready to talk, just gated\n }\n return {\n status: status,\n readiness: readiness,\n readinessMessage: tele ? tele.readiness_message : null,\n sessionAge: sessionAge,\n activityAge: activityAge,\n triggerCount: tele ? tele.trigger_count : null,\n drops: tele ? tele.drops : null,\n dropRate: tele ? tele.drop_rate : null,\n };\n }\n\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 var node = {\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 schedulerRobotId: match?(match.schedulerRobotId||match.schedulerDeviceId||''):'',\n schedulerDeviceId: match?(match.schedulerDeviceId||match.schedulerRobotId||''):'',\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n // slotId is the stable handle we use to persist operator-\n // chosen positions. slotNx/slotNy are the preset's default\n // coordinates so the user can always reset back.\n slotId: slot.id, slotNx: slot.nx, slotNy: slot.ny,\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n // Attach a status derived from the latest scheduler telemetry\n // (push-poll, so it can lag up to 3s behind /fed/status).\n if(kind === 'robot'){\n var tele = lookupTelemetry(node);\n var st = computeStatus(tele, node);\n node.telemetry = tele;\n node.status = st.status;\n node.readiness = st.readiness;\n node.readinessMessage = st.readinessMessage;\n node.sessionAge = st.sessionAge;\n node.activityAge = st.activityAge;\n }\n return node;\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 // re-apply any persisted Ctrl+drag overrides on top of the\n // fresh layout (the server may have also returned updated\n // slot nx/ny \u2014 those go through the override map too)\n _applyPosOverrides();\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 // State for Ctrl+drag robot repositioning on the park map. When\n // the user holds Ctrl and mousedown's a robot, the canvas stops\n // panning and instead drags that node around. On mouseup, the\n // new (nx, ny) is written to _parkPosOverrides and pushed to\n // the scheduler via a debounced PUT. Survives reloads because\n // the server stores nx/ny in the character_presets table.\n var _parkPosOverrides = {}; // presetId -> {nx, ny} (normalized 0..1)\n var _dragNode = null; // the robot node currently being dragged\n var _dragOffset = {x:0, y:0}; // screen-space offset from node center\n var _posSaveTimer = null; // debounce timer for the server PUT\n var _posSaveQueue = {}; // presetId -> {nx, ny} pending save\n function _isoToWorld(sx, sy){\n // Inverse of iso() in the same coordinate system. Given a\n // screen point, return the (wx, wy) world point. Used by the\n // Ctrl+drag handler to translate a mouse position back into\n // the iso coordinate space so the robot follows the cursor.\n var dx = (sx - (W-300)/2 - cam.x) / (TW*cam.z);\n var dy = (sy - H*0.42 - cam.y) / (TH*cam.z);\n // from iso(): dx = (wx-PARK_W/2) - (wy-PARK_D/2); dy = (wx-PARK_W/2) + (wy-PARK_D/2)\n // so wx = (dx+dy)/2 + PARK_W/2; wy = (dy-dx)/2 + PARK_D/2\n return { x: (dx+dy)*0.5 + PARK_W*0.5, y: (dy-dx)*0.5 + PARK_D*0.5 };\n }\n function _applyPosOverrides(){\n // Walk nodes and override their wx/wy from _parkPosOverrides if\n // we have a stored position. The slot's default nx/ny stays in\n // node.slotNx/slotNy so the user can \"reset\" back to defaults\n // if we ever expose that control.\n for(var i=0; i<nodes.length; i++){\n var n = nodes[i];\n if(!n.slotId) continue;\n var ov = _parkPosOverrides[n.slotId];\n if(ov){\n n.wx = Math.max(0, Math.min(PARK_W, ov.nx * PARK_W));\n n.wy = Math.max(0, Math.min(PARK_D, ov.ny * PARK_D));\n n._posOverridden = true;\n } else if(n._posOverridden){\n // The server just gave us a fresh layout and we don't have\n // a local override \u2014 fall back to the slot's default position.\n n.wx = n.slotNx * PARK_W;\n n.wy = n.slotNy * PARK_D;\n n._posOverridden = false;\n }\n }\n }\n function _schedulePosSave(){\n // Debounce: collapse a flurry of drag-end events into one\n // PUT after 350ms of quiet. Saves queue is merged so the final\n // PUT always reflects the latest position for each preset.\n if(_posSaveTimer) clearTimeout(_posSaveTimer);\n _posSaveTimer = setTimeout(_flushPosSaves, 350);\n }\n function _flushPosSaves(){\n var entries = [];\n for(var id in _posSaveQueue){\n if(Object.prototype.hasOwnProperty.call(_posSaveQueue, id)){\n entries.push({ id: id, nx: _posSaveQueue[id].nx, ny: _posSaveQueue[id].ny });\n }\n }\n _posSaveQueue = {};\n _posSaveTimer = null;\n if(!entries.length) return;\n // Persist to the scheduler. We POST to the dedicated\n // /api/character-presets/positions batch endpoint which clamps\n // nx/ny to [0.04, 0.96] so a stray drag can't park a robot\n // off the island.\n var body = JSON.stringify({ positions: entries });\n try{\n fetch('/api/character-presets/positions'+QS, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: body,\n keepalive: true\n }).then(function(r){\n if(r.ok) return r.json();\n throw new Error('HTTP '+r.status);\n }).then(function(j){\n if(typeof showOut === 'function'){\n showOut('park position saved \u00B7 '+(j.updated||entries.length)+' preset'+(entries.length===1?'':'s'));\n }\n }).catch(function(err){\n if(typeof showOut === 'function') showOut('park position save failed: '+err.message);\n });\n }catch(e){ /* offline \u2014 keep override in memory */ }\n }\n function _hitTestNode(mx, my){\n // Walk all visible nodes and return the first whose hit-box\n // contains (mx, my). Mirrors the existing click hit-test\n // but returns the node directly (and skips early if a drag\n // is already in progress).\n for(var i=0; i<nodes.length; i++){\n var n = nodes[i];\n var hb = n._hit;\n if(hb && mx>=hb.x && mx<=hb.x+hb.w && my>=hb.y && my<=hb.y+hb.h){\n return n;\n }\n }\n return null;\n }\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 // Panning: drag on the canvas (no Ctrl) pans the iso camera.\n // Ctrl+drag on a robot reposition the robot on the park map.\n // Each gesture is mutually exclusive \u2014 we keep one _isPanning\n // flag and one _dragNode at a time.\n var _isPanning = false;\n var _px = 0, _py = 0;\n C.addEventListener('mousedown',function(e){\n var r = C.getBoundingClientRect();\n var mx = e.clientX - r.left;\n var my = e.clientY - r.top;\n // Ctrl+drag: if a robot is under the cursor, pick it up.\n // Only ROBOT nodes are draggable \u2014 the hub, parked cars in the\n // lot, and ghosts for offline nodes stay locked to their slot.\n if(e.ctrlKey){\n var hit = _hitTestNode(mx, my);\n if(hit && hit.slotId && hit.kind === 'robot'){\n _dragNode = hit;\n var p = iso(hit.wx, hit.wy);\n _dragOffset.x = mx - p.x;\n _dragOffset.y = my - p.y;\n // prevent text selection while dragging\n e.preventDefault();\n return;\n }\n }\n // otherwise: start a pan\n _isPanning = true;\n _px = e.clientX; _py = e.clientY;\n });\n window.addEventListener('mouseup',function(){\n if(_dragNode){\n // commit the drag: record override, schedule server save\n var nx = _dragNode.wx / PARK_W;\n var ny = _dragNode.wy / PARK_D;\n var id = _dragNode.slotId;\n if(id){\n _parkPosOverrides[id] = { nx: nx, ny: ny };\n _posSaveQueue[id] = { nx: nx, ny: ny };\n _schedulePosSave();\n }\n _dragNode = null;\n }\n _isPanning = false;\n });\n window.addEventListener('mousemove',function(e){\n if(_dragNode){\n var r = C.getBoundingClientRect();\n var mx = e.clientX - r.left;\n var my = e.clientY - r.top;\n // screen point = mouse minus the original grab offset, so\n // the robot doesn't snap its center to the cursor\n var worldPt = _isoToWorld(mx - _dragOffset.x, my - _dragOffset.y);\n // clamp to the visible grass rectangle (with a small\n // inner margin so the avatar doesn't get clipped by the\n // gold wall or the hedge)\n var MARGIN = 0.4;\n _dragNode.wx = Math.max(MARGIN, Math.min(PARK_W - MARGIN, worldPt.x));\n _dragNode.wy = Math.max(MARGIN, Math.min(PARK_D - MARGIN, worldPt.y));\n return;\n }\n if(!_isPanning) return;\n cam.x += e.clientX - _px;\n cam.y += e.clientY - _py;\n _px = e.clientX; _py = e.clientY;\n });\n // Escape cancels an in-progress drag without saving\n window.addEventListener('keydown',function(e){\n if((e.key === 'Escape' || e.key === 'Esc') && _dragNode){\n // restore to the original override (or slot default)\n _dragNode.wx = _dragNode.slotNx * PARK_W;\n _dragNode.wy = _dragNode.slotNy * PARK_D;\n _dragNode = null;\n }\n });\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 // Suppress the click that fires at the end of a drag \u2014 we\n // don't want a single drag-release to also open the drawer.\n if(_dragNode) return;\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(now){\n // now is optional \u2014 only the road cars and water effects need a\n // wall-clock. We pass performance.now() in from frame() so every\n // animation reads the same time.\n if(typeof now !== 'number') now = performance.now();\n var _now = now;\n var z=cam.z;\n // \u2500\u2500 grass base \u2014 single uniform mid-green, no radial brightening \u2500\u2500\n // Park sits on a floating island now, so we want a flat, calm\n // grass tone across the whole field. The previous radial gradient\n // pushed a lighter green toward the center which read as a \"spot\"\n // \u2014 replaced with a single soft fill so the island feels like a\n // single piece of turf.\n var parkCx=PARK_W/2, parkCy=PARK_D/2;\n var gCent=iso(parkCx,parkCy);\n var gRad=X.createRadialGradient(gCent.x,gCent.y,12*z,gCent.x,gCent.y,Math.max(W,H)*0.62);\n gRad.addColorStop(0,'rgba(94,150,84,0.95)');\n gRad.addColorStop(0.5,'rgba(86,140,76,0.95)');\n gRad.addColorStop(1,'rgba(78,128,68,0.95)');\n // grass field \u2014 tight margin around the plaza + lot, NOT covering the road or the beach\n var GF_X0=0.0, GF_X1=PARK_W, GF_Y0=-0.32, GF_Y1=PARK_D-0.55;\n poly([[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]], gRad, 'rgba(140,200,150,0.10)',1);\n // (mowed-stripe effect removed \u2014 was the lighter green alternating\n // bands on the sides of the park. Keeping the lawn as a single\n // uniform tone so the park reads as a clean floating island.)\n // \u2500\u2500 outer hedge / foliage border just inside the grass field \u2500\u2500\n // Slightly ragged inner edge: extra points along each side to break the straight line.\n // The hedge wraps the grass field, which now hugs the plaza footprint (no side overhang).\n var hedgeW=0.9; // world units thick\n var pTL=[GF_X0,GF_Y0], pTR=[GF_X1,GF_Y0], pBR=[GF_X1,GF_Y1], pBL=[GF_X0,GF_Y1];\n // Build a ragged inner-edge polygon: along the inner edge we add jitter points.\n // We use 4 sides, each with 4 extra points (5 segments per side), jitter \u00B10.18.\n function jx(seed){ return ((Math.sin(seed*12.9898)*43758.5453)%1+1)%1; }\n function hedgeInnerPts(){\n // Returns an inner ring (hedge width hedgeW from outer), starting at top-left and going CW.\n var pts=[];\n // top edge: from pTL toward pTR (jitter along y)\n var segs=6;\n for(var i=0;i<=segs;i++){\n var t=i/segs;\n var x=pTL[0]+(pTR[0]-pTL[0])*t;\n var y=pTL[1]+hedgeW + (jx(i*3+1)-0.5)*0.34;\n pts.push([x,y]);\n }\n // right edge: from pTR toward pBR (jitter along x)\n for(var i2=1;i2<=segs;i2++){\n var t2=i2/segs;\n var x2=pTR[0]-hedgeW + (jx(i2*5+7)-0.5)*0.34;\n var y2=pTR[1]+(pBR[1]-pTR[1])*t2;\n pts.push([x2,y2]);\n }\n // bottom edge: from pBR toward pBL (jitter along y, reversed)\n for(var i3=1;i3<=segs;i3++){\n var t3=i3/segs;\n var x3=pBR[0]+(pBL[0]-pBR[0])*t3;\n var y3=pBR[1]-hedgeW + (jx(i3*7+13)-0.5)*0.34;\n pts.push([x3,y3]);\n }\n // left edge: from pBL toward pTL (jitter along x, reversed)\n for(var i4=1;i4<segs;i4++){\n var t4=i4/segs;\n var x4=pBL[0]+hedgeW + (jx(i4*11+19)-0.5)*0.34;\n var y4=pBL[1]+(pTL[1]-pBL[1])*t4;\n pts.push([x4,y4]);\n }\n return pts;\n }\n var innerRing=hedgeInnerPts();\n // full grass outer polygon = outer rect, then inner ring (use even-odd via closePath)\n X.save();\n X.beginPath();\n var outerRect=[[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]];\n for(var oi=0;oi<outerRect.length;oi++){ var op=iso(outerRect[oi][0],outerRect[oi][1]); if(oi===0)X.moveTo(op.x,op.y); else X.lineTo(op.x,op.y); }\n X.closePath();\n for(var ii=0;ii<innerRing.length;ii++){ var ip=iso(innerRing[ii][0],innerRing[ii][1]); if(ii===0)X.moveTo(ip.x,ip.y); else X.lineTo(ip.x,ip.y); }\n X.closePath();\n X.fillStyle='rgba(28,68,38,0.95)'; X.fill('evenodd');\n X.restore();\n // thin lighter inner rim ~0.15 inside the inner edge\n function innerRimPts(){\n var rim=[];\n var segs=6;\n for(var i=0;i<=segs;i++){ var t=i/segs; rim.push([(pTL[0]+(pTR[0]-pTL[0])*t), (pTL[1]+hedgeW-0.15) + (jx(i*3+1)-0.5)*0.30]); }\n for(var i2=1;i2<=segs;i2++){ var t2=i2/segs; rim.push([(pTR[0]-hedgeW+0.15) + (jx(i2*5+7)-0.5)*0.30, (pTR[1]+(pBR[1]-pTR[1])*t2)]); }\n for(var i3=1;i3<=segs;i3++){ var t3=i3/segs; rim.push([(pBR[0]+(pBL[0]-pBR[0])*t3), (pBR[1]-hedgeW+0.15) + (jx(i3*7+13)-0.5)*0.30]); }\n for(var i4=1;i4<segs;i4++){ var t4=i4/segs; rim.push([(pBL[0]+hedgeW-0.15) + (jx(i4*11+19)-0.5)*0.30, (pBL[1]+(pTL[1]-pBL[1])*t4)]); }\n return rim;\n }\n var rimPts=innerRimPts();\n X.save();\n X.beginPath();\n for(var ri2=0;ri2<rimPts.length;ri2++){ var rp=iso(rimPts[ri2][0],rimPts[ri2][1]); if(ri2===0)X.moveTo(rp.x,rp.y); else X.lineTo(rp.x,rp.y); }\n X.closePath();\n X.strokeStyle='rgba(96,148,86,0.7)'; X.lineWidth=0.8*z; X.stroke();\n X.restore();\n // grass noise \u2014 scattered darker tufts for texture (no lighter\n // tufts, so the field stays a single uniform mid-green)\n X.save();\n for(var ni=0;ni<40;ni++){\n var gnx=GF_X0 + ((ni*37)%100)/100*(GF_X1-GF_X0);\n var gny=GF_Y0 + ((ni*53)%100)/100*(GF_Y1-GF_Y0);\n var gp=iso(gnx,gny);\n X.fillStyle='rgba(56,112,62,0.22)';\n X.beginPath(); X.arc(gp.x+((ni*7)%13-6)*z,gp.y+((ni*11)%9-4)*z,1.6*z,0,7); X.fill();\n }\n X.restore();\n // \u2500\u2500 GOLD BORDER + FLOATING ISLAND DEPTH \u2500\u2500\n // The whole park now reads as a single floating slab of turf in\n // the sky. We render (in world space, around the grass field):\n // 1. a soft drop-shadow ellipse on the sky below the island\n // 2. a tapered rocky underside (darker, hanging from the edge)\n // 3. a thick gold rim tracing the grass perimeter\n // 4. a thin warm-glow outer ring (sells the gold as \"lit by\n // the warm sun\")\n // The underside and drop shadow give the park real depth so it\n // doesn't look like a flat sticker on the sky.\n // Build the grass perimeter polygon once (the same corners the\n // grass field uses, so the gold wall's top edge sits flush\n // against the grass). We use grassPerim(0) \u2014 no offset \u2014 for\n // the walls, and the perimeter itself is used again later for\n // the bottom face of the floating island (see UNDERSIDE_LIFT).\n function grassPerim(offset){\n // Use the same world corners as the grass field, offset outward.\n var o = offset;\n return [\n [GF_X0-o, GF_Y0-o],\n [GF_X1+o, GF_Y0-o],\n [GF_X1+o, GF_Y1+o],\n [GF_X0-o, GF_Y1+o]\n ];\n }\n // 1. drop shadow on the sky \u2014 a wide soft ellipse below the\n // island's bottom edge (in screen space, large + soft).\n var botL = iso(GF_X0, GF_Y1);\n var botR = iso(GF_X1, GF_Y1);\n var botM = { x:(botL.x+botR.x)/2, y:(botL.y+botR.y)/2 };\n var islandWidth = Math.abs(botR.x - botL.x);\n var shadowCx = botM.x;\n var shadowCy = botM.y + 28*z;\n var shadowRX = islandWidth * 0.55;\n var shadowRY = 22 * z;\n var dropShadow = X.createRadialGradient(shadowCx, shadowCy, 0, shadowCx, shadowCy, shadowRX);\n dropShadow.addColorStop(0, 'rgba(20, 24, 36, 0.55)');\n dropShadow.addColorStop(0.5, 'rgba(20, 24, 36, 0.25)');\n dropShadow.addColorStop(1, 'rgba(20, 24, 36, 0)');\n X.fillStyle = dropShadow;\n X.beginPath();\n X.ellipse(shadowCx, shadowCy, shadowRX, shadowRY, 0, 0, Math.PI*2);\n X.fill();\n // 2. rocky underside \u2014 draw the grass field polygon again, but\n // offset down by ~14*z and slightly darker, then close it into\n // a wedge. We do this with two filled paths:\n // - the bottom face: same perimeter polygon shifted down\n // - the side rim: a band that traces from the top of the\n // bottom face up to the top of the island (so the side\n // of the slab reads as dirt/rock)\n var UNDERSIDE_LIFT = 16 * z;\n var topPerim = grassPerim(0);\n var botPerim = topPerim.map(function(p){ return [p[0], p[1] + UNDERSIDE_LIFT / (TH*z) * (1/22)]; });\n // Convert botPerim into screen-space points (slightly down from top).\n var topScreen = topPerim.map(function(p){ return iso(p[0], p[1]); });\n var botScreen = topPerim.map(function(p){ return iso(p[0], p[1] + 1.0); });\n // right side face\n X.save();\n X.fillStyle = 'rgba(38, 50, 36, 0.92)';\n X.beginPath();\n X.moveTo(topScreen[1].x, topScreen[1].y);\n X.lineTo(topScreen[2].x, topScreen[2].y);\n X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);\n X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);\n X.closePath();\n X.fill();\n // bottom-right edge highlight (slight bevel)\n X.strokeStyle = 'rgba(80, 96, 60, 0.55)';\n X.lineWidth = 1.0*z;\n X.beginPath();\n X.moveTo(topScreen[2].x, topScreen[2].y);\n X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);\n X.stroke();\n // left side face\n X.fillStyle = 'rgba(28, 38, 26, 0.95)';\n X.beginPath();\n X.moveTo(topScreen[0].x, topScreen[0].y);\n X.lineTo(topScreen[1].x, topScreen[1].y);\n X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);\n X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);\n X.closePath();\n X.fill();\n // bottom-left edge highlight\n X.strokeStyle = 'rgba(70, 84, 52, 0.45)';\n X.beginPath();\n X.moveTo(topScreen[0].x, topScreen[0].y);\n X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);\n X.stroke();\n // bottom face \u2014 slightly tapered into a rough point so the slab\n // doesn't look like a flat box. We taper the bottom by ~25%.\n var tapered = topScreen.map(function(s, i){\n if(i === 0) return { x: s.x + 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };\n if(i === 1) return { x: s.x - 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };\n if(i === 2) return { x: s.x - 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };\n if(i === 3) return { x: s.x + 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };\n return { x: s.x, y: s.y + UNDERSIDE_LIFT };\n });\n X.fillStyle = 'rgba(20, 28, 20, 0.92)';\n X.beginPath();\n X.moveTo(topScreen[0].x, topScreen[0].y);\n X.lineTo(topScreen[1].x, topScreen[1].y);\n X.lineTo(tapered[1].x, tapered[1].y);\n X.lineTo(tapered[2].x, tapered[2].y);\n X.lineTo(tapered[3].x, tapered[3].y);\n X.lineTo(tapered[0].x, tapered[0].y);\n X.closePath();\n X.fill();\n // a few rocky stalactite bumps hanging from the underside so it\n // doesn't look like a perfect box either\n var bumpSpots = [\n { px: 0.18, len: 0.65 },\n { px: 0.34, len: 0.85 },\n { px: 0.52, len: 0.55 },\n { px: 0.68, len: 0.95 },\n { px: 0.86, len: 0.70 }\n ];\n for(var bpi=0; bpi<bumpSpots.length; bpi++){\n var bx = GF_X0 + bumpSpots[bpi].px * (GF_X1-GF_X0);\n var by = GF_Y1;\n var bp = iso(bx, by);\n var bpLen = bumpSpots[bpi].len * UNDERSIDE_LIFT;\n X.fillStyle = 'rgba(24, 32, 22, 0.88)';\n X.beginPath();\n X.moveTo(bp.x - 14*z, bp.y + 2*z);\n // 2-sided stalactite that tapers to a point\n X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x, bp.y + bpLen);\n X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x + 14*z, bp.y + 2*z);\n X.closePath();\n X.fill();\n }\n X.restore();\n // 3. RAISED GOLD WALLS \u2014 proper 3D parapet around the park.\n // The wall follows the grass perimeter (4-corner rectangle).\n // Each side has a visible front face that extends downward in\n // screen space, plus a thin top-edge highlight that catches\n // the warm sun light. Drawn AFTER the underside so the top\n // edge of the wall sits flush with the top of the grass.\n //\n // Geometry: the wall's TOP edge is the grass perimeter line.\n // Its BOTTOM edge is the same line shifted down by WALL_H\n // pixels. We draw each of the 4 sides as a quad (TL, TR,\n // BR, BL), then trace a thin gold line along the top of\n // each side for the highlight.\n var rimInner = grassPerim(0);\n var rimInnerScreen = rimInner.map(function(p){ return iso(p[0], p[1]); });\n var WALL_H = 26 * z; // wall height in screen pixels\n // Build bottom-edge screen points by shifting each top point\n // down by WALL_H.\n var rimBotScreen = rimInnerScreen.map(function(s){\n return { x: s.x, y: s.y + WALL_H };\n });\n // Front face: a single closed polygon (the grass perimeter top\n // + the grass perimeter bottom, traversed in reverse). Drawn\n // in two passes \u2014 first a darker base, then a lighter overlay\n // stripe near the top to give the wall some \"warm-light from\n // above\" shading.\n X.save();\n // base wall (slightly darker gold)\n X.fillStyle = 'rgba(176, 124, 44, 0.96)';\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);\n X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);\n X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);\n X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);\n X.closePath();\n X.fill();\n // warm-light gradient overlay \u2014 fades from bright gold at the\n // top of the wall to transparent at the bottom, so the wall\n // reads as if the top is catching the sun and the bottom is\n // in shadow.\n var wallGrad = X.createLinearGradient(0, rimInnerScreen[0].y, 0, rimInnerScreen[0].y + WALL_H);\n wallGrad.addColorStop(0, 'rgba(255, 226, 132, 0.85)');\n wallGrad.addColorStop(0.35, 'rgba(255, 196, 92, 0.55)');\n wallGrad.addColorStop(1, 'rgba(176, 124, 44, 0)');\n X.fillStyle = wallGrad;\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);\n X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);\n X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);\n X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);\n X.closePath();\n X.fill();\n // outer dark base line at the bottom of the wall (the bottom\n // edge against the sky) \u2014 sells the \"raised\" effect\n X.strokeStyle = 'rgba(124, 84, 28, 0.95)';\n X.lineWidth = 1.4 * z;\n X.beginPath();\n X.moveTo(rimBotScreen[0].x, rimBotScreen[0].y);\n X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);\n X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);\n X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);\n X.closePath();\n X.stroke();\n // top edge \u2014 gold band right at the grass line, with a thin\n // lighter highlight on top to catch the sun.\n X.strokeStyle = 'rgba(196, 138, 48, 0.95)';\n X.lineWidth = 4.0 * z;\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.closePath();\n X.stroke();\n // bright top highlight (thin, hottest color)\n X.strokeStyle = 'rgba(255, 232, 158, 0.95)';\n X.lineWidth = 1.2 * z;\n X.beginPath();\n X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);\n X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);\n X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);\n X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);\n X.closePath();\n X.stroke();\n // corner studs \u2014 small gold dots at each of the 4 perimeter\n // vertices so the wall reads as constructed panels, not a\n // single continuous block.\n for(var sci=0; sci<rimInnerScreen.length; sci++){\n var sPt = rimInnerScreen[sci];\n X.fillStyle = 'rgba(255, 220, 140, 0.95)';\n X.beginPath(); X.arc(sPt.x, sPt.y, 2.2 * z, 0, Math.PI*2); X.fill();\n X.fillStyle = 'rgba(196, 138, 48, 0.85)';\n X.beginPath(); X.arc(sPt.x, sPt.y, 3.4 * z, 0, Math.PI*2); X.stroke();\n }\n X.restore();\n // \u2500\u2500 flower spots \u2014 deterministic small dots in 3 colors, scattered in the lawn only \u2500\u2500\n X.save();\n var flowerColors=['rgba(232,124,164,0.85)','rgba(248,220,96,0.88)','rgba(248,248,240,0.85)'];\n for(var fi=0;fi<34;fi++){\n var fx=GF_X0 + jx(fi*17+3)*(GF_X1-GF_X0);\n var fy=hedgeW + jx(fi*23+11)*(GF_Y1-hedgeW*1.4);\n // skip plaza area (P_X0..P_X1, P_Y0..P_Y1) and pond area\n if(fx>=P_X0-0.3 && fx<=P_X1+0.3 && fy>=P_Y0-0.3 && fy<=P_Y1+0.3) continue;\n var dxp=fx-POND.cx, dyp=fy-POND.cy;\n if(dxp*dxp/(POND.rx*POND.rx) + dyp*dyp/(POND.ry*POND.ry) < 1.1) continue;\n var fp=iso(fx,fy);\n X.fillStyle=flowerColors[fi%3];\n X.beginPath(); X.arc(fp.x+((fi*5)%7-3)*z, fp.y+((fi*9)%5-2)*z, 1.4*z + (fi%2)*0.5*z, 0, 7); X.fill();\n }\n X.restore();\n // beach strip at the very bottom (sandy)\n poly([[-1,BEACH_Y0],[PARK_W+1,BEACH_Y0],[PARK_W+1,BEACH_Y1],[-1,BEACH_Y1]],\n 'rgba(214,196,150,0.55)', 'rgba(180,160,120,0.5)', 1);\n // darker curb along the paved edge (drawn as a thin offset quad)\n var curbOff=0.12;\n var curbPoly=PLAZA_POLY.map(function(p){\n // expand outward a bit so the curb ring is visible just outside the plaza footprint\n return [p[0] + (p[0]<1?-curbOff:(p[0]>14?curbOff:0)), p[1] + (p[1]<-0.05?-curbOff:(p[1]>5?curbOff:0))];\n });\n poly(curbPoly, 'rgba(20,28,22,0.85)', null, 0);\n // paved plaza base \u2014 neutral stone so the tile pattern reads clearly (polygon outline)\n var pg=X.createLinearGradient(0,0,0,H);\n pg.addColorStop(0,'rgba(170,162,150,0.92)');\n pg.addColorStop(1,'rgba(132,124,114,0.92)');\n poly(PLAZA_POLY, pg, 'rgba(60,52,44,0.55)',1);\n // chessboard stone tile pattern \u2014 iterate a rectangle then clip to the plaza polygon\n X.save();\n X.beginPath();\n for(var pli=0;pli<PLAZA_POLY.length;pli++){\n var plp=iso(PLAZA_POLY[pli][0],PLAZA_POLY[pli][1]);\n if(pli===0) X.moveTo(plp.x,plp.y); else X.lineTo(plp.x,plp.y);\n }\n X.closePath();\n X.clip();\n var tile=1.5;\n var tx0=Math.floor(P_X0/tile)*tile, ty0=Math.floor(P_Y0/tile)*tile;\n var lite='rgba(206,200,188,0.92)', dark='rgba(118,108,98,0.92)';\n var liteBevel='rgba(238,232,218,0.55)', darkShade='rgba(60,52,44,0.45)';\n for(var gy2=ty0;gy2<P_Y1+1;gy2+=tile){\n for(var gx2=tx0;gx2<P_X1+1;gx2+=tile){\n var x0=gx2, y0=gy2, x1=gx2+tile, y1=gy2+tile;\n if(x1>P_X1) x1=P_X1; if(y1>P_Y1) y1=P_Y1;\n var isLite=(((Math.round((x0-P_X0)/tile)+Math.round((y0-P_Y0)/tile))%2)+2)%2===0;\n var top=isLite?lite:dark;\n // top face\n poly([[x0,y0],[x1,y0],[x1,y1],[x0,y1]], top, null, 0);\n // bevel: lighter top-left edge, darker bottom-right edge (use iso projection edge lines)\n var tlA=iso(x0,y0), tlB=iso(x1,y0), tlC=iso(x1,y1), tlD=iso(x0,y1);\n // top edge (lit)\n X.strokeStyle=isLite?liteBevel:'rgba(160,150,140,0.35)'; X.lineWidth=0.9*z;\n X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlB.x,tlB.y); X.stroke();\n // left edge (lit)\n X.strokeStyle=isLite?liteBevel:'rgba(150,140,130,0.30)';\n X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlD.x,tlD.y); X.stroke();\n // bottom edge (shaded)\n X.strokeStyle=darkShade;\n X.beginPath(); X.moveTo(tlD.x,tlD.y); X.lineTo(tlC.x,tlC.y); X.stroke();\n // right edge (shaded)\n X.strokeStyle=darkShade;\n X.beginPath(); X.moveTo(tlB.x,tlB.y); X.lineTo(tlC.x,tlC.y); X.stroke();\n }\n }\n X.restore();\n // outer plaza border for a clean curb (use the polygon)\n poly(PLAZA_POLY, null, 'rgba(50,44,36,0.7)', 1.4*z);\n // \u2500\u2500 sun-shade pavilions on the plaza (4 of them) \u2500\u2500\n // (pavilions removed \u2014 the chessboard plaza reads better without canopies)\n // parking lot \u2014 upper-right corner of the plaza (after the road)\n // curb ring around the lot\n poly([[LOT_X0-0.08,LOT_Y0-0.08],[LOT_X1+0.08,LOT_Y0-0.08],[LOT_X1+0.08,LOT_Y1+0.08],[LOT_X0-0.08,LOT_Y1+0.08]],\n 'rgba(20,20,24,0.95)', null, 0);\n // asphalt surface\n poly([[LOT_X0,LOT_Y0],[LOT_X1,LOT_Y0],[LOT_X1,LOT_Y1],[LOT_X0,LOT_Y1]],\n 'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.9)', 1);\n // access driveway from lot down into the plaza (thin asphalt strip)\n poly([[8.5, 1.5],[10.0, 1.5],[10.6, 2.5],[9.1, 2.5]],\n 'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.85)', 0.8*z);\n // parking bay lines (white) \u2014 2 rows\n X.strokeStyle='rgba(238,238,232,0.85)'; X.lineWidth=1.1*z; X.setLineDash([]);\n var bays=6;\n var lotH=LOT_Y1-LOT_Y0;\n var rowYs=[LOT_Y0+lotH*0.32, LOT_Y0+lotH*0.70];\n for(var ri2=0;ri2<rowYs.length;ri2++){\n for(var bi=1;bi<bays;bi++){\n var bx=LOT_X0+bi*((LOT_X1-LOT_X0)/bays);\n var ba=iso(bx, rowYs[ri2]-0.18), bb=iso(bx, rowYs[ri2]+0.18);\n X.beginPath(); X.moveTo(ba.x,ba.y); X.lineTo(bb.x,bb.y); X.stroke();\n }\n }\n // front edge line\n var feA=iso(LOT_X0+0.12,LOT_Y1-0.22), feB=iso(LOT_X1-0.12,LOT_Y1-0.22);\n X.beginPath(); X.moveTo(feA.x,feA.y); X.lineTo(feB.x,feB.y); X.stroke();\n // back edge line\n var beA=iso(LOT_X0+0.12,LOT_Y0+0.22), beB=iso(LOT_X1-0.12,LOT_Y0+0.22);\n X.beginPath(); X.moveTo(beA.x,beA.y); X.lineTo(beB.x,beB.y); X.stroke();\n // parked cars in 2 rows\n var bayW=(LOT_X1-LOT_X0)/bays;\n var parkedColors=['#d44a4a','#5b8bd6','#e8e6dc','#f2c14e','#7fb4ff','#5fa060','#c87ed4','#e0a23c','#9aa0a8','#ff7e6e'];\n for(var pri=0;pri<rowYs.length;pri++){\n for(var pbi=0;pbi<bays-1;pbi++){\n var pcx=LOT_X0+0.7+pbi*bayW;\n if(pbi===0 || pbi===bays-2) continue; // skip end bays for some breathing room\n drawCar(pcx, rowYs[pri], parkedColors[(pri*bays+pbi)%parkedColors.length]);\n }\n }\n // street band along the far edge + dashed centerline + road cars\n poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.92)', 'rgba(255,255,255,0.08)',1);\n X.strokeStyle='rgba(232,210,120,0.55)'; X.setLineDash([9*z,9*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 // animated road cars \u2014 positions updated each frame by tickCars()\n // draw order: outbound lane first (front), then inbound (back) for natural overlap\n for(var cai=0; cai<CARS.length; cai++){\n var car = CARS[cai];\n drawCar(car.x, car.y, car.color, car.dir, _carPhase[cai]||0, _now);\n }\n // white event tent in the left grass strip (west of the plaza)\n drawTent(-0.15, 0.5);\n // \u2500\u2500 lake \u2014 wider sandy shore with soft radial fade to grass \u2500\u2500\n // shore ring: a wider light sand band outside the water with a soft radial fade.\n X.save();\n var shoreRings=18;\n for(var shr=shoreRings; shr>=0; shr--){\n var t=shr/shoreRings;\n var rx=POND.rx + 0.5*(1-t);\n var ry=POND.ry + 0.5*(1-t);\n var alpha=0.55*(1-t)*(1-t);\n var col='rgba(228,210,168,'+alpha.toFixed(3)+')';\n var ringPts=[];\n for(var sk=0;sk<Math.PI*2;sk+=Math.PI/16){\n ringPts.push([POND.cx+rx*Math.cos(sk), POND.cy+ry*Math.sin(sk)*0.7]);\n }\n poly(ringPts, col, null, 0);\n }\n X.restore();\n // thin outer foam ring (kept for sparkle)\n var foamPts=[];\n for(var tf=0;tf<Math.PI*2;tf+=Math.PI/16){\n foamPts.push([POND.cx+(POND.rx+0.06)*Math.cos(tf), POND.cy+(POND.ry+0.05)*Math.sin(tf)*0.7]);\n }\n poly(foamPts, 'rgba(232,244,248,0.45)', 'rgba(255,255,255,0.5)', 1.0*z);\n // water outline (outer ellipse)\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 // \u2500\u2500 lake bed \u2014 3 concentric ellipses with progressively darker teal/blue, slightly offset toward upper-back \u2500\u2500\n function lakeEllipse(cx,cy,rx,ry,offX,offY){ var a=[]; for(var k=0;k<Math.PI*2;k+=Math.PI/16){ a.push([cx+offX+rx*Math.cos(k), cy+offY+ry*Math.sin(k)*0.7]); } return a; }\n // Outer: teal\n poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.70, POND.ry*0.70, -0.10, -0.06), 'rgba(112,196,212,0.55)', null, 0);\n // Mid: deeper teal\n poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.45, POND.ry*0.45, -0.18, -0.10), 'rgba(56,148,178,0.78)', null, 0);\n // Inner: deep blue\n poly(lakeEllipse(POND.cx, POND.cy, POND.rx*0.22, POND.ry*0.22, -0.22, -0.12), 'rgba(20,82,124,0.92)', null, 0);\n // water surface outline (drawn on top of the depth layers for a clean edge)\n poly(pts, null, 'rgba(120,200,222,0.6)',1.6*z);\n // \u2500\u2500 sun-glare streak \u2014 translucent white diagonal ellipse at upper-right of lake \u2500\u2500\n var glareCx=POND.cx+0.55, glareCy=POND.cy-0.55;\n var glareP=iso(glareCx, glareCy);\n var glareGrad=X.createRadialGradient(glareP.x, glareP.y, 2*z, glareP.x, glareP.y, 28*z);\n glareGrad.addColorStop(0,'rgba(255,255,255,0.32)');\n glareGrad.addColorStop(0.6,'rgba(255,255,255,0.10)');\n glareGrad.addColorStop(1,'rgba(255,255,255,0)');\n X.save();\n X.translate(glareP.x, glareP.y);\n X.rotate(-Math.PI/4);\n X.fillStyle=glareGrad;\n X.beginPath(); X.ellipse(0, 0, 26*z, 6*z, 0, 0, Math.PI*2); X.fill();\n X.restore();\n // \u2500\u2500 animated ripple/highlight \u2014 thin white elliptical arcs that drift across the lake \u2500\u2500\n var pondCent=iso(POND.cx, POND.cy);\n var ripplePhase=Math.sin(_now/1500);\n X.save();\n X.strokeStyle='rgba(232,248,252,0.7)'; X.lineWidth=1.0*z; X.lineCap='round';\n for(var rk=0;rk<4;rk++){\n var drift=(rk*0.6 + ripplePhase*0.8) % (POND.rx*1.4) - POND.rx*0.7;\n var rxr2=Math.abs(ripplePhase)*POND.rx*0.35 + POND.rx*0.18 + rk*0.18;\n var ryr2=POND.ry*0.18 + rk*0.10;\n var rCx2=POND.cx + drift*0.4;\n var rCy2=POND.cy - 0.10 + rk*0.12;\n var rA2=iso(rCx2-rxr2, rCy2);\n var rB2=iso(rCx2+rxr2, rCy2);\n X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(_now/900 + rk));\n X.beginPath();\n X.moveTo(rA2.x, rA2.y);\n X.quadraticCurveTo(pondCent.x, rA2.y - 3*z, rB2.x, rB2.y);\n X.stroke();\n }\n X.globalAlpha=1;\n X.restore();\n // \u2500\u2500 lily pads \u2014 small green ellipse clusters at deterministic positions \u2500\u2500\n var lilySpots=[\n {x: POND.cx-1.8, y: POND.cy+0.25, s: 0.95},\n {x: POND.cx+1.5, y: POND.cy+0.45, s: 1.05},\n {x: POND.cx-0.3, y: POND.cy-0.55, s: 0.85},\n {x: POND.cx+2.1, y: POND.cy-0.35, s: 0.9}\n ];\n for(var li=0;li<lilySpots.length;li++){\n var lp=iso(lilySpots[li].x, lilySpots[li].y);\n var lsz=lilySpots[li].s*z;\n // lily pad: ellipse with a notch cut from the right side\n X.save();\n X.fillStyle='rgba(72,140,72,0.85)';\n X.beginPath();\n X.ellipse(lp.x, lp.y, 5*lsz, 3.2*lsz, 0, -0.4, Math.PI*2-0.4);\n X.lineTo(lp.x, lp.y);\n X.closePath(); X.fill();\n // notch (darker wedge) \u2014 small triangle cut-out\n X.fillStyle='rgba(30,90,40,0.7)';\n X.beginPath();\n X.moveTo(lp.x, lp.y);\n X.lineTo(lp.x+5.2*lsz, lp.y-0.6*lsz);\n X.lineTo(lp.x+5.2*lsz, lp.y+0.6*lsz);\n X.closePath(); X.fill();\n // highlight\n X.fillStyle='rgba(150,210,130,0.45)';\n X.beginPath(); X.ellipse(lp.x-1.2*lsz, lp.y-0.8*lsz, 2*lsz, 0.8*lsz, 0, 0, Math.PI*2); X.fill();\n // tiny flower on one of them\n if(li===1){\n X.fillStyle='rgba(252,220,232,0.95)';\n X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 1.2*lsz, 0, Math.PI*2); X.fill();\n X.fillStyle='rgba(248,184,200,0.95)';\n X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 0.6*lsz, 0, Math.PI*2); X.fill();\n }\n X.restore();\n }\n // \u2500\u2500 tiny fountain \u2014 3 concentric rings of decreasing opacity around lake centroid \u2500\u2500\n X.save();\n for(var fk=0;fk<3;fk++){\n var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(_now/600 + fk));\n var falpha=0.55 - fk*0.18;\n var fcol='rgba(220,240,248,'+falpha.toFixed(3)+')';\n var fringPts=[];\n for(var fj=0;fj<Math.PI*2;fj+=Math.PI/14){\n fringPts.push([POND.cx + fr*Math.cos(fj), POND.cy + fr*Math.sin(fj)*0.7]);\n }\n poly(fringPts, null, fcol, 1.0*z);\n }\n X.restore();\n // \u2500\u2500 fully styled walking path \u2014 from plaza bottom edge curving right through the grass \u2500\u2500\n X.lineCap='round'; X.lineJoin='round';\n var pathPts=[\n [3.5, 5.7],\n [4.2, 6.2],\n [5.4, 6.5],\n [6.8, 6.5],\n [8.2, 6.2],\n [9.4, 5.7]\n ];\n function strokePath(strokeW, strokeStyle, dashed, dash){\n X.beginPath();\n for(var spi=0;spi<pathPts.length;spi++){\n var spp=iso(pathPts[spi][0], pathPts[spi][1]);\n if(spi===0) X.moveTo(spp.x, spp.y); else X.lineTo(spp.x, spp.y);\n }\n X.lineWidth=strokeW;\n X.strokeStyle=strokeStyle;\n if(dashed){ X.setLineDash(dash); } else { X.setLineDash([]); }\n X.stroke();\n }\n // edge stroke (grass border) \u2014 drawn first, slightly wider\n strokePath(4.0*z + 1.0*z, 'rgba(140,200,160,0.55)', false, null);\n // base stroke \u2014 light tan compacted dirt\n strokePath(4.0*z, 'rgba(212,190,140,0.92)', false, null);\n // subtle darker speckle along the path for \"compacted dirt\" texture\n X.save();\n X.fillStyle='rgba(168,142,90,0.55)';\n for(var spt=0;spt<14;spt++){\n var sSeg=(spt+0.5)/14;\n var sIdx = Math.min(pathPts.length-2, Math.floor(sSeg*(pathPts.length-1)));\n var sT = sSeg*(pathPts.length-1) - sIdx;\n var sAx=pathPts[sIdx][0]*(1-sT)+pathPts[sIdx+1][0]*sT;\n var sAy=pathPts[sIdx][1]*(1-sT)+pathPts[sIdx+1][1]*sT;\n var sPx=iso(sAx,sAy);\n var sAng=Math.sin(spt*23.7)*1.4;\n X.beginPath(); X.arc(sPx.x+Math.cos(sAng)*1.4*z, sPx.y+Math.sin(sAng)*1.0*z, 0.5*z, 0, 7); X.fill();\n }\n X.restore();\n // a faint lighter highlight stripe down the middle\n strokePath(1.0*z, 'rgba(238,222,178,0.5)', false, null);\n X.setLineDash([]); X.lineCap='butt'; X.lineJoin='miter';\n // \u2500\u2500 path lanterns \u2014 vertical post + warm yellow glow at the top, 3 of them \u2500\u2500\n var lanternSpots=[\n [pathPts[1][0], pathPts[1][1]-0.2],\n [pathPts[3][0], pathPts[3][1]],\n [pathPts[5][0], pathPts[5][1]]\n ];\n for(var ln=0;ln<lanternSpots.length;ln++){\n var lpos=iso(lanternSpots[ln][0], lanternSpots[ln][1]);\n // post\n X.strokeStyle='rgba(36,30,28,0.92)'; X.lineWidth=1.6*z;\n X.beginPath(); X.moveTo(lpos.x, lpos.y); X.lineTo(lpos.x, lpos.y-1.5*z*10); X.stroke();\n // base plate\n X.fillStyle='rgba(48,40,36,0.92)';\n X.beginPath(); X.ellipse(lpos.x, lpos.y, 2.4*z, 0.9*z, 0, 0, Math.PI*2); X.fill();\n // glow at top (shadowBlur for warm glow)\n X.save();\n X.shadowColor='rgba(255,214,112,0.95)'; X.shadowBlur=12*z;\n X.fillStyle='rgba(255,234,140,0.95)';\n X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 1.8*z, 0, Math.PI*2); X.fill();\n X.restore();\n // bright core\n X.fillStyle='rgba(255,250,210,1)';\n X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 0.8*z, 0, Math.PI*2); X.fill();\n }\n // \u2500\u2500 playground equipment in the grass area (below the plaza) \u2500\u2500\n drawPlayground(13.2, 6.4, 'rgba(224,82,52,0.92)');\n drawPlayground(13.8, 7.0, 'rgba(248,176,52,0.92)');\n drawBlueCanopy(0.9, 6.6);\n }\n function drawTent(wx,wy){\n // Compact ridge tent: footprint 0.4 long x 0.28 deep (~ half a plaza tile), height 7*z.\n var z=cam.z, wLong=0.4, wShort=0.28, h=7*z;\n var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);\n var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);\n var rdL={ x:bkL.x, y:bkL.y - h };\n var rdR={ x:bkR.x, y:bkR.y - h };\n var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;\n // soft ground shadow\n X.fillStyle='rgba(0,0,0,0.30)';\n X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();\n // stone apron\n X.fillStyle='rgba(190,186,176,0.55)';\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.stroke();\n // back roof panel (shaded)\n X.fillStyle='rgba(196,200,212,0.97)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(bkR.x,bkR.y); X.lineTo(bkL.x,bkL.y); X.closePath(); X.fill();\n // front roof panel (lit)\n X.fillStyle='rgba(248,248,252,0.98)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n // ridge beam\n X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.9*z;\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 4 corner poles\n X.strokeStyle='rgba(80,60,40,0.92)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n X.strokeStyle='rgba(50,40,30,0.98)'; X.lineWidth=0.7*z;\n X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 2 mid poles on front edge\n var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };\n var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };\n var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };\n var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };\n X.strokeStyle='rgba(50,40,30,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();\n X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();\n // fabric scallops on the front eave\n X.strokeStyle='rgba(220,224,232,0.55)'; X.lineWidth=0.4*z;\n function scallop(x1,y1,x2,y2,sag){\n var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;\n X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();\n }\n scallop(frL.x,frL.y, midLF.x,midLF.y, 0.8*z);\n scallop(midLF.x,midLF.y, midRF.x,midRF.y, 0.8*z);\n scallop(midRF.x,midRF.y, frR.x,frR.y, 0.8*z);\n // small flag at the right ridge end\n X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(rdR.x,rdR.y); X.lineTo(rdR.x, rdR.y - 4*z); X.stroke();\n X.fillStyle='rgba(232,72,72,0.95)';\n X.beginPath(); X.moveTo(rdR.x,rdR.y-4*z); X.lineTo(rdR.x+2.5*z,rdR.y-3*z); X.lineTo(rdR.x,rdR.y-2*z); X.closePath(); X.fill();\n }\n function drawCar(wx,wy,col,dir,phase,now){\n var p=iso(wx,wy), z=cam.z;\n // Optional animation params: dir (+1 right / -1 left), phase (per-car\n // offset in seconds), now (performance.now in ms). Falls back to the\n // static parked-car look when these are undefined.\n var moving = (dir===1 || dir===-1) && typeof now === 'number';\n // gentle vertical bob so a moving car looks like it has suspension\n var bob = moving ? Math.sin((now/180 + (phase||0)*1000)) * 0.5*z : 0;\n // soft drop shadow \u2014 slight motion blur for moving cars\n X.fillStyle='rgba(0,0,0,0.32)';\n X.beginPath(); X.ellipse(p.x,p.y+4*z,13*z,5.5*z,0,0,7); X.fill();\n if(moving){\n // ground shadow streak behind the car (opposite to direction of travel)\n var sx = p.x - dir*8*z;\n X.fillStyle='rgba(0,0,0,0.10)';\n X.beginPath(); X.ellipse(sx, p.y+4.5*z, 9*z, 4*z, 0, 0, 7); X.fill();\n // soft headlight beam projected on the road in front of the car\n var beamFrontX = p.x + dir*16*z;\n var beam = X.createRadialGradient(beamFrontX, p.y+1*z, 1*z, beamFrontX, p.y+1*z, 14*z);\n beam.addColorStop(0, 'rgba(255,242,200,0.32)');\n beam.addColorStop(0.5, 'rgba(255,242,200,0.10)');\n beam.addColorStop(1, 'rgba(255,242,200,0)');\n X.save();\n X.fillStyle = beam;\n X.beginPath(); X.ellipse(beamFrontX, p.y+1*z, 14*z, 6*z, 0, 0, Math.PI*2); X.fill();\n X.restore();\n }\n // chassis (lower body)\n X.fillStyle=col;\n X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z+bob,26*z,11*z,3*z); X.fill();\n // chassis bottom shade\n X.fillStyle='rgba(0,0,0,0.22)';\n X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z+bob,26*z,4*z,2*z); X.fill();\n // roof (darker top)\n X.fillStyle='rgba(0,0,0,0.55)';\n X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();\n // roof tint with car color\n X.fillStyle=col;\n X.globalAlpha=0.6;\n X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();\n X.globalAlpha=1;\n // windshield highlight\n X.fillStyle='rgba(180,220,255,0.65)';\n X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z+bob,12*z,3*z,1.5*z); X.fill();\n // windshield shine\n X.fillStyle='rgba(255,255,255,0.45)';\n X.beginPath();\n X.moveTo(p.x-5*z,p.y-9*z+bob); X.lineTo(p.x-2*z,p.y-9*z+bob); X.lineTo(p.x-4*z,p.y-7*z+bob); X.closePath(); X.fill();\n // wheels \u2014 animated spokes when the car is moving (rotation = distance traveled)\n X.fillStyle='#0c0c10';\n X.beginPath(); X.arc(p.x-8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x+8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x-8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x+8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();\n // wheel rims with a moving spoke mark so wheels visibly spin\n X.fillStyle='rgba(200,200,210,0.75)';\n X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();\n X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();\n // wheel spoke (rotates with speed) \u2014 gives the impression of motion\n if(moving){\n var spokeAng = (now/40 + (phase||0)*1000) % (Math.PI*2);\n var spokeDx = Math.cos(spokeAng) * 1.6*z;\n var spokeDy = Math.sin(spokeAng) * 1.6*z;\n X.strokeStyle='rgba(220,220,230,0.85)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(p.x-8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x-8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();\n X.beginPath(); X.moveTo(p.x+8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x+8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();\n }\n // headlight (front \u2014 the side the car is driving toward)\n var frontX = p.x + (dir||1)*12*z;\n X.fillStyle='rgba(255,240,180,0.95)';\n X.beginPath(); X.arc(frontX, p.y-1*z+bob, 1.6*z, 0, 7); X.fill();\n X.fillStyle='rgba(255,240,180,0.4)';\n X.beginPath(); X.arc(frontX, p.y-1*z+bob, 3*z, 0, 7); X.fill();\n // taillight (rear \u2014 opposite side, slightly redder)\n var rearX = p.x - (dir||1)*12*z;\n X.fillStyle='rgba(255,80,80,0.85)';\n X.beginPath(); X.arc(rearX, p.y-1*z+bob, 1.2*z, 0, 7); X.fill();\n if(moving){\n // a small red glow on the road behind the car\n X.fillStyle='rgba(255,80,80,0.25)';\n X.beginPath(); X.arc(rearX - dir*3*z, p.y+1*z+bob, 2.6*z, 0, 7); X.fill();\n }\n // body highlight\n X.fillStyle='rgba(255,255,255,0.18)';\n X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z+bob,24*z,1.5*z,1*z); X.fill();\n }\n\n // \u2500\u2500 Atmosphere: sky, sun, distant mountains, clouds, haze \u2500\u2500\n // Painted on top of the cleared canvas and below the rest of the map\n // so the map looks like it sits in an actual world instead of on a\n // blank panel. Cheap to render (a handful of full-canvas fills + a\n // few silhouette paths + a small cloud set) and turns the whole\n // scene from \"miniature on a black velvet\" into \"park at golden hour\".\n //\n // Everything is drawn in screen space, not world space, because the\n // sky doesn't follow the iso projection \u2014 it's a backdrop, not a\n // surface. Distant mountains live just above the road horizon line,\n // the sun is fixed near the upper-right (a warm focal point), and a\n // handful of soft clouds drift slowly across the sky.\n function drawAtmosphere(now){\n if(!W || !H) return;\n var z = cam.z;\n\n // \u2500\u2500 sky gradient \u2014 warm late-afternoon palette (top to bottom) \u2500\u2500\n // Top: deep cobalt \u2192 mid: soft sky \u2192 low: warm gold haze. Anchored\n // to a virtual horizon line ~38% from the top of the stage so the\n // map always sits on the same horizon regardless of zoom.\n var horizon = H * 0.42;\n var sky = X.createLinearGradient(0, 0, 0, H);\n sky.addColorStop(0, 'rgba(46, 78, 132, 0.95)'); // deep sky\n sky.addColorStop(0.30, 'rgba(108, 152, 196, 0.85)'); // mid sky\n sky.addColorStop(0.46, 'rgba(214, 184, 138, 0.78)'); // warm horizon\n sky.addColorStop(0.55, 'rgba(244, 222, 168, 0.62)'); // soft golden glow\n sky.addColorStop(0.75, 'rgba(248, 232, 200, 0.20)'); // fades into the ground area\n sky.addColorStop(1, 'rgba(248, 232, 200, 0)');\n X.fillStyle = sky;\n X.fillRect(0, 0, W, H);\n\n // \u2500\u2500 sun \u2014 a soft warm disc with a wide glow halo \u2500\u2500\n // Anchored at ~78% across, 22% down so it reads as the off-screen\n // light source warming the whole scene. The glow pulses very\n // gently so it doesn't feel like a static sticker.\n var sunX = W * 0.78;\n var sunY = H * 0.22;\n var sunR = 28 * (0.95 + 0.05 * Math.sin(now/2400));\n // outer warm glow\n var glow = X.createRadialGradient(sunX, sunY, sunR*0.6, sunX, sunY, sunR*7);\n glow.addColorStop(0, 'rgba(255, 222, 140, 0.55)');\n glow.addColorStop(0.25,'rgba(255, 210, 130, 0.30)');\n glow.addColorStop(0.6, 'rgba(255, 200, 120, 0.08)');\n glow.addColorStop(1, 'rgba(255, 200, 120, 0)');\n X.fillStyle = glow;\n X.fillRect(0, 0, W, H);\n // sun disc\n var disc = X.createRadialGradient(sunX, sunY, sunR*0.2, sunX, sunY, sunR);\n disc.addColorStop(0, 'rgba(255, 250, 230, 1)');\n disc.addColorStop(0.6, 'rgba(255, 232, 170, 0.95)');\n disc.addColorStop(1, 'rgba(255, 210, 130, 0)');\n X.fillStyle = disc;\n X.beginPath(); X.arc(sunX, sunY, sunR, 0, Math.PI*2); X.fill();\n\n // \u2500\u2500 distant mountain silhouette (3 receding layers) \u2500\u2500\n // Each layer uses a slightly lighter color so the eye reads depth.\n // Anchored just below the horizon so the iso map sits in front of\n // them. Heights/shapes are deterministic so the silhouette doesn't\n // shimmer between frames.\n function mountainLayer(baseY, amp, segments, color, seedOff){\n X.fillStyle = color;\n X.beginPath();\n X.moveTo(0, H);\n // sample the silhouette using a sum of low-frequency sines\n for(var mx=0; mx<=W; mx+=8){\n var t = mx / W;\n var h = (\n Math.sin(t*6.3 + seedOff) * 0.5 +\n Math.sin(t*12.5 + seedOff*2.3) * 0.28 +\n Math.sin(t*22.1 + seedOff*0.7) * 0.14\n ) * amp;\n X.lineTo(mx, baseY - h);\n }\n X.lineTo(W, H);\n X.closePath();\n X.fill();\n }\n // farther layer (lighter haze, lower)\n mountainLayer(H*0.46, 28, 18, 'rgba(120, 132, 158, 0.45)', 1.7);\n // middle layer\n mountainLayer(H*0.48, 36, 16, 'rgba(86, 100, 124, 0.55)', 3.1);\n // closer ridge (darkest, in front of the plaza)\n mountainLayer(H*0.50, 22, 14, 'rgba(54, 68, 86, 0.60)', 0.4);\n\n // \u2500\u2500 soft cloud band \u2014 5 elongated ellipses drifting right \u2500\u2500\n // Drawn as flat white ellipses with low alpha so they look like\n // wisps, not puffs. Each cloud has its own period so they don't\n // march in lockstep.\n var cloudColors = [\n 'rgba(255,255,255,0.55)',\n 'rgba(255,250,240,0.45)',\n 'rgba(248,242,228,0.40)',\n 'rgba(255,250,235,0.35)',\n 'rgba(252,246,232,0.50)',\n ];\n for(var ci=0; ci<cloudColors.length; ci++){\n var period = 90 + ci*22; // seconds for a full pass\n var drift = ((now/1000) / period) % 1; // 0..1\n var cx = (drift * (W + 240)) - 120; // wrap from -120 to W+120\n var cy = H*0.10 + ci*16;\n var rx = 60 + ci*8;\n var ry = 10 + (ci%3)*3;\n X.fillStyle = cloudColors[ci];\n // fluffy edge: 3 overlapping ellipses\n X.beginPath(); X.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2); X.fill();\n X.beginPath(); X.ellipse(cx - rx*0.5, cy + ry*0.4, rx*0.7, ry*0.7, 0, 0, Math.PI*2); X.fill();\n X.beginPath(); X.ellipse(cx + rx*0.5, cy + ry*0.3, rx*0.7, ry*0.6, 0, 0, Math.PI*2); X.fill();\n }\n\n // \u2500\u2500 soft warm light wash on the upper third of the map \u2500\u2500\n // Sits in screen space too: a wide radial glow over the entire\n // map area that makes the grass + plaza read as if lit from the\n // direction of the sun (upper-right). Without this the map looks\n // like it was cut out and pasted in.\n var wash = X.createRadialGradient(sunX, sunY*0.7, 40, sunX, sunY*0.7, Math.max(W, H)*0.85);\n wash.addColorStop(0, 'rgba(255, 220, 150, 0.16)');\n wash.addColorStop(0.35, 'rgba(255, 200, 130, 0.06)');\n wash.addColorStop(1, 'rgba(255, 200, 130, 0)');\n X.fillStyle = wash;\n X.fillRect(0, 0, W, H);\n\n // \u2500\u2500 subtle haze band along the horizon \u2500\u2500\n // Sits just above the road so the cars pop against a soft\n // horizon-glow rather than a hard line.\n var haze = X.createLinearGradient(0, horizon - 30, 0, horizon + 50);\n haze.addColorStop(0, 'rgba(255, 220, 160, 0)');\n haze.addColorStop(0.5, 'rgba(255, 220, 160, 0.22)');\n haze.addColorStop(1, 'rgba(255, 220, 160, 0)');\n X.fillStyle = haze;\n X.fillRect(0, horizon - 30, W, 80);\n\n // \u2500\u2500 ambient particles \u2014 drifting motes / pollen / fireflies \u2500\u2500\n // 40 tiny dots that float across the scene. Each one is held in\n // an array; the array is seeded once (so they don't all start at\n // the same position) and advanced by a tiny per-frame velocity.\n // On first call we lazily seed it; on resize we re-distribute.\n if(!_atmos.particles){\n _atmos.particles = [];\n for(var pi=0; pi<40; pi++){\n _atmos.particles.push({\n x: Math.random()*W,\n y: H*0.20 + Math.random()*H*0.55,\n r: 0.8 + Math.random()*1.6,\n vx: -6 + Math.random()*12, // px/sec, mostly right\n vy: -2 + Math.random()*4, // slight up/down sway\n phase: Math.random()*Math.PI*2,\n twinkle: 0.5 + Math.random()*0.5, // brightness amplitude\n hue: Math.random() < 0.35 ? 'warm' : 'cool', // mix of gold + silver\n });\n }\n }\n var dt = _atmos.lastNow ? Math.min(64, now - _atmos.lastNow)/1000 : 0.016;\n _atmos.lastNow = now;\n for(var pj=0; pj<_atmos.particles.length; pj++){\n var pt = _atmos.particles[pj];\n // simple wrap\n pt.x += pt.vx * dt;\n pt.y += Math.sin(now/1200 + pt.phase) * 0.3;\n if(pt.x > W + 8) pt.x = -8;\n if(pt.x < -8) pt.x = W + 8;\n if(pt.y < H*0.10) pt.y = H*0.75;\n if(pt.y > H*0.80) pt.y = H*0.15;\n // soft twinkle alpha\n var a = 0.25 + 0.45 * Math.abs(Math.sin(now/700 + pt.phase)) * pt.twinkle;\n X.fillStyle = pt.hue === 'warm'\n ? 'rgba(255, 230, 160, '+a.toFixed(3)+')'\n : 'rgba(220, 235, 250, '+a.toFixed(3)+')';\n X.beginPath(); X.arc(pt.x, pt.y, pt.r, 0, Math.PI*2); X.fill();\n // tiny halo\n X.fillStyle = pt.hue === 'warm'\n ? 'rgba(255, 220, 140, '+(a*0.35).toFixed(3)+')'\n : 'rgba(200, 220, 245, '+(a*0.30).toFixed(3)+')';\n X.beginPath(); X.arc(pt.x, pt.y, pt.r*2.5, 0, Math.PI*2); X.fill();\n }\n }\n // Holds the persistent atmospheric state (particle positions + last tick time).\n var _atmos = { particles: null, lastNow: 0 };\n\n // Sun-shade pavilion: 4 thin white poles supporting a flat white square canopy.\n function drawPavilion(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n var canopy=12*z;\n var poleH=6*z;\n // soft ground shadow under the canopy\n X.fillStyle='rgba(0,0,0,0.18)';\n X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, canopy*1.1, canopy*0.42, 0, 0, 7); X.fill();\n // four corner poles (slight isometric offset)\n var poleOff=canopy*0.42;\n function pole(px,py){\n X.strokeStyle='rgba(240,240,244,0.95)';\n X.lineWidth=0.9*z;\n X.beginPath(); X.moveTo(px, py); X.lineTo(px, py-poleH); X.stroke();\n // tiny base dot\n X.fillStyle='rgba(40,40,48,0.6)';\n X.beginPath(); X.arc(px, py, 0.8*z, 0, 7); X.fill();\n }\n pole(p.x-poleOff, p.y+poleOff*0.5);\n pole(p.x+poleOff, p.y+poleOff*0.5);\n pole(p.x-poleOff, p.y-poleOff*0.5);\n pole(p.x+poleOff, p.y-poleOff*0.5);\n // canopy roof \u2014 flat white square (top diamond)\n X.fillStyle='rgba(248,248,252,0.97)';\n X.beginPath();\n X.moveTo(p.x, p.y-poleH-canopy*0.5);\n X.lineTo(p.x+canopy, p.y-poleH);\n X.lineTo(p.x, p.y-poleH+canopy*0.5);\n X.lineTo(p.x-canopy, p.y-poleH);\n X.closePath(); X.fill();\n // canopy rim/edge\n X.strokeStyle='rgba(170,172,180,0.85)';\n X.lineWidth=0.8*z;\n X.beginPath();\n X.moveTo(p.x, p.y-poleH-canopy*0.5);\n X.lineTo(p.x+canopy, p.y-poleH);\n X.lineTo(p.x, p.y-poleH+canopy*0.5);\n X.lineTo(p.x-canopy, p.y-poleH);\n X.closePath(); X.stroke();\n // soft highlight band on the top of the canopy\n X.fillStyle='rgba(255,255,255,0.55)';\n X.beginPath();\n X.moveTo(p.x-canopy*0.8, p.y-poleH-canopy*0.05);\n X.lineTo(p.x+canopy*0.8, p.y-poleH-canopy*0.05);\n X.lineTo(p.x+canopy*0.6, p.y-poleH+canopy*0.25);\n X.lineTo(p.x-canopy*0.6, p.y-poleH+canopy*0.25);\n X.closePath(); X.fill();\n }\n // Abstract playground equipment: a small cluster of colored geometric shapes.\n function drawPlayground(wx,wy,accent){\n var p=iso(wx,wy), z=cam.z;\n // ground shadow\n X.fillStyle='rgba(0,0,0,0.20)';\n X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, 9*z, 4*z, 0, 0, 7); X.fill();\n // square post (red/orange)\n X.fillStyle=accent || 'rgba(224,82,52,0.95)';\n X.fillRect(p.x-3*z, p.y-8*z, 6*z, 6*z);\n X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z;\n X.strokeRect(p.x-3*z, p.y-8*z, 6*z, 6*z);\n // yellow circle\n X.fillStyle='rgba(248,212,72,0.95)';\n X.beginPath(); X.arc(p.x+5*z, p.y-4*z, 2.4*z, 0, Math.PI*2); X.fill();\n X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();\n // blue triangle\n X.fillStyle='rgba(82,142,224,0.95)';\n X.beginPath();\n X.moveTo(p.x-6*z, p.y-2*z);\n X.lineTo(p.x-2*z, p.y-2*z);\n X.lineTo(p.x-4*z, p.y-7*z);\n X.closePath(); X.fill();\n X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();\n // tiny base bar\n X.strokeStyle='rgba(48,48,52,0.85)'; X.lineWidth=1.2*z;\n X.beginPath(); X.moveTo(p.x-5*z, p.y); X.lineTo(p.x+6*z, p.y); X.stroke();\n }\n // Larger blue canopy (event-tent style) for the grass area.\n function drawBlueCanopy(wx,wy){\n // Compact ridge tent (slightly bigger than drawTent, blue, no flag).\n var z=cam.z, wLong=0.55, wShort=0.35, h=10*z;\n var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);\n var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);\n var rdL={ x:bkL.x, y:bkL.y - h };\n var rdR={ x:bkR.x, y:bkR.y - h };\n var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;\n // ground shadow\n X.fillStyle='rgba(0,0,0,0.32)';\n X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();\n // stone apron\n X.fillStyle='rgba(190,186,176,0.55)';\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(bkR.x,bkR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.stroke();\n // back roof panel (shaded blue)\n X.fillStyle='rgba(58,104,176,0.97)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(bkR.x,bkR.y); X.lineTo(bkL.x,bkL.y); X.closePath(); X.fill();\n // front roof panel (lit blue)\n X.fillStyle='rgba(132,184,240,0.98)';\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.lineTo(frR.x,frR.y); X.lineTo(frL.x,frL.y); X.closePath(); X.fill();\n // ridge beam\n X.strokeStyle='rgba(20,40,76,0.95)'; X.lineWidth=1.0*z;\n X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 4 corner poles\n X.strokeStyle='rgba(40,60,90,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n X.strokeStyle='rgba(20,36,68,0.98)'; X.lineWidth=0.7*z;\n X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();\n X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();\n // 2 mid poles on front edge\n var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };\n var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };\n var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };\n var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };\n X.strokeStyle='rgba(20,36,68,0.95)'; X.lineWidth=0.5*z;\n X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();\n X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();\n // fabric scallops on the front eave\n X.strokeStyle='rgba(220,232,248,0.6)'; X.lineWidth=0.4*z;\n function scallop(x1,y1,x2,y2,sag){\n var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;\n X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();\n }\n scallop(frL.x,frL.y, midLF.x,midLF.y, 1.0*z);\n scallop(midLF.x,midLF.y, midRF.x,midRF.y, 1.0*z);\n scallop(midRF.x,midRF.y, frR.x,frR.y, 1.0*z);\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n // soft ground shadow\n X.fillStyle='rgba(0,0,0,0.30)';\n X.beginPath(); X.ellipse(p.x,p.y+3*z,14*z,5.5*z,0,0,7); X.fill();\n // trunk: tapered curved column drawn as a filled quad, with a darker right side\n var trunkH=46*z, trunkBaseW=4*z, trunkTopW=2.2*z;\n // base point and apex (slight curve to the right)\n var baseX=p.x, baseY=p.y;\n var cpx=baseX+5*z, cpy=baseY-trunkH*0.5;\n var apexX=baseX+2*z, apexY=baseY-trunkH;\n // sample two intermediate points to build the curved silhouette\n function curveAt(t){\n // quadratic bezier\n var u=1-t;\n return {\n x:u*u*baseX + 2*u*t*cpx + t*t*apexX,\n y:u*u*baseY + 2*u*t*cpy + t*t*apexY\n };\n }\n var sA=curveAt(0.0), sB=curveAt(0.33), sC=curveAt(0.66), sD=curveAt(1.0);\n // right (shaded) side of trunk \u2014 offset outward at each sample\n function off(px,py,t,w){\n // normal-ish: lean to the right\n return { x:px+w, y:py };\n }\n var rA=off(sA.x,sA.y,0,trunkBaseW), rB=off(sB.x,sB.y,0.33,trunkBaseW*0.78);\n var rC=off(sC.x,sC.y,0.66,trunkBaseW*0.55), rD=off(sD.x,sD.y,1,trunkTopW);\n // left (lit) side\n function offL(px,py,t,w){ return { x:px-w, y:py }; }\n var lA=offL(sA.x,sA.y,0,trunkBaseW), lB=offL(sB.x,sB.y,0.33,trunkBaseW*0.78);\n var lC=offL(sC.x,sC.y,0.66,trunkBaseW*0.55), lD=offL(sD.x,sD.y,1,trunkTopW);\n // shaded right half\n X.fillStyle='rgba(82,52,28,0.95)';\n X.beginPath();\n X.moveTo(rA.x,rA.y);\n X.quadraticCurveTo(cpx+trunkBaseW, cpy, rB.x,rB.y);\n X.quadraticCurveTo(cpx+trunkBaseW*0.55, cpy*0.4+apexY*0.6, rC.x,rC.y);\n X.lineTo(rD.x,rD.y);\n X.lineTo(sD.x,sD.y);\n X.lineTo(sC.x,sC.y);\n X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);\n X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);\n X.closePath(); X.fill();\n // lighter lit left edge of trunk\n X.fillStyle='rgba(148,102,62,0.95)';\n X.beginPath();\n X.moveTo(lA.x,lA.y);\n X.quadraticCurveTo(cpx-trunkBaseW, cpy, lB.x,lB.y);\n X.quadraticCurveTo(cpx-trunkBaseW*0.55, cpy*0.4+apexY*0.6, lC.x,lC.y);\n X.lineTo(sC.x,sC.y);\n X.lineTo(sB.x,sB.y);\n X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);\n X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);\n X.closePath(); X.fill();\n // trunk segment rings (texture)\n X.strokeStyle='rgba(48,28,14,0.55)'; X.lineWidth=0.8*z;\n for(var ri=1;ri<6;ri++){\n var tr=ri/6;\n var tl=curveAt(tr-0.04), trr=curveAt(tr+0.04);\n var tlW=(trunkBaseW*(1-tr)+trunkTopW*tr);\n X.beginPath();\n X.moveTo(tl.x-tlW,tl.y);\n X.lineTo(tl.x+tlW,tl.y);\n X.stroke();\n }\n // crown center\n var tx=apexX, ty=apexY;\n // coconuts near the crown\n X.fillStyle='rgba(60,38,20,0.95)';\n X.beginPath(); X.arc(tx-3*z,ty+2*z,2*z,0,7); X.fill();\n X.beginPath(); X.arc(tx+3.5*z,ty+2.5*z,2.1*z,0,7); X.fill();\n X.beginPath(); X.arc(tx+0.5*z,ty+4*z,1.8*z,0,7); X.fill();\n // coconut highlights\n X.fillStyle='rgba(180,140,90,0.6)';\n X.beginPath(); X.arc(tx-3.6*z,ty+1.4*z,0.7*z,0,7); X.fill();\n X.beginPath(); X.arc(tx+2.9*z,ty+1.9*z,0.7*z,0,7); X.fill();\n // frond layer 1 \u2014 drooping down (8 fronds)\n var frondCount=8;\n for(var a=0;a<frondCount;a++){\n var ang=(-Math.PI/2) + (a-(frondCount-1)/2)*(Math.PI*1.05/frondCount);\n // drooping fronds have a downward bias\n var droop=0.45;\n var tipDx=Math.cos(ang)*22*z;\n var tipDy=Math.sin(ang)*14*z + 6*z; // pull down\n var ctlDx=Math.cos(ang)*8*z + Math.sin(ang)*4*z;\n var ctlDy=Math.sin(ang)*4*z + 8*z;\n // frond gradient from dark base to light tip\n var fGrad=X.createLinearGradient(tx+ctlDx*0.2, ty+ctlDy*0.2, tx+tipDx, ty+tipDy);\n fGrad.addColorStop(0,'rgba(34,90,46,0.95)');\n fGrad.addColorStop(0.6,'rgba(60,140,68,0.92)');\n fGrad.addColorStop(1,'rgba(130,200,110,0.88)');\n X.fillStyle=fGrad;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);\n // tip point then back via a second control point that bows out\n var backCtlX=tx+tipDx*0.7 - Math.sin(ang)*3*z;\n var backCtlY=ty+tipDy*0.7 - Math.cos(ang)*2*z + 1*z;\n X.quadraticCurveTo(backCtlX, backCtlY, tx, ty);\n X.closePath(); X.fill();\n // frond center vein\n X.strokeStyle='rgba(28,70,36,0.6)'; X.lineWidth=0.7*z;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);\n X.stroke();\n }\n // frond layer 2 \u2014 pointing up (7 fronds, offset angles for fullness)\n var frondCount2=7;\n for(var a2=0;a2<frondCount2;a2++){\n var ang2=(-Math.PI/2) + (a2-(frondCount2-1)/2)*(Math.PI*0.85/frondCount2) + 0.2;\n var tipDx2=Math.cos(ang2)*20*z;\n var tipDy2=Math.sin(ang2)*16*z - 6*z; // push up\n var ctlDx2=Math.cos(ang2)*7*z - Math.sin(ang2)*3*z;\n var ctlDy2=Math.sin(ang2)*6*z - 2*z;\n var fGrad2=X.createLinearGradient(tx+ctlDx2*0.2, ty+ctlDy2*0.2, tx+tipDx2, ty+tipDy2);\n fGrad2.addColorStop(0,'rgba(40,100,52,0.95)');\n fGrad2.addColorStop(0.6,'rgba(70,150,76,0.92)');\n fGrad2.addColorStop(1,'rgba(150,210,120,0.88)');\n X.fillStyle=fGrad2;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);\n var backCtlX2=tx+tipDx2*0.7 + Math.sin(ang2)*3*z;\n var backCtlY2=ty+tipDy2*0.7 + Math.cos(ang2)*1*z - 1*z;\n X.quadraticCurveTo(backCtlX2, backCtlY2, tx, ty);\n X.closePath(); X.fill();\n // vein\n X.strokeStyle='rgba(30,76,40,0.55)'; X.lineWidth=0.6*z;\n X.beginPath();\n X.moveTo(tx,ty);\n X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);\n X.stroke();\n }\n // crown core highlight\n X.fillStyle='rgba(200,220,160,0.45)';\n X.beginPath(); X.arc(tx,ty,2.5*z,0,7); X.fill();\n }\n function ring(cx,cy,r,frac,color){\n // No more raised stone base plate \u2014 the plaza chessboard tile shows\n // through underneath. Just the soft gold glow + load arc.\n X.save();\n X.shadowColor=color; X.shadowBlur=14*cam.z;\n X.fillStyle='rgba(232,200,120,0.18)';\n X.beginPath(); X.ellipse(cx,cy, r+3*cam.z, (r+3*cam.z)*0.5, 0, 0, 7); X.fill();\n X.restore();\n X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.14)'; 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){\n // \"Not joined\" marker \u2014 soft crosshair on the bare plaza tile.\n // No more raised stone platform; the chessboard shows through.\n X.strokeStyle='rgba(232,200,120,0.6)'; X.lineWidth=1.4*z;\n X.beginPath();\n X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);\n X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);\n X.stroke();\n // soft outer glow\n X.strokeStyle='rgba(255,210,140,0.4)'; X.lineWidth=3*z;\n X.beginPath();\n X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);\n X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);\n X.stroke();\n }\n // Render a 3D-looking raised tile (top face + right side + front face) centered on (cx,cy)\n function drawTileQuad(cx,cy,w,h,fillTop,fillSide){\n var halfW=w*0.5, halfH=h*0.35;\n var lift=3*cam.z;\n // top face (diamond) \u2014 lit\n X.fillStyle=fillTop;\n X.beginPath();\n X.moveTo(cx,cy-lift-halfH);\n X.lineTo(cx+halfW,cy-lift);\n X.lineTo(cx,cy-lift+halfH);\n X.lineTo(cx-halfW,cy-lift);\n X.closePath(); X.fill();\n // top face bevel highlight (upper-left edges)\n X.strokeStyle='rgba(255,255,255,0.18)'; X.lineWidth=0.9*cam.z;\n X.beginPath();\n X.moveTo(cx,cy-lift-halfH);\n X.lineTo(cx-halfW,cy-lift);\n X.stroke();\n X.beginPath();\n X.moveTo(cx-halfW,cy-lift);\n X.lineTo(cx,cy-lift+halfH);\n X.stroke();\n // right side face (darker)\n X.fillStyle=fillSide;\n X.beginPath();\n X.moveTo(cx,cy-lift+halfH);\n X.lineTo(cx+halfW,cy-lift);\n X.lineTo(cx+halfW,cy);\n X.lineTo(cx,cy+halfH);\n X.closePath(); X.fill();\n // front face (darker)\n X.fillStyle=fillSide;\n X.beginPath();\n X.moveTo(cx-halfW,cy-lift);\n X.lineTo(cx,cy-lift+halfH);\n X.lineTo(cx,cy+halfH);\n X.lineTo(cx-halfW,cy);\n X.closePath(); X.fill();\n // outline\n X.strokeStyle='rgba(0,0,0,0.45)'; X.lineWidth=0.8*cam.z;\n X.beginPath();\n X.moveTo(cx,cy-lift-halfH); X.lineTo(cx+halfW,cy-lift);\n X.lineTo(cx+halfW,cy); X.lineTo(cx,cy+halfH);\n X.lineTo(cx-halfW,cy); X.lineTo(cx-halfW,cy-lift);\n X.closePath(); X.stroke();\n }\n function drawHub(n){\n // Desktop PC / server workstation: a tower on the left, a monitor on the right,\n // a keyboard in front and a mouse to the right. Centered on (n.wx, n.wy) on the iso ground.\n var z=cam.z;\n var bob=Math.sin((performance.now()-t0)/700)*1.2*z;\n // base point on the iso ground (the workstation sits centered here, slightly raised by bob)\n var p=iso(n.wx, n.wy);\n var gx=p.x, gy=p.y - bob; // ground reference\n // soft floor shadow under the whole workstation\n X.fillStyle='rgba(0,0,0,0.32)';\n X.beginPath(); X.ellipse(gx, gy+3*z, 32*z, 12*z, 0, 0, 7); X.fill();\n // (desk-mat removed \u2014 the bare plaza tile now shows under the workstation)\n\n // \u2500\u2500 PC TOWER (left of center, vertical box) \u2500\u2500\n // tower footprint ~14*z wide x 10*z deep on the desk, height 26*z\n var tx=gx-18*z, ty=gy-11*z; // tower base x, y on desk top\n var tw=14*z, td=10*z, th=26*z;\n // right side face (darker)\n X.fillStyle='rgba(36,32,48,0.97)';\n X.beginPath();\n X.moveTo(tx+tw, ty);\n X.lineTo(tx+tw, ty-th);\n X.lineTo(tx+tw+td*0.45, ty-th+td*0.35);\n X.lineTo(tx+tw+td*0.45, ty+td*0.35);\n X.closePath(); X.fill();\n // front face (lit)\n X.fillStyle='rgba(56,50,72,0.97)';\n X.beginPath();\n X.moveTo(tx, ty);\n X.lineTo(tx, ty-th);\n X.lineTo(tx+tw, ty-th);\n X.lineTo(tx+tw, ty);\n X.closePath(); X.fill();\n // top face\n X.fillStyle='rgba(78,70,96,0.97)';\n X.beginPath();\n X.moveTo(tx, ty);\n X.lineTo(tx+tw, ty);\n X.lineTo(tx+tw+td*0.45, ty+td*0.35);\n X.lineTo(tx+td*0.45, ty+td*0.35);\n X.closePath(); X.fill();\n // tower outline\n X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;\n X.beginPath();\n X.moveTo(tx, ty); X.lineTo(tx, ty-th); X.lineTo(tx+tw, ty-th); X.lineTo(tx+tw, ty); X.closePath(); X.stroke();\n X.beginPath();\n X.moveTo(tx, ty); X.lineTo(tx+tw, ty); X.lineTo(tx+tw+td*0.45, ty+td*0.35); X.lineTo(tx+td*0.45, ty+td*0.35); X.closePath(); X.stroke();\n X.beginPath();\n X.moveTo(tx, ty-th); X.lineTo(tx+td*0.45, ty-th+td*0.35); X.stroke();\n X.beginPath();\n X.moveTo(tx+tw, ty-th); X.lineTo(tx+tw+td*0.45, ty-th+td*0.35); X.stroke();\n // front-face details: power button + drive bay slots + LED\n X.fillStyle='rgba(255,214,112,0.95)';\n X.beginPath(); X.arc(tx+3*z, ty-4*z, 1.4*z, 0, 7); X.fill(); // power LED\n // drive bay slots\n X.fillStyle='rgba(20,16,28,0.95)';\n X.fillRect(tx+2*z, ty-9*z, 10*z, 2*z);\n X.fillRect(tx+2*z, ty-13*z, 10*z, 2*z);\n X.fillRect(tx+2*z, ty-17*z, 10*z, 2*z);\n // blinking activity LED (green)\n var blink=Math.abs(Math.sin((performance.now()-t0)/300));\n X.fillStyle='rgba(110,224,166,'+(0.5+0.5*blink).toFixed(3)+')';\n X.beginPath(); X.arc(tx+11*z, ty-4*z, 0.9*z, 0, 7); X.fill();\n // mesh vent lines on right side (darker grooves)\n X.strokeStyle='rgba(12,8,18,0.7)'; X.lineWidth=0.5*z;\n for(var vi=0;vi<5;vi++){ X.beginPath(); X.moveTo(tx+tw+0.6*z, ty-2*z-vi*4*z); X.lineTo(tx+tw+td*0.45-0.6*z, ty-2*z-vi*4*z+td*0.35); X.stroke(); }\n\n // \u2500\u2500 MONITOR (right of center) \u2500\u2500\n // stand base, neck, screen\n var mx=gx+4*z, my=gy-11*z; // monitor base on desk\n var mw=18*z, mh=14*z; // screen width / height\n // monitor neck\n X.fillStyle='rgba(50,44,68,0.95)';\n X.beginPath(); X.moveTo(mx+5*z, my); X.lineTo(mx+13*z, my); X.lineTo(mx+12*z, my-3*z); X.lineTo(mx+6*z, my-3*z); X.closePath(); X.fill();\n // monitor stand base (small ellipse)\n X.fillStyle='rgba(40,34,52,0.95)';\n X.beginPath(); X.ellipse(mx+9*z, my+0.5*z, 6*z, 1.4*z, 0, 0, 7); X.fill();\n // monitor screen (lit, animated content)\n var scrX=mx-2*z, scrY=my-3*z - mh;\n // screen bezel\n X.fillStyle='rgba(20,16,28,0.98)';\n X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.fill();\n // screen surface \u2014 animated dashboard gradient\n var scrGrad=X.createLinearGradient(scrX, scrY, scrX, scrY+mh);\n scrGrad.addColorStop(0, n.connected?'rgba(36,76,108,0.95)':'rgba(48,30,30,0.95)');\n scrGrad.addColorStop(1, n.connected?'rgba(20,40,64,0.95)':'rgba(28,18,18,0.95)');\n X.fillStyle=scrGrad;\n X.beginPath(); X.roundRect(scrX+1*z, scrY+1*z, mw-2*z, mh-3*z, 1*z); X.fill();\n // screen content \u2014 animated bars (looks like a live dashboard)\n X.fillStyle='rgba(110,224,166,0.85)';\n for(var bi=0;bi<5;bi++){\n var bw=2.5*z, bh=(3 + 4*Math.abs(Math.sin((performance.now()-t0)/400 + bi*0.7)))*z;\n X.fillRect(scrX+2.5*z + bi*3*z, scrY+mh-3*z-bh, bw, bh);\n }\n // header bar (gold)\n X.fillStyle='rgba(255,214,112,0.7)';\n X.fillRect(scrX+1.5*z, scrY+2*z, mw-3*z, 1*z);\n // tiny \"REC\" dot\n X.fillStyle='rgba(255,90,90,0.95)';\n X.beginPath(); X.arc(scrX+3*z, scrY+4*z, 0.7*z, 0, 7); X.fill();\n // monitor bezel outline\n X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;\n X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.stroke();\n // brand bar at bottom of monitor\n X.fillStyle='rgba(255,214,112,0.85)';\n X.fillRect(scrX+mw*0.35, scrY+mh-1.4*z, mw*0.3, 0.6*z);\n\n // \u2500\u2500 KEYBOARD (in front of the monitor) \u2500\u2500\n var kbX=mx-1*z, kbY=gy-2*z;\n var kbW=18*z, kbH=4.5*z;\n X.fillStyle='rgba(28,24,36,0.95)';\n X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.fill();\n X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;\n X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.stroke();\n // key rows\n X.fillStyle='rgba(70,62,86,0.95)';\n for(var kr=0;kr<3;kr++){\n for(var kc=0;kc<10;kc++){\n X.fillRect(kbX+1*z + kc*1.7*z, kbY+0.5*z + kr*1.3*z, 1.2*z, 1*z);\n }\n }\n // spacebar\n X.fillRect(kbX+5*z, kbY+0.5*z+3*1.3*z, 6*z, 1*z);\n\n // \u2500\u2500 MOUSE (right of keyboard) \u2500\u2500\n var msX=kbX+kbW+1.5*z, msY=kbY+0.5*z;\n X.fillStyle='rgba(28,24,36,0.95)';\n X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.fill();\n X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;\n X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.stroke();\n // scroll wheel\n X.fillStyle='rgba(255,214,112,0.85)';\n X.fillRect(msX+1.4*z, msY+0.5*z, 0.3*z, 0.8*z);\n\n // \u2500\u2500 glowing signal lines coming from the monitor (radio waves) \u2500\u2500\n for(var ri=0;ri<3;ri++){\n var rphase=((performance.now()-t0)/1100 + ri*0.33) % 1;\n var rr=10*z + rphase*22*z;\n var ralpha=(1-rphase)*0.5;\n X.strokeStyle='rgba(255,214,112,'+ralpha.toFixed(3)+')';\n X.lineWidth=0.8*z;\n X.beginPath();\n X.arc(scrX+mw*0.5, scrY+mh*0.5, rr, Math.PI*0.85, Math.PI*1.15);\n X.stroke();\n }\n\n // \u2500\u2500 label below the workstation \u2500\u2500\n var labelX=gx;\n var labelY=gy+10*z;\n label(labelX, labelY, n.name, getVar('--gold-bright'), (n.connected?'HUB \u00B7 '+Math.round(n.load)+'%':'offline'));\n // hit-box covers the whole workstation footprint\n n._hit={x:gx-32*z, y:scrY-3*z, w:64*z, h:(labelY+8*z)-(scrY-3*z)};\n }\n function drawRobot(n){\n var p=iso(n.wx,n.wy), z=cam.z, now=performance.now();\n // Slight bob varies by status: live/talking robots get a livelier\n // bob, offline/ended ones stay still.\n var bobAmp = (n.status==='live'||n.status==='talking') ? 2.6*z\n : (n.status==='ended') ? 0.4*z\n : (n.connected ? 1.6*z : 0);\n var bob = bobAmp ? Math.sin(now/500 + (n.wx||0)) * bobAmp : 0;\n var bx=p.x, by=p.y-4*z-bob;\n var status = n.status || (n.connected ? 'ready' : 'offline');\n\n // \u2500\u2500 status-driven ground effect (drawn under the avatar) \u2500\u2500\n // Each status has a distinct footprint so you can read what's\n // happening from across the plaza: a green pulse for live, an\n // expanding sound ring for talking, a slow blue breathing\n // halo for ready/waiting, a fading grey ring for ended, and\n // a dim grey halo for offline.\n drawStatusGround(n, status, bx, p.y+3*z, z, now);\n\n // Shadow under the avatar (kept subtle so the new ground effect\n // is the visual focus).\n X.fillStyle='rgba(0,0,0,0.28)';\n X.beginPath(); X.ellipse(bx, p.y+3*z, 13*z, 5.5*z, 0, 0, Math.PI*2); X.fill();\n\n // Status-colored rim under the avatar (thin ring, same hue as\n // the status ground effect \u2014 binds the avatar to its footprint).\n var rimColor = statusRimColor(status);\n X.strokeStyle=rimColor; X.lineWidth=1.4*z;\n X.beginPath(); X.ellipse(bx, p.y+3*z, 18*z, 8*z, 0, 0, Math.PI*2); X.stroke();\n\n // Load arc (still useful \u2014 shows CPU pressure even on a live\n // robot). Drawn at a slightly larger radius than the status rim\n // so the two don't fight visually.\n if(n.connected) ring(bx, p.y+3*z, 18*z, n.load/100, loadColor(n.load));\n else padMark(bx, p.y+3*z, z);\n\n var col = n.connected ? rimColor : '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=40*z, ih=40*z*img.naturalHeight/img.naturalWidth;\n X.save();\n X.beginPath(); X.roundRect(bx-20*z, by-54*z, iw, ih+12*z, 8*z); X.clip();\n X.drawImage(img, bx-20*z, by-60*z, iw, ih+18*z);\n X.restore();\n } else {\n X.fillStyle='rgba(24,20,38,0.92)';\n X.beginPath(); X.roundRect(bx-17*z, by-36*z, 34*z, 36*z, 7*z); X.fill();\n X.fillStyle='rgba(30,24,44,0.95)';\n X.beginPath(); X.roundRect(bx-12*z, by-54*z, 24*z, 20*z, 5*z); X.fill();\n // Mouth/eyes default to the live color; \"talking\" status will\n // animate the mouth in drawStatusOverlays() below.\n X.fillStyle = n.connected ? getVar('--ok') : '#655';\n X.beginPath(); X.arc(bx-5*z, by-44*z, 3*z, 0, 7); X.arc(bx+5*z, by-44*z, 3*z, 0, 7); X.fill();\n // mouth dot \u2014 gets animated when status==='talking'\n X.fillStyle = (status==='talking') ? '#FF5B4A' : ((status==='ended') ? 'rgba(200,180,160,0.5)' : '#FF5B4A');\n X.beginPath(); X.arc(bx, by-65*z, (status==='talking' ? 2.2 + 1.4*Math.abs(Math.sin(now/85)) : 2.4)*z, 0, 7); X.fill();\n }\n X.globalAlpha=1;\n\n // status overlays: speech rings (talking), thought bubble\n // (waiting/ready), a \"\uD83D\uDC4B bye\" mark (ended). Drawn ABOVE the\n // avatar so they read at a glance from the rest of the plaza.\n drawStatusOverlays(n, status, bx, by, z, now);\n\n // sub-text under the avatar \u2014 show readiness + a short status word\n var subBits = [];\n if(n.sub && n.sub!==n.name) subBits.push(n.sub);\n if(n.connected){\n if(status==='talking') subBits.push('talking');\n else if(status==='live') subBits.push('live');\n else if(status==='ended') subBits.push('session ended');\n else if(status==='ready') subBits.push('waiting');\n subBits.push(Math.round(n.load||0)+'%');\n } else {\n subBits.push('not joined');\n }\n label(bx, p.y+17*z, n.label, col, subBits.join(' \u00B7 '));\n // \u2500\u2500 holographic name callout pill above the avatar (status-colored border) \u2500\u2500\n if(n.connected){\n var pillW=Math.max(70*z, (n.label.length*7 + 18) * z);\n var pillH=16*z;\n var pillR=8*z;\n var pillCx=bx;\n var pillCy=by - 8*z - 54*z - pillH*0.5;\n X.save();\n X.fillStyle='rgba(20,16,32,0.85)';\n X.beginPath();\n if(X.roundRect){\n X.roundRect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH, pillR);\n } else {\n X.rect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH);\n }\n X.fill();\n // pill border uses the same status hue as the ground effect\n X.strokeStyle = rimColor; X.lineWidth=1.0*z;\n X.stroke();\n X.fillStyle='rgba(255,232,160,0.95)';\n X.font='700 '+(10*z)+'px '+mono();\n X.textAlign='center'; X.textBaseline='middle';\n X.fillText(n.label, pillCx, pillCy);\n X.restore();\n // \u2500\u2500 pulsing status dot to the right of the pill (faster pulse for live, fastest for talking) \u2500\u2500\n var dotCx=pillCx + pillW*0.5 + 4*z;\n var dotCy=pillCy;\n var dotPeriod = status==='talking' ? 110 : (status==='live' ? 230 : 400);\n var dotAlpha=0.55+0.45*Math.abs(Math.sin(now/dotPeriod + (n.wx||0)));\n X.save();\n X.fillStyle=hexWithAlpha(rimColor, dotAlpha.toFixed(3));\n X.shadowColor=rimColor; X.shadowBlur=6*z;\n X.beginPath(); X.arc(dotCx, dotCy, 3*z, 0, Math.PI*2); X.fill();\n X.restore();\n }\n n._hit={x:bx-25*z,y:by-67*z,w:50*z,h:80*z};\n }\n\n // Map status \u2192 rgb base color (used for ground effect, rim, dot).\n // Kept in a small helper so all the status-driven effects stay in\n // sync visually.\n function statusRimColor(status){\n switch(status){\n case 'talking': return 'rgb(110,224,166)'; // bright green \u2014 voice is active\n case 'live': return 'rgb(255,214,112)'; // gold \u2014 session open, idle\n case 'ended': return 'rgb(180,170,160)'; // grey \u2014 session just ended, fading\n case 'ready': return 'rgb(110,180,255)'; // soft blue \u2014 waiting/ready\n case 'offline': return 'rgba(150,143,120,0.5)';\n default: return 'rgb(255,214,112)';\n }\n }\n // Build an rgba() string from an rgb() base + an alpha. Used so\n // shadowColor / strokeStyle can blend nicely against the gold grid.\n function hexWithAlpha(rgb, a){\n // Accepts \"rgb(r,g,b)\" or \"rgba(r,g,b,x)\"; returns rgba(r,g,b,a).\n // This file is embedded in a TypeScript template string, so regex\n // backslashes must be doubled or the browser receives an invalid regex.\n var m = /rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/.exec(rgb);\n if(!m) return rgb;\n return 'rgba('+m[1]+','+m[2]+','+m[3]+','+a+')';\n }\n\n // \u2500\u2500 status-driven ground effect under the avatar \u2500\u2500\n // Drawn before the avatar so the rim + ring sit on top. Each\n // status reads as a distinct footprint color + animation so the\n // plaza instantly tells you which robots are quiet, waiting,\n // listening, talking, or just stopped.\n function drawStatusGround(n, status, cx, cy, z, now){\n if(status === 'offline'){\n // dim grey aura (kept from the original)\n var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, 22*z);\n g.addColorStop(0,'rgba(140,140,160,0.16)');\n g.addColorStop(1,'rgba(140,140,160,0)');\n X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, 22*z, 11*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'live'){\n // breathing green pulse \u2014 small inner glow that throbs every\n // ~0.7s, plus the load arc's gold ring already drawn above.\n var beat = 0.5 + 0.5*Math.sin(now/350);\n var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, (18 + 6*beat)*z);\n g.addColorStop(0, 'rgba(110,224,166,'+(0.45 + 0.25*beat).toFixed(3)+')');\n g.addColorStop(0.6, 'rgba(110,224,166,0.12)');\n g.addColorStop(1, 'rgba(110,224,166,0)');\n X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, (18+6*beat)*z, (9+3*beat)*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'talking'){\n // 2-3 expanding sound rings (like \u2312) \u2014 color bright green, very\n // distinct from the gold \"live\" pulse so listeners know voice\n // is actually flowing right now.\n var ringCount = 2;\n for(var ti=0; ti<ringCount; ti++){\n var tphase = ((now/420) + ti*0.5) % 1;\n var tr = (10 + tphase*22)*z;\n var ta = (1 - tphase) * 0.7;\n X.strokeStyle='rgba(110,224,166,'+ta.toFixed(3)+')';\n X.lineWidth=1.6*z;\n X.beginPath(); X.ellipse(cx, cy, tr, tr*0.5, 0, 0, Math.PI*2); X.stroke();\n }\n // solid inner glow so the \"voice\" footprint reads even when\n // rings are between expansions.\n var tg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 14*z);\n tg.addColorStop(0, 'rgba(110,224,166,0.45)');\n tg.addColorStop(1, 'rgba(110,224,166,0)');\n X.fillStyle=tg; X.beginPath(); X.ellipse(cx, cy, 14*z, 7*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'ready'){\n // soft blue \"waiting\" halo \u2014 slow breath, calmer than live.\n var breath = 0.5 + 0.5*Math.sin(now/900);\n var rg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 20*z);\n rg.addColorStop(0, 'rgba(110,180,255,'+(0.18 + 0.10*breath).toFixed(3)+')');\n rg.addColorStop(1, 'rgba(110,180,255,0)');\n X.fillStyle=rg; X.beginPath(); X.ellipse(cx, cy, 20*z, 10*z, 0, 0, 7); X.fill();\n return;\n }\n if(status === 'ended'){\n // session just ended \u2014 fading grey ring expanding outward,\n // like a sound ring dying down. Alpha drops to 0 over 4s.\n var mem = _statusMem[n.id] || {};\n var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;\n if(since > 4000) return;\n var ft = Math.min(1, since / 4000);\n var fr = (16 + ft*30)*z;\n var fa = (1 - ft) * 0.55;\n X.strokeStyle='rgba(180,170,160,'+fa.toFixed(3)+')';\n X.lineWidth=1.4*z;\n X.beginPath(); X.ellipse(cx, cy, fr, fr*0.5, 0, 0, Math.PI*2); X.stroke();\n return;\n }\n }\n\n // \u2500\u2500 status-driven overlays above the avatar \u2500\u2500\n // Speech rings (talking), thought-bubble \"?\" (ready/waiting),\n // fading \"\uD83D\uDC4B\" (ended). Drawn on top of the avatar so they read\n // at a glance.\n function drawStatusOverlays(n, status, bx, by, z, now){\n if(status === 'talking'){\n // Speech ring: 3 expanding arcs above the avatar, like\n // someone is mid-sentence. Color bright green to match the\n // ground pulse.\n for(var si=0; si<3; si++){\n var sphase = ((now/260) + si*0.34) % 1;\n var sa = (1 - sphase) * 0.85;\n var sr = (4 + sphase*9)*z;\n X.strokeStyle='rgba(110,224,166,'+sa.toFixed(3)+')';\n X.lineWidth=1.4*z;\n X.beginPath();\n X.arc(bx, by-58*z - sphase*4*z, sr, Math.PI*1.15, Math.PI*1.85);\n X.stroke();\n }\n return;\n }\n if(status === 'ready'){\n // Thought bubble: small cloud (3 circles) with a \"?\" inside.\n // Bobs gently so it reads as a thought, not a sticker.\n var bob = Math.sin(now/700) * 1.4*z;\n var bubbleX = bx + 20*z;\n var bubbleY = by - 64*z + bob;\n // tail circles\n X.fillStyle='rgba(220,234,255,0.95)';\n X.beginPath(); X.arc(bubbleX-7*z, bubbleY+8*z, 1.6*z, 0, Math.PI*2); X.fill();\n X.beginPath(); X.arc(bubbleX-4*z, bubbleY+4*z, 2.2*z, 0, Math.PI*2); X.fill();\n // main bubble\n X.beginPath(); X.arc(bubbleX, bubbleY, 6*z, 0, Math.PI*2); X.fill();\n X.strokeStyle='rgba(110,180,255,0.85)'; X.lineWidth=1.0*z; X.stroke();\n // \"?\" text\n X.fillStyle='rgba(60,90,140,0.95)';\n X.font='700 '+(7*z)+'px '+mono();\n X.textAlign='center'; X.textBaseline='middle';\n X.fillText('?', bubbleX, bubbleY+0.5*z);\n return;\n }\n if(status === 'ended'){\n // Fading \"bye\" wave \u2014 a small waving-hand glyph that rises and\n // fades out over 4s.\n var mem = _statusMem[n.id] || {};\n var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;\n if(since > 4000) return;\n var ft = since / 4000;\n var a = (1 - ft) * 0.95;\n var lift = ft * 14*z;\n X.save();\n X.globalAlpha = a;\n // small bubble background for legibility\n X.fillStyle='rgba(20,16,32,0.7)';\n X.beginPath(); X.arc(bx, by - 60*z - lift, 8*z, 0, Math.PI*2); X.fill();\n X.strokeStyle='rgba(180,170,160,0.9)'; X.lineWidth=0.8*z;\n X.stroke();\n // hand glyph (canvas-drawn, simpler than emoji so it always renders)\n var hx = bx, hy = by - 60*z - lift;\n X.fillStyle='rgba(255,214,112,0.95)';\n // palm\n X.beginPath(); X.arc(hx, hy + 0.5*z, 3.0*z, 0, Math.PI*2); X.fill();\n // 4 fingers\n for(var fi=-2; fi<=1; fi++){\n X.fillRect(hx + fi*1.2*z - 0.4*z, hy - 3.6*z, 0.8*z, 2.6*z);\n }\n // thumb\n X.beginPath(); X.arc(hx - 2.4*z, hy + 0.5*z, 0.9*z, 0, Math.PI*2); X.fill();\n // tiny side-to-side wave wobble\n var wobble = Math.sin(now/110) * 1.4*z;\n X.translate(wobble, 0);\n X.restore();\n return;\n }\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 // \u2500\u2500 Walker tick + draw \u2500\u2500\n // Advances each walker along its path at a steady world-space speed\n // (one segment in segDurMs ms). Frame-rate independent: every\n // frame we move the walker by the elapsed time, not a per-tick\n // constant. When a walker is within greet-distance of its next\n // pauseAt waypoint, it pauses for 1.4s in a wave pose; the nearest\n // connected robot pad fires a \uD83D\uDC4B pulse while it pauses.\n var _lastTickMs = 0;\n function tickWalkers(now){\n if(!_lastTickMs) _lastTickMs = now;\n var dt = Math.min(64, now - _lastTickMs); // clamp to avoid huge jumps on tab-switch\n _lastTickMs = now;\n for(var wi=0; wi<walkers.length; wi++){\n var w = walkers[wi];\n if(now < w.pauseUntil) continue;\n // Distance per frame for this walker = total path length / (segDurMs / dt) segments.\n // Convert to world units: distance = w.info.total * (dt / (w.path.length-1) / w.spec.segDurMs)\n // Simplified: dDistance = w.info.total * dt / ((w.path.length-1) * segDurMs)\n var stepDist = w.info.total * dt / ((w.path.length - 1) * w.spec.segDurMs);\n w.distance += stepDist;\n // Check whether we've crossed a pauseAt point during this tick.\n // Sample the current and previous position and see if either\n // got close to the next pending pauseAt waypoint. This is\n // cheaper than walking the whole polyline and handles fast\n // frame deltas.\n for(var pi=0; pi<w.spec.pauseAt.length; pi++){\n if(pi !== w.nextGreet % w.spec.pauseAt.length) continue;\n var pp = w.spec.pauseAt[pi];\n // current and previous world positions\n var cur = pathPosAt(w.info, w.distance);\n var prev = pathPosAt(w.info, w.distance - stepDist);\n var ca = w.path[cur.seg], cb = w.path[cur.seg+1] || w.path[0];\n var pa = w.path[prev.seg], pb = w.path[prev.seg+1] || w.path[0];\n var cwx = ca[0] + (cb[0]-ca[0])*cur.t, cwy = ca[1] + (cb[1]-ca[1])*cur.t;\n var pwx = pa[0] + (pb[0]-pa[0])*prev.t, pwy = pa[1] + (pb[1]-pa[1])*prev.t;\n // Closest distance from the line segment prev\u2192cur to the pause point.\n var dx = cwx - pwx, dy = cwy - pwy;\n var segLen2 = dx*dx + dy*dy;\n var t = 0;\n if(segLen2 > 1e-6){\n t = ((pp[0]-pwx)*dx + (pp[1]-pwy)*dy) / segLen2;\n if(t < 0) t = 0; else if(t > 1) t = 1;\n }\n var qx = pwx + dx*t, qy = pwy + dy*t;\n var ddx = qx - pp[0], ddy = qy - pp[1];\n if(ddx*ddx + ddy*ddy < 0.35*0.35){\n w.pauseUntil = now + 1400;\n w.nextGreet++;\n // pulse the nearest connected robot pad so the interaction is visible\n var best = null, bd = 4.0;\n for(var ni=0; ni<nodes.length; ni++){\n var nd = nodes[ni];\n if(nd.kind !== 'robot' || !nd.connected) continue;\n var nx = nd.wx - pp[0], ny = nd.wy - pp[1];\n var dd = nx*nx + ny*ny;\n if(dd < bd){ bd = dd; best = nd; }\n }\n if(best) pulse(best, '\uD83D\uDC4B');\n break;\n }\n }\n }\n // Cars share the same elapsed-dt clock so a tab-switch doesn't\n // catapult every car off the road at once. speed is world units\n // per second, dt is milliseconds. cdt is the already-clamped dt.\n // Per-frame speed jitter (\u00B15%) so the same lane never settles into\n // a stable cluster \u2014 the fastest car gradually pulls away from the\n // slowest one and the spread stays alive even after many wraps.\n var cdt = Math.min(64, dt);\n for(var ci=0; ci<CARS.length; ci++){\n var car = CARS[ci];\n // Deterministic per-car jitter based on wall-clock + index so it\n // changes every frame but isn't pure noise (smooth-ish accel).\n var jitter = 1 + (Math.sin(now/380 + ci*1.7) * 0.05);\n car.x += car.dir * car.speed * jitter * (cdt/1000);\n // Wrap the car around the road. When it leaves one end, it\n // re-enters the opposite end on the other lane \u2014 and the\n // re-entry x is randomized across the visible road so two\n // cars that arrived at the boundary together don't land on\n // top of each other.\n if(car.dir > 0 && car.x > PARK_W + 0.5){\n // Outbound (right-bound) car reached the right edge \u2014\n // re-enter on the left. We swap to the OTHER lane so two\n // lanes of traffic are visible at all times; the wrap dir\n // is explicitly set to +1 so the car continues in its\n // original direction on the new lane. (Previous version\n // computed dir from the *post-wrap* y, which always\n // flipped the car to the opposite direction \u2014 cars would\n // oscillate at the road edge instead of crossing it.)\n car.x = -0.5 - Math.random() * (PARK_W * 0.5);\n car.y = (car.y < -0.9) ? -0.65 : -1.18;\n car.dir = +1;\n } else if(car.dir < 0 && car.x < -0.5){\n // Inbound (left-bound) car reached the left edge \u2014\n // re-enter on the right, swap to the other lane, keep\n // direction at -1. (See note above about the prior bug.)\n car.x = PARK_W + 0.5 + Math.random() * (PARK_W * 0.5);\n car.y = (car.y < -0.9) ? -0.65 : -1.18;\n car.dir = -1;\n }\n }\n }\n function walkerPos(w){\n // Decode w.distance (in units of \"segments\") into a (seg, t) so\n // callers can project a real world point without re-walking the\n // path each time. The 'seg' returned is a continuous float, so a\n // walker mid-stride is at seg=1.6, for example \u2014 used by the\n // depth sort to get a smooth iso-depth from the world point.\n var path = w.path, info = w.info;\n var d = ((w.distance % (path.length-1)) + (path.length-1)) % (path.length-1);\n var segI = Math.floor(d);\n var segT = d - segI;\n var a = path[segI], b = path[segI+1] || path[0];\n return [ a[0] + (b[0]-a[0])*segT, a[1] + (b[1]-a[1])*segT ];\n }\n function drawWalker(w, now){\n var pos = walkerPos(w);\n var p = iso(pos[0], pos[1]);\n var z = cam.z;\n // Direction along the path: average the next 0.5s of motion so the\n // figure doesn't snap on segment turns.\n var lookAhead = walkerPos({path:w.path, info:w.info, distance: w.distance + 0.5});\n var lh = iso(lookAhead[0], lookAhead[1]);\n var facingRight = (lh.x - p.x) >= 0;\n // Gait cycle: tied to distance travelled (not wall clock) so the\n // animation is smooth at any speed. ~1 step per 0.4 world units.\n var phase = w.distance * 2.4;\n // Use a cosine for the step so foot-down poses linger briefly at\n // each extreme \u2014 reads as a real walk rather than a sine wobble.\n var stepC = Math.cos(phase);\n var stepS = Math.sin(phase);\n // Bob: only on the up-phase of the step (plant foot + rise).\n var bob = Math.max(0, stepC) * 1.0 * z;\n // Body lean: very slight forward lean into the direction of travel.\n var lean = 0.6 * z;\n var greeting = now < w.pauseUntil;\n // Anchor on the ground (shadow), body sits above.\n var gx = p.x, gy = p.y + 2*z;\n X.save();\n if(!facingRight){ X.translate(gx, 0); X.scale(-1, 1); X.translate(-gx, 0); }\n // shadow ellipse \u2014 softens and shrinks while in the air\n X.fillStyle='rgba(0,0,0,0.28)';\n X.beginPath(); X.ellipse(gx, gy, (4.6 - bob*0.5)*z, 1.8*z, 0, 0, Math.PI*2); X.fill();\n // legs (two angled lines from the hip, swinging opposite with cosine)\n var hipX = gx + lean, hipY = gy - 9*z + bob;\n var legSpread = 2.6*z;\n var legSwing = stepC * 3.0*z;\n X.strokeStyle = w.spec.pants; X.lineWidth = 1.9*z; X.lineCap = 'round';\n // Back leg (planted)\n X.beginPath();\n X.moveTo(hipX - legSpread*0.5, hipY);\n X.lineTo(hipX - legSpread*0.5 - legSwing, gy);\n X.stroke();\n // Front leg (swinging forward)\n X.beginPath();\n X.moveTo(hipX + legSpread*0.5, hipY);\n X.lineTo(hipX + legSpread*0.5 + legSwing, gy);\n X.stroke();\n // shoes \u2014 small dark caps at the foot so the step reads\n X.fillStyle = '#1a1410';\n X.beginPath(); X.ellipse(hipX - legSpread*0.5 - legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();\n X.beginPath(); X.ellipse(hipX + legSpread*0.5 + legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();\n // torso (shirt) \u2014 slightly tilted into the lean\n X.save();\n X.translate(hipX, hipY - 4*z);\n X.rotate(lean / 10);\n X.fillStyle = w.spec.shirt;\n X.beginPath(); X.roundRect(-3.2*z, -3.6*z, 6.4*z, 7.2*z, 1.6*z); X.fill();\n // belt\n X.fillStyle = 'rgba(0,0,0,0.25)';\n X.fillRect(-3.2*z, 2.4*z, 6.4*z, 0.9*z);\n X.restore();\n // arms (opposite-phase to the legs for a natural gait)\n X.strokeStyle = w.spec.shirt; X.lineWidth = 1.6*z; X.lineCap = 'round';\n var armSwing = stepC * 1.6*z;\n if(greeting){\n // waving arm: raised and gently flicking\n var flick = Math.sin(now/85) * 1.2*z;\n X.beginPath();\n X.moveTo(hipX - 2.8*z, hipY - 6*z);\n X.lineTo(hipX - 5*z + flick, hipY - 12*z);\n X.stroke();\n // resting arm \u2014 small bend so it doesn't look stiff\n X.beginPath();\n X.moveTo(hipX + 2.8*z, hipY - 6*z);\n X.lineTo(hipX + 4.4*z, hipY - 1.4*z);\n X.stroke();\n } else {\n X.beginPath();\n X.moveTo(hipX - 2.8*z, hipY - 6*z);\n X.lineTo(hipX - 4.2*z - armSwing, hipY - 0.6*z);\n X.moveTo(hipX + 2.8*z, hipY - 6*z);\n X.lineTo(hipX + 4.2*z + armSwing, hipY - 0.6*z);\n X.stroke();\n }\n // head (skin tone circle) \u2014 slight up/down nod with the step\n var headY = hipY - 11*z + bob*0.4;\n X.fillStyle = '#e8c39a';\n X.beginPath(); X.arc(hipX + lean*0.3, headY, 2.2*z, 0, Math.PI*2); X.fill();\n // hair cap (slightly different for each walker for visual variety)\n X.fillStyle = w.idx % 3 === 0 ? '#2a2018' : (w.idx % 3 === 1 ? '#4a2a18' : '#1a1810');\n X.beginPath(); X.arc(hipX + lean*0.3, headY - 0.9*z, 2.2*z, Math.PI*1.05, Math.PI*1.95); X.fill();\n // hat / accessory variant for walker 4 & 5 so they read as distinct characters\n if(w.idx === 4 || w.idx === 5){\n X.fillStyle = w.idx === 4 ? '#3a4a6a' : '#7a5a2a';\n X.beginPath(); X.ellipse(hipX + lean*0.3, headY - 1.7*z, 2.6*z, 0.7*z, 0, 0, Math.PI*2); X.fill();\n }\n X.restore();\n }\n\n function frame(){\n if(!running) return;\n var now = performance.now();\n X.clearRect(0,0,W,H);\n // \u2500\u2500 atmosphere: sky + sun + clouds + distant mountains + haze \u2500\u2500\n // Painted first, behind everything else, so the rest of the map\n // sits on top of an actual horizon instead of on the empty page\n // background. Drawn every frame so the sun and clouds drift even\n // when the rest of the scene is static.\n drawAtmosphere(now);\n drawGround(now);\n // step the looping walkers (and road cars) along their paths\n tickWalkers(now);\n // Re-derive status every frame so the \"ended\" countdown (a wall-\n // clock timer) decrements smoothly between telemetry polls (3s).\n for(var sni=0; sni<nodes.length; sni++){\n var sn = nodes[sni];\n if(sn.kind === 'robot'){\n var st = computeStatus(sn.telemetry, sn);\n sn.status = st.status;\n sn.readiness = st.readiness;\n sn.readinessMessage = st.readinessMessage;\n }\n }\n // depth-sort palms + nodes + walkers 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 for(var wii=0; wii<walkers.length; wii++){ var wpos=walkerPos(walkers[wii]); objs.push({wx:wpos[0],wy:wpos[1],walker:walkers[wii]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n objs.forEach(function(o){\n if(o.palm) drawPalm(o.wx,o.wy);\n else if(o.walker) drawWalker(o.walker, now);\n else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); }\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 // Pull the latest operator-saved positions from the scheduler\n // and seed the in-memory override map. Without this step the\n // dashboard would only know about local (this-session) drags\n // and would reset to slot defaults on every reload.\n try{\n getJson('/api/character-presets'+QS).then(function(list){\n if(Array.isArray(list)){\n for(var pi=0; pi<list.length; pi++){\n var p = list[pi];\n if(p && p.id && typeof p.nx === 'number' && typeof p.ny === 'number'){\n _parkPosOverrides[p.id] = { nx: p.nx, ny: p.ny };\n }\n }\n }\n // re-apply on the live node list (if it's already populated\n // by a prior /fed/status frame)\n if(typeof _applyPosOverrides === 'function') _applyPosOverrides();\n }).catch(function(){ /* offline \u2014 use slot defaults */ });\n }catch(e){}\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; deviceSelectorsReady=false;\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 preferred=schedulerDevice(n), preferredId=preferred&&preferred.id;\n var row=(preferredId&&roboparkByName[String(preferredId).toLowerCase()]) || roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase(),String(preferredId||'').toLowerCase()]);\n var seen={}, candidates=[];\n for(var rk in roboparkByName){\n var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);\n if(!candidate||!cid||seen[cid]) continue;\n seen[cid]=true;\n var label=String(candidate.name||'').toLowerCase(), idlabel=String(cid).toLowerCase(), matches=false;\n for(var ki=0;ki<keys.length;ki++){ var key=String(keys[ki]||'').toLowerCase(); if(key&&(label===key||idlabel===key||label.indexOf(key)>-1||key.indexOf(label)>-1)){ matches=true; break; } }\n if(matches) candidates.push(candidate);\n }\n function readyScore(candidate){\n var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;\n if(preferredId && String(candidate.robot_id||candidate.id||'')===String(preferredId)) score+=100000;\n if(candidate.production_mode===true||device.production_mode===true) score+=1000;\n if(status==='online'||status==='running'||status==='detecting') score+=500;\n if(candidate.readiness_code==='ready') score+=250;\n if(candidate===exact) score+=10;\n return score;\n }\n candidates.sort(function(a,b){ return readyScore(b)-readyScore(a); });\n return candidates[0]||row;\n }\n function schedId(n){\n // Device ids are authoritative. A legacy robots row may share the\n // name but have no preview, heartbeat, or hardware inventory.\n var device=schedulerDevice(n); if(device&&device.id) return device.id;\n var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;\n if(n.schedulerRobotId) return n.schedulerRobotId;\n return n.name;\n }\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(pipelineTimer){ clearInterval(pipelineTimer); pipelineTimer=null; }\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n lkAudioEls.forEach(function(el){try{el.pause();el.remove();}catch(e){}});lkAudioEls=[];\n if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}micMonitorSource=null;micMonitorGain=null;\n micMonitorAudible=false;micMonitorConnecting=false;micMonitorState='no active conversation session';\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 var command=$('pk-setup-clicmd'),label=$('pk-setup-clicmd-label');\n if(command)command.value=buildPadRobotCommand(name,body.character_id||n.id,token);\n if(label)label.textContent='run this ON THE ROBOT (uses the token minted above)';\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 // Per-robot production mode: PATCHes the device row, then immediately\n // arms/disarms this robot's continuous motion-triggered loop + always-on\n // camera feed rather than waiting for the next poll cycle to notice.\n function setProductionMode(n, on){\n var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }\n rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),{production_mode:!!on}).then(function(){\n showOut('production mode '+(on?'enabled':'disabled')+' for '+(device.name||id));\n if(device) device.production_mode=!!on;\n if(on && !previewOn && !lkRoom) startCam(n);\n if(!on && previewOn) stopPreview(n);\n }).catch(function(e){ var pm=$('pk-prodmode'); if(pm) pm.checked=!on; showOut('production mode ('+id+'): '+e.message); });\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='\u21BB Reconnect camera'; b.classList.remove('danger'); } }\n function goLive(n){ stopVision(); tryVisionOverlay(n,true); }\n function startPreview(n){\n previewOn=true; updateGoLive();\n if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n idleCam('connecting');\n tryVisionOverlay(n, true);\n return;\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); tryVisionOverlay(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(e){ idleCam('unreachable'); if(note) note.textContent='preview request failed'; if(window.console) console.error('RoboPark preview request failed',e); 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 var directNote=$('pk-golivenote'); if(directNote) directNote.textContent='';\n tryVisionOverlay(n,true);\n return;\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 // LiveKit is the authoritative camera feed. The standalone OpenCV MJPEG\n // server is opt-in because running it beside preview_agent would open the\n // same Windows camera twice and make both feeds unreliable.\n function tryVisionOverlay(n, force){\n var directImg=$('pk-vision-img'); if(!directImg) return;\n // Names stay stable when a robot is re-enrolled and receives a new mesh\n // node id. The hub resolves the name to the currently connected peer.\n var directRef=n.displayName||n.name||n.nodeId||n.id;\n if(!directRef){ visionOn=false; directImg.src=''; directImg.style.display='none'; return; }\n var directCamNote=$('pk-camnote'), directLiveNote=$('pk-golivenote'), directRes=$('pk-camres');\n visionOn=true;\n directImg.style.display='block';\n if(directCamNote)directCamNote.textContent='opening RoboVision camera relay...';\n if(directRes)directRes.textContent='CONNECTING';\n directImg.onerror=function(){ visionOn=false; directImg.style.display='none'; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable; reconnecting...'; if(directLiveNote) directLiveNote.textContent='camera reconnecting'; if(directRes) directRes.textContent='RETRYING'; if(visionRetryTimer)clearTimeout(visionRetryTimer);visionRetryTimer=setTimeout(function(){if(drawerId&&byId[drawerId]===n&&!visionOn)tryVisionOverlay(n,true);},3000); };\n directImg.onload=function(){ visionOn=true;if(visionRetryTimer){clearTimeout(visionRetryTimer);visionRetryTimer=null;}directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };\n directImg.src='/fed/media/robots/'+encodeURIComponent(directRef)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();\n return;\n var img=$('pk-vision-img'); if(!img) return;\n if(!force && String(window.localStorage.getItem('robopark.visionOverlay')||'').toLowerCase()!=='true'){\n visionOn=false; img.src=''; img.style.display='none'; return;\n }\n var row=schedRow(n)||{}, device=schedulerDevice(n)||{};\n var preferTail=/^100./.test(location.hostname);\n var ip=preferTail ? (device.tailscale_ip||row.tailscale_ip||device.lan_ip||row.lan_ip) : (device.lan_ip||row.lan_ip||device.tailscale_ip||row.tailscale_ip);\n if(!ip){ visionOn=false; img.src=''; img.style.display='none'; return; }\n var port=row.vision_port||5000;\n visionOn=true;\n img.onerror=function(){ visionOn=false; img.style.display='none'; img.src=''; };\n img.onload=function(){ img.style.display='block'; };\n img.src='http://'+ip+':'+port+'/video_feed';\n }\n function stopVision(){\n visionOn=false;\n if(visionRetryTimer){clearTimeout(visionRetryTimer);visionRetryTimer=null;}\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[String(x.robot_id||x.id||x.name||'robot').toLowerCase()]=x; if(x.name) roboparkByName[String(x.name).toLowerCase()]=x; });\n refreshDrawer();\n }).catch(function(){});\n }\n function schedulerDevice(n){\n // Deliberately does not call schedRow: telemetry can contain a legacy\n // robot name, while this lookup must choose the current enrolled device.\n var candidates=[];\n var ids=[n.schedulerDeviceId,n.schedulerRobotId,n.id,n.nodeId];\n for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]]) candidates.push(schedulerDevicesById[ids[i]]);\n var names=[n.name,n.displayName,n.label];\n for(var j=0;j<names.length;j++){\n var key=String(names[j]||'').toLowerCase();\n if(key&&schedulerDevicesByName[key]) candidates.push(schedulerDevicesByName[key]);\n }\n candidates=candidates.filter(function(device,index,list){return device&&list.indexOf(device)===index;});\n candidates.sort(function(a,b){return schedulerDeviceScore(b)-schedulerDeviceScore(a);});\n return candidates[0]||null;\n }\n function deviceList(inv, key, fallback){\n var list=inv&&Array.isArray(inv[key])?inv[key]:[];\n return list.length?list:fallback.map(function(id){ return {id:id,name:id}; });\n }\n function hasRealInventory(inv){ return !!(inv && ((inv.video&&inv.video.length>2) || (inv.audio_input&&inv.audio_input.length>1) || (inv.audio_output&&inv.audio_output.length>1))); }\n function refreshDeviceSelectors(n){\n var device=schedulerDevice(n)||{}, inv=device.device_inventory||{};\n if(hasRealInventory(inv)) deviceSelectorsReady=true;\n var query='';\n // Launch hardware profile shared by every deployed robot. Do not expose\n // unstable PortAudio indexes, HDMI, or Pi codec devices in production.\n var video=[{id:'/dev/video0',name:'H65 USB CAMERA (/dev/video0)'}];\n var input=[{id:'2',name:'USB microphone (hw:3,0)'}];\n var output=[{id:'1',name:'USB speaker (hw:2,0)'}];\n function fill(id, list, selected){\n var el=$(id); if(!el) return;\n // Heartbeat polling rebuilds this panel frequently. Preserve an\n // operator's unsaved choice instead of snapping back to the last\n // scheduler value while they move between camera/mic/speaker fields.\n var effectiveSelected=el.dataset.dirty==='true'?el.value:selected;\n el.innerHTML='';\n list.filter(function(x){ return !query || String(x.name||x.id).toLowerCase().indexOf(query)>-1 || String(x.id).toLowerCase().indexOf(query)>-1; }).forEach(function(x){\n var o=document.createElement('option'); o.value=String(x.id); o.textContent=String(x.name||x.id); if(String(x.id)===String(effectiveSelected||'')) o.selected=true; el.appendChild(o);\n });\n if(!el.value && effectiveSelected){ var o=document.createElement('option'); o.value=String(effectiveSelected); o.textContent=String(effectiveSelected)+(el.dataset.dirty==='true'?' (selected)':' (saved)'); o.selected=true; el.appendChild(o); }\n }\n fill('pk-video-device', video, '/dev/video0');\n fill('pk-audio-input', input, '2');\n fill('pk-audio-output', output, '1');\n var selectedOutput=$('pk-audio-output'),selectedLabel=$('pk-spk-selected');\n if(selectedLabel&&selectedOutput){var selectedOption=selectedOutput.options&&selectedOutput.selectedIndex>=0?selectedOutput.options[selectedOutput.selectedIndex]:null;selectedLabel.textContent=selectedOption?String(selectedOption.textContent||selectedOption.value):String(selectedOutput.value||'default');}\n var greetings=$('pk-greeting-phrases');\n if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));\n var testPhrase=$('pk-spk-phrase');if(testPhrase&&document.activeElement!==testPhrase&&device.greeting_phrases&&device.greeting_phrases.length)testPhrase.value=device.greeting_phrases[0];\n var note=$('pk-device-note');\n if(note){\n if(!device.id && n && n.connected){\n note.textContent='mesh connected; scheduler identity is bootstrapping or failed';\n return;\n }\n var source=inv.source||'robot heartbeat', counts=(inv.video||[]).length+'/'+(inv.audio_input||[]).length+'/'+(inv.audio_output||[]).length;\n note.textContent=hasRealInventory(inv) ? ('inventory from '+source+' \u00B7 camera/mic/speaker '+counts) : (device.id ? 'no real hardware inventory reported yet \u00B7 restart vision-agent + preview-agent on this robot' : 'waiting for robot heartbeat inventory');\n }\n }\n function saveDeviceSelectors(n){\n var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }\n var body={video_device:'/dev/video0', audio_device:'2', audio_output_device:'1',\n greeting_phrases:($('pk-greeting-phrases').value||'').split(String.fromCharCode(10)).map(function(x){return x.trim();}).filter(Boolean)};\n var button=$('pk-device-save'),note=$('pk-device-note');\n if(button){button.disabled=true;button.textContent='Saving...';}\n if(note)note.textContent='saving device selection to scheduler...';\n rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),body).then(function(saved){\n // Keep the drawer's local scheduler record current immediately. The\n // next /devices poll will confirm the same values from persistent DB.\n Object.assign(device,saved||body);\n ['pk-video-device','pk-audio-input','pk-audio-output'].forEach(function(field){var el=$(field);if(el)delete el.dataset.dirty;});\n refreshDeviceSelectors(n);\n if(note)note.textContent='saved \u00B7 robot will apply selection on its next config heartbeat';\n if(button){button.textContent='Saved';setTimeout(function(){if($('pk-device-save')===button)button.textContent='Save device selection + greetings';},1400);}\n showOut('camera, microphone, and speaker selection saved for '+(device.name||id));\n }).catch(function(e){\n if(note)note.textContent='save failed \u00B7 '+e.message;\n if(button)button.textContent='Retry save';\n showOut('device selection: '+e.message);\n }).then(function(){if(button)button.disabled=false;});\n }\n function fmtUptime(secs){\n if(secs==null) return '';\n secs=Math.round(secs);\n if(secs<60) return secs+'s';\n if(secs<3600) return Math.round(secs/60)+'m';\n return Math.round(secs/3600)+'h';\n }\n function restartService(n, svcName, btn){\n var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }\n if(btn){ btn.disabled=true; btn.textContent='queuing\u2026'; }\n rpAction('POST','/robopark/api/devices/'+encodeURIComponent(id)+'/services/'+encodeURIComponent(svcName)+'/restart',{}).then(function(){\n showOut('restart queued for '+svcName+' \u2014 picked up on the next robot check-in, up to about 10s');\n if(btn){ btn.textContent='queued'; }\n }).catch(function(e){\n showOut('restart ('+svcName+'): '+e.message);\n if(btn){ btn.disabled=false; btn.textContent='restart'; }\n });\n }\n function refreshSupervisorPanel(n){\n var list=$('pk-svc-list'), age=$('pk-svc-age'); if(!list) return;\n var device=schedulerDevice(n);\n var services=device&&device.supervisor_status;\n var reportedAt=device&&device.supervisor_status_at;\n if(!services || !services.length){\n list.innerHTML='<div class=\"pk-note\">no supervisor report yet \u2014 either robot_supervisor.py is not running on this robot, or it has not enrolled (no device_token) yet</div>';\n if(age) age.textContent='';\n return;\n }\n list.innerHTML=services.map(function(s){\n var cls=!s.enabled?'disabled':(s.running?'up':'down');\n var meta=!s.enabled?'disabled'\n : s.running?('pid '+s.pid+' \u00B7 up '+fmtUptime(s.uptime_seconds)+(s.failure_count?(' \u00B7 '+s.failure_count+' restart'+(s.failure_count===1?'':'s')+' so far'):''))\n : ('down'+(s.last_exit_code!=null?(' \u00B7 exit '+s.last_exit_code):'')+(s.failure_count?(' \u00B7 retry #'+s.failure_count):''));\n var btn=s.enabled?('<button class=\"rp-btn pk-svc-restart-btn\" data-svc=\"'+esc(s.name)+'\">restart</button>'):'';\n return '<div class=\"pk-svc-row '+cls+'\"><span class=\"pk-svc-dot\"></span><span class=\"pk-svc-name\">'+esc(s.name)+'</span><span class=\"pk-svc-meta\">'+esc(meta)+'</span>'+btn+'</div>';\n }).join('');\n if(age){\n if(reportedAt){\n var ageSec=(Date.now()-new Date(reportedAt+'Z').getTime())/1000;\n age.textContent = ageSec>30 ? ('\u26A0 last report '+fmtUptime(ageSec)+' ago \u2014 supervisor may be down') : ('last report '+fmtUptime(ageSec)+' ago');\n age.style.color = ageSec>30 ? 'var(--bad)' : '';\n } else { age.textContent=''; }\n }\n }\n\n // \u2500\u2500 C1: Per-robot shell section \u2500\u2500\n // Lets an operator tail service logs and run a small allowlist of\n // diagnostic commands (uptime, free -m, ps aux, etc.) on the robot\n // itself, without leaving the dashboard or opening a terminal. The\n // round trip is:\n // 1. dashboard POSTs /robopark/api/robots/{id}/shell/{tail,exec}\n // 2. scheduler queues a row in shell_requests\n // 3. robot supervisor's next status-report poll receives the row\n // in its commands field, runs the request on a background\n // thread, and POSTs the result to /api/devices/{id}/supervisor-output\n // 4. dashboard long-polls /robopark/api/robots/{id}/shell/wait/{rid}\n // with an 8s budget and renders the result.\n //\n // The buffer is per-robot, bounded at 5000 lines, persists across\n // drawer open/close. Service picker is populated from the supervisor\n // report (the same list shown in the section above).\n var SHELL_MAX_LINES = 5000;\n var _shellBuffers = {}; // device_id -> {lines:[{t, kind, text}], lastReqId, lastService}\n\n function _shellBuf(robotId){\n if(!_shellBuffers[robotId]) _shellBuffers[robotId] = { lines:[], lastReqId:null, lastService:'' };\n return _shellBuffers[robotId];\n }\n function _shellAppend(robotId, kind, text){\n var buf = _shellBuf(robotId);\n var now = Date.now();\n var lineText = String(text == null ? '' : text);\n // split on newlines so a multi-line result renders as separate rows\n var parts = lineText.split(/\\r?\\n/);\n for(var i=0;i<parts.length;i++){\n if(!parts[i] && i===parts.length-1) continue; // skip trailing empty\n buf.lines.push({ t: now, kind: kind, text: parts[i] });\n }\n while(buf.lines.length > SHELL_MAX_LINES) buf.lines.shift();\n }\n function _shellStatus(robotId, status, detail){\n _shellAppend(robotId, 'status', status + (detail ? (' \u00B7 '+detail) : ''));\n }\n function _renderShellBuffer(robotId){\n var out = $('pk-shell-out'); if(!out) return;\n var buf = _shellBuf(robotId);\n var html = '';\n for(var i=0;i<buf.lines.length;i++){\n var ln = buf.lines[i];\n var cls = ln.kind === 'status' ? 'pk-shell-status'\n : ln.kind === 'stderr' ? 'pk-shell-stderr'\n : ln.kind === 'header' ? 'pk-shell-header'\n : ln.kind === 'input' ? 'pk-shell-input'\n : 'pk-shell-stdout';\n var hh = (new Date(ln.t)).toTimeString().slice(0,8);\n html += '<div class=\"pk-shell-line '+cls+'\">'\n + '<span class=\"pk-shell-ts\">'+hh+'</span>'\n + '<span class=\"pk-shell-text\">'+esc(ln.text)+'</span>'\n + '</div>';\n }\n out.innerHTML = html;\n // auto-scroll if the user hasn't scrolled up\n if(out.__autoscroll !== false){\n out.scrollTop = out.scrollHeight;\n }\n }\n // Build the markup for the Shell section. Pulls the list of\n // available services from the supervisor_status report (if any),\n // otherwise shows a placeholder.\n function buildShellSection(robotId){\n var device = robotId ? schedulerDevicesByName[String(robotName({name:robotId, id:robotId})||'').toLowerCase()] || null : null;\n // Try to read the device row by id directly\n try{\n for(var k in schedulerDevicesById){\n if(k === robotId){ device = schedulerDevicesById[k]; break; }\n }\n }catch(e){}\n var services = device && device.supervisor_status;\n var svcOptions = '';\n if(services && services.length){\n for(var i=0;i<services.length;i++){\n svcOptions += '<option value=\"'+esc(services[i].name)+'\">'+esc(services[i].name)+'</option>';\n }\n } else {\n svcOptions = '<option value=\"\">\u2014 services will appear once the supervisor reports \u2014</option>';\n }\n return ''\n + '<div class=\"pk-sect\" id=\"pk-shell-sect\">'\n + '<h4>\uD83D\uDCBB shell <span class=\"pk-note\" style=\"font-weight:400;margin-left:0.4rem;\">per-robot logs &amp; diagnostics</span></h4>'\n + '<div class=\"pk-shell-row\">'\n + '<label class=\"pk-shell-lbl\">Service</label>'\n + '<select id=\"pk-shell-svc\" class=\"pk-shell-sel\" data-robot=\"'+esc(robotId||'')+'\">'+svcOptions+'</select>'\n + '<label class=\"pk-shell-lbl\" style=\"margin-left:0.5rem;\">Lines</label>'\n + '<input type=\"number\" id=\"pk-shell-tail\" class=\"pk-shell-num\" value=\"200\" min=\"10\" max=\"2000\" step=\"10\">'\n + '<button type=\"button\" class=\"rp-btn\" id=\"pk-shell-fetch\" data-robot=\"'+esc(robotId||'')+'\" style=\"margin-left:0.4rem;\">Tail logs</button>'\n + '</div>'\n + '<div class=\"pk-shell-out-wrap\">'\n + '<div class=\"pk-shell-out\" id=\"pk-shell-out\" tabindex=\"0\" role=\"log\" aria-label=\"robot shell output\"><div class=\"pk-shell-status\">\u2014 shell idle \u2014</div></div>'\n + '</div>'\n + '<div class=\"pk-shell-row\" style=\"margin-top:0.4rem;\">'\n + '<input type=\"text\" id=\"pk-shell-cmd\" list=\"pk-shell-cmd-list\" placeholder=\"uptime, free, df, ps, systemctl status preview_agent, journalctl -u preview_agent, \u2026\" autocomplete=\"off\" style=\"flex:1;min-width:0;\">'\n + '<datalist id=\"pk-shell-cmd-list\">'\n + '<option value=\"uptime\"><option value=\"free\"><option value=\"df\"><option value=\"ps\"><option value=\"uname\"><option value=\"hostname\"><option value=\"ip\"><option value=\"ss\">'\n + '<option value=\"systemctl-status\"><option value=\"journalctl\"><option value=\"ls-logs\"><option value=\"os-release\">'\n + '</datalist>'\n + '<button type=\"button\" class=\"rp-btn primary\" id=\"pk-shell-run\" data-robot=\"'+esc(robotId||'')+'\" style=\"margin-left:0.4rem;\">Run</button>'\n + '<button type=\"button\" class=\"rp-btn\" id=\"pk-shell-autoscroll\" data-robot=\"'+esc(robotId||'')+'\" title=\"Auto-scroll to the newest line\" style=\"margin-left:0.4rem;\">\u2913 auto</button>'\n + '<button type=\"button\" class=\"rp-btn\" id=\"pk-shell-clear\" data-robot=\"'+esc(robotId||'')+'\" style=\"margin-left:0.4rem;\">Clear</button>'\n + '</div>'\n + '<div class=\"pk-shell-status\" id=\"pk-shell-stateline\" style=\"margin-top:0.35rem;\">idle</div>'\n + '<div class=\"pk-note\" id=\"pk-shell-help\" style=\"margin-top:0.35rem;\">'\n + 'Run a pre-approved diagnostic on this robot. Output is captured by robot_supervisor.py (8s budget, 64KB cap, allowlisted commands only).'\n + '</div>'\n + '</div>';\n }\n // Wire the shell section's events. Bound once per buildDrawer call,\n // but the inputs are re-created each time so we re-bind here.\n function _bindShellSection(n){\n if(n.kind !== 'robot') return;\n var robotId = schedId(n);\n if(!robotId) return;\n var $out = $('pk-shell-out');\n if($out && $out.__autoscroll === undefined) $out.__autoscroll = true;\n // initial render of any previously-captured output for this robot\n _renderShellBuffer(robotId);\n var svcSel = $('pk-shell-svc');\n if(svcSel && !svcSel.value && svcSel.options.length){\n // pick the first enabled-looking service\n for(var i=0;i<svcSel.options.length;i++){\n if(svcSel.options[i].value){ svcSel.selectedIndex = i; break; }\n }\n }\n var fetchBtn = $('pk-shell-fetch');\n if(fetchBtn) fetchBtn.addEventListener('click', function(){\n var svc = ($('pk-shell-svc')||{}).value || '';\n var lines = parseInt(($('pk-shell-tail')||{}).value, 10) || 200;\n _shellTail(robotId, svc, lines);\n });\n var runBtn = $('pk-shell-run');\n if(runBtn) runBtn.addEventListener('click', function(){\n var inp = $('pk-shell-cmd');\n if(!inp) return;\n var raw = (inp.value || '').trim();\n if(!raw) return;\n // parse \"name param1=val1 param2=val2\" into the params object\n var parts = raw.split(/\\s+/);\n var name = parts.shift();\n var params = {};\n for(var i=0;i<parts.length;i++){\n var eq = parts[i].indexOf('=');\n if(eq < 0) continue;\n params[parts[i].slice(0, eq)] = parts[i].slice(eq+1);\n }\n // convenience: if the name is systemctl-status or journalctl,\n // auto-fill <name> from the selected service\n if((name === 'systemctl-status' || name === 'journalctl' || name === 'tail') && !params.name && !params.path){\n var svc = ($('pk-shell-svc')||{}).value || '';\n if(name === 'tail' && svc) params.path = svc + '.log';\n else if(svc) params.name = svc;\n }\n if(name === 'tail' && !params.n) params.n = String(parseInt(($('pk-shell-tail')||{}).value, 10) || 200);\n // log the command itself\n _shellAppend(robotId, 'input', '$ '+raw);\n inp.value = '';\n _shellExec(robotId, name, params);\n });\n var cmdInp = $('pk-shell-cmd');\n if(cmdInp) cmdInp.addEventListener('keydown', function(ev){\n if(ev.key === 'Enter'){ ev.preventDefault(); var b=$('pk-shell-run'); if(b) b.click(); }\n });\n var autoBtn = $('pk-shell-autoscroll');\n if(autoBtn) autoBtn.addEventListener('click', function(){\n var o = $('pk-shell-out'); if(!o) return;\n o.__autoscroll = !o.__autoscroll;\n autoBtn.classList.toggle('primary', !!o.__autoscroll);\n if(o.__autoscroll) o.scrollTop = o.scrollHeight;\n });\n if(autoBtn) autoBtn.classList.add('primary');\n var clearBtn = $('pk-shell-clear');\n if(clearBtn) clearBtn.addEventListener('click', function(){\n _shellBuffers[robotId] = { lines:[], lastReqId:null, lastService:'' };\n _renderShellBuffer(robotId);\n _setShellStatus(robotId, 'cleared');\n });\n // user scrolled up disables autoscroll\n if($out) $out.addEventListener('scroll', function(){\n var atBottom = ($out.scrollHeight - $out.scrollTop - $out.clientHeight) < 8;\n $out.__autoscroll = atBottom;\n var ab = $('pk-shell-autoscroll'); if(ab) ab.classList.toggle('primary', atBottom);\n });\n }\n function _setShellStatus(robotId, text, kind){\n var el = $('pk-shell-stateline'); if(!el) return;\n el.textContent = text;\n el.style.color = kind === 'error' ? 'var(--bad)' : kind === 'ok' ? 'var(--ok)' : 'var(--muted)';\n }\n // POST /api/robots/{id}/shell/tail and long-poll the result.\n function _shellTail(robotId, service, lines){\n _setShellStatus(robotId, 'tailing '+service+' (waiting for robot\u2026)');\n _shellAppend(robotId, 'status', '\u2014\u2014 tail '+service+'.log, last '+lines+' lines \u2014\u2014');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/tail',{service:service, params:{lines:lines}})\n .then(function(q){\n return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, service, 'tail');\n })\n .catch(function(e){ _setShellStatus(robotId, 'tail failed: '+e.message, 'error'); _shellAppend(robotId, 'status', '\u2717 tail failed: '+e.message); });\n }\n function _shellExec(robotId, name, params){\n _setShellStatus(robotId, 'running '+name+' (waiting for robot\u2026)');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/exec',{params:{name:name, params:params}})\n .then(function(q){\n return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, '', name);\n })\n .catch(function(e){ _setShellStatus(robotId, 'exec failed: '+e.message, 'error'); _shellAppend(robotId, 'status', '\u2717 exec failed: '+e.message); });\n }\n // Long-poll the result endpoint. The scheduler blocks up to 8s\n // before returning timed_out: true; we just respect that and\n // give the operator a clear \"still waiting\" line in the output.\n function _shellWaitForResult(robotId, requestId, deviceId, service, label){\n var url = '/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/wait/'+encodeURIComponent(requestId)+'?timeout=8';\n var pollOnce = function(){\n return getJson(url).then(function(r){\n if(r && r.completed){\n var result = r.result || {};\n _renderShellResult(robotId, result, label);\n return result;\n }\n // Not done yet \u2014 fall through to manual polling\n throw new Error('not_ready');\n });\n };\n return pollOnce().catch(function(){\n // Manual poll loop (covers the case where /wait timed out but\n // the robot is still chewing). Cap at 12s of fallback polling.\n var deadline = Date.now() + 12000;\n return new Promise(function(resolve,reject){\n var tick = function(){\n if(Date.now() > deadline){\n _setShellStatus(robotId, '\u26A0 '+label+' did not complete in 20s \u2014 robot may be offline', 'error');\n _shellAppend(robotId, 'status', '\u2717 '+label+' did not complete in 20s');\n reject(new Error(label+' did not complete in 20s'));\n return;\n }\n getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/result/'+encodeURIComponent(requestId))\n .then(function(r){\n if(r && r.completed){var result=r.result||{};_renderShellResult(robotId,result,label);resolve(result);return;}\n setTimeout(tick,500);\n })\n .catch(function(){setTimeout(tick,800);});\n };\n tick();\n });\n });\n }\n function _renderShellResult(robotId, result, label){\n if(!result || !result.ok){\n var err = (result && result.error) || '(no response)';\n _setShellStatus(robotId, '\u2717 '+label+' failed: '+err, 'error');\n _shellAppend(robotId, 'stderr', '\u2717 '+err);\n _renderShellBuffer(robotId);\n return;\n }\n var exit = (result.exit_code === 0) ? '\u2713 exit 0' : ('exit '+result.exit_code);\n _setShellStatus(robotId, exit, result.exit_code === 0 ? 'ok' : 'error');\n if(result.stdout){ _shellAppend(robotId, 'stdout', result.stdout); }\n if(result.stderr){ _shellAppend(robotId, 'stderr', result.stderr); }\n if(result.truncated){ _shellAppend(robotId, 'status', '\u26A0 output truncated to '+SHELL_OUTPUT_LIMIT+' bytes'); }\n if(result.exit_code !== 0){ _shellAppend(robotId, 'status', '\u2014\u2014 '+label+' finished with exit '+result.exit_code+' \u2014\u2014'); }\n else { _shellAppend(robotId, 'status', '\u2014\u2014 '+label+' finished \u2014\u2014'); }\n _renderShellBuffer(robotId);\n }\n // 64KB cap mirrors the robot's SHELL_OUTPUT_MAX_BYTES\n var SHELL_OUTPUT_LIMIT = 64 * 1024;\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 &amp; add robot</button></div>'\n + '<div class=\"rp-field\"><label id=\"pk-setup-clicmd-label\">or run this ON THE ROBOT (mints its own token)</label><textarea id=\"pk-setup-clicmd\" readonly rows=\"4\" style=\"resize:vertical;font-family:var(--mono);font-size:.62rem;\"></textarea></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\" 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'),label=$('pk-setup-clicmd-label');if(el)el.value=buildPadRobotCommand(nm,cid,'');if(label)label.textContent='or run this ON THE ROBOT (mints its own token)';\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 var devForPm=schedulerDevice(n)||{};\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 +'<img id=\"pk-vision-img\" style=\"width:100%;height:100%;object-fit:cover;position:absolute;inset:0;display:none;border-radius:inherit\" alt=\"vision feed\"/>'\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 live feed\u2026</div>'\n +'<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn\" id=\"pk-golive\" data-robot=\"'+esc(n.name)+'\">\u21BB Reconnect camera</button><button class=\"rp-btn primary\" id=\"pk-camera-fullscreen\">Fullscreen operations view</button><span class=\"pk-note\" id=\"pk-golivenote\" style=\"margin:0\">always-on RoboVision relay</span></div>'\n +'<label class=\"pk-prodtoggle\"><input type=\"checkbox\" id=\"pk-prodmode\"'+(devForPm.production_mode?' checked':'')+'><span>Production mode \u2014 continuous motion-triggered loop, camera always live</span></label>')\n + sect('Remote production test','<div id=\"pk-live-stage\" class=\"pk-live-stage-focus waiting\"><span class=\"eyebrow\">Current production stage</span><b>Checking robot state...</b><span>Waiting for scheduler telemetry</span></div><div id=\"pk-pipeline-summary\" class=\"pk-pipeline-summary\">Checking robot, camera, scheduler and LiveKit...</div>'\n + '<div class=\"pk-ctl\" style=\"margin-top:0.55rem\"><button class=\"rp-btn primary\" id=\"pk-unattended-test\">Run unattended readiness</button><button class=\"rp-btn\" id=\"pk-pipeline-start\">Start full test</button><button class=\"rp-btn danger\" id=\"pk-pipeline-stop\">Stop conversation</button></div>'\n + '<div class=\"pk-note\" id=\"pk-unattended-result\">Unattended readiness runs an acoustic speaker/mic round-trip, triggers the production motion path, and requires greeting playback on the robot.</div>'\n + '<div class=\"pk-pipeline\" id=\"pk-pipeline\"></div>')\n + sect('Production run history','<div class=\"pk-pipe-history\" style=\"margin-top:0;padding-top:0;border-top:0\"><div class=\"pk-pipe-history-head\"><b>Persistent sessions</b><div class=\"pk-history-pager\"><button class=\"rp-btn\" id=\"pk-history-prev\">Prev</button><span id=\"pk-history-page\">page 1</span><button class=\"rp-btn\" id=\"pk-history-next\">Next</button></div></div><div class=\"pk-pipe-history-list\" id=\"pk-pipeline-history\"><div class=\"pk-note\">Loading persistent sessions...</div></div></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><div class=\"pk-ctl\" style=\"margin-top:.5rem\"><button class=\"rp-btn\" id=\"pk-mic-listen\">Listen in browser</button><label class=\"pk-note\" style=\"display:flex;align-items:center;gap:.35rem;margin:0\">volume <input id=\"pk-mic-volume\" type=\"range\" min=\"0\" max=\"100\" value=\"80\" style=\"width:90px\"></label></div><div class=\"pk-note\" id=\"pk-mic-listen-note\">Muted by default. Use headphones to prevent acoustic feedback.</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\">TTS plays on the robot speaker \u2014 use \u201CTest speaker\u201D below to round-trip a tone and confirm the audio path</div>'\n + '<div class=\"rp-form\" id=\"pk-spk-test\" style=\"grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);\">'\n + '<div class=\"rp-field\"><label>Test type</label><select id=\"pk-spk-mode\"><option value=\"tts\" selected>Cached character voice</option><option value=\"tone\">Tone + mic round-trip</option></select></div>'\n + '<div class=\"rp-field\"><label>Selected output</label><div class=\"pk-note\" id=\"pk-spk-selected\" style=\"margin:.45rem 0 0\">loading device...</div></div>'\n + '<div class=\"rp-field\" style=\"grid-column:1/-1\"><label>Voice test phrase</label><input id=\"pk-spk-phrase\" value=\"Hello, this is a RoboPark speaker test.\" maxlength=\"240\"></div>'\n + '<div class=\"rp-field\"><label>Frequency</label><select id=\"pk-spk-freq\"><option value=\"500\">500 Hz (low)</option><option value=\"1000\" selected>1000 Hz (1 kHz reference)</option><option value=\"2000\">2000 Hz (presence)</option><option value=\"3000\">3000 Hz (clarity)</option></select></div>'\n + '<div class=\"rp-field\"><label>Duration</label><select id=\"pk-spk-dur\"><option value=\"0.4\">0.4 s</option><option value=\"0.6\" selected>0.6 s</option><option value=\"1.0\">1.0 s</option></select></div>'\n + '<div style=\"grid-column:1/-1;display:flex;gap:0.4rem;align-items:center;\">'\n + '<button class=\"rp-btn primary\" id=\"pk-spk-runtest\" data-robot=\"'+esc(schedId(n))+'\">Play cached voice</button>'\n + '<button class=\"rp-btn\" id=\"pk-spk-tone\">Quick tone</button>'\n + '<span class=\"pk-note\" id=\"pk-spk-test-stateline\" style=\"margin:0\">awaiting test</span>'\n + '</div>'\n + '<div style=\"grid-column:1/-1;\" id=\"pk-spk-test-result\"></div>'\n + '<div style=\"grid-column:1/-1;\" id=\"pk-spk-test-history\"></div>'\n + '</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('\uD83E\uDE7A robot services','<div id=\"pk-svc-list\" class=\"pk-svc-list\"><div class=\"pk-note\">waiting for supervisor report\u2026</div></div><div class=\"pk-note\" id=\"pk-svc-age\" style=\"margin-top:0.35rem;\"></div><div class=\"rp-field\" style=\"margin-top:0.55rem;\"><label>Quick run (on the robot, from packages/robopark/scheduler)</label><input id=\"pk-svc-clicmd\" readonly value=\"python robot_supervisor.py\"></div><button class=\"rp-btn\" id=\"pk-svc-clicmd-copy\" style=\"margin-top:0.35rem;\">\uD83D\uDCCB Copy command</button>')\n + buildShellSection(schedId(n))\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><div class=\"rp-config-actions\" style=\"margin-top:.45rem\"><button class=\"rp-btn primary\" id=\"pk-eff-save\" data-robot=\"'+esc(schedId(n))+'\">Apply to robot</button><button class=\"rp-btn\" id=\"pk-edit-character\">Edit character</button><button class=\"rp-btn\" id=\"pk-edit-stack\">Edit stack</button><button class=\"rp-btn\" id=\"pk-new-stack\">New stack</button></div>');\n b.innerHTML += sect('Microphone setup suite','<div class=\"pk-mic-suite\"><div class=\"pk-mic-health\" id=\"pk-mic-health\"><div class=\"pk-mic-check\"><div class=\"k\">Hardware lock</div><div class=\"v\">USB mic hw:3,0</div></div><div class=\"pk-mic-check\"><div class=\"k\">PCM capture</div><div class=\"v\">not tested</div></div><div class=\"pk-mic-check\"><div class=\"k\">Production track</div><div class=\"v\">waiting</div></div></div><div class=\"rp-form\" style=\"grid-template-columns:1fr 1fr;gap:.4rem\"><div class=\"rp-field\"><label>Ground-truth capture</label><select id=\"pk-mic-duration\"><option value=\"2\">2 seconds</option><option value=\"4\" selected>4 seconds</option><option value=\"8\">8 seconds</option></select></div><div class=\"rp-field\"><label>Locked input</label><div class=\"pk-note\" style=\"margin:.45rem 0 0\">USB microphone (plughw:3,0)</div></div></div><div class=\"pk-ctl\"><button class=\"rp-btn primary\" id=\"pk-mic-groundtruth\">Run raw mic capture</button><button class=\"rp-btn\" id=\"pk-mic-roundtrip\">Run speaker + mic test</button><button class=\"rp-btn\" id=\"pk-mic-apply\">Reapply production profile</button></div><div class=\"pk-note\" id=\"pk-mic-test-state\">Raw capture bypasses LiveKit and tests the exact ALSA path. Round-trip then verifies the physical speaker-to-mic path.</div><div id=\"pk-mic-test-result\"></div></div>');\n b.innerHTML += sect('Motor registry + sequence canvas','<div class=\"rp-field\"><label>Robot-local motor server</label><input id=\"pk-motor-url\" value=\"http://127.0.0.1:8001\" placeholder=\"http://127.0.0.1:8001\"></div><div class=\"pk-motor-registry\" id=\"pk-motor-registry\"></div><button class=\"rp-btn\" id=\"pk-motor-add\" style=\"margin-top:.4rem\">Add registered motor</button><div class=\"rp-form\" style=\"grid-template-columns:1fr 1fr;margin-top:.7rem\"><div class=\"rp-field\"><label>Saved sequence</label><select id=\"pk-motor-sequence\"></select></div><div class=\"rp-field\"><label>Sequence name</label><input id=\"pk-motor-sequence-name\" placeholder=\"Greeting motion\"></div></div><div class=\"pk-motor-canvas\" id=\"pk-motor-canvas\"></div><div class=\"pk-ctl\"><button class=\"rp-btn\" id=\"pk-motor-step-add\">Add timed node</button><button class=\"rp-btn primary\" id=\"pk-motor-save\">Save motor profile</button><button class=\"rp-btn\" id=\"pk-motor-run\">Run selected sequence</button><button class=\"rp-btn danger\" id=\"pk-motor-test-all\">Test all registered GPIO</button></div><label class=\"pk-prodtoggle\"><input type=\"checkbox\" id=\"pk-motor-greeting\"><span>Run this sequence automatically with the greeting pipeline</span></label><div class=\"pk-note\">Full GPIO test pulses registered relays only: 300 ms on, 200 ms pause, one at a time.</div><div class=\"pk-motor-status\" id=\"pk-motor-status\">Loading motor profile...</div>');\n b.innerHTML += sect('Production camera + audio','<div class=\"pk-note\" id=\"pk-device-search\" style=\"margin-bottom:.55rem\">Hardware locked for launch. Camera, microphone, and speaker cannot drift between restarts.</div><div class=\"rp-field\"><label>Camera input</label><select id=\"pk-video-device\" disabled></select></div><div class=\"rp-field\"><label>Microphone input</label><select id=\"pk-audio-input\" disabled></select></div><div class=\"rp-field\"><label>Speaker output</label><select id=\"pk-audio-output\" disabled></select></div><button class=\"rp-btn primary\" id=\"pk-device-test-speaker\" style=\"margin-top:0.35rem\">Test USB speaker now</button><div class=\"rp-field\"><label>Preset greeting phrases <span class=\"pk-note\">one per line, cached per voice</span></label><textarea id=\"pk-greeting-phrases\" rows=\"3\" placeholder=\"Hi, I am Jaguar! / Welcome to RoboPark.\"></textarea></div><button class=\"rp-btn\" id=\"pk-device-save\" style=\"margin-top:0.35rem\">Save greetings + enforce hardware</button><div class=\"pk-note\" id=\"pk-device-note\">waiting for robot heartbeat inventory</div>');\n // Put live hardware selection beside the camera and audio tests,\n // ahead of persona and service administration in the Park drawer.\n var deviceInput=$('pk-device-search'), deviceSection=deviceInput&&deviceInput.closest('.pk-sect');\n var speakerTest=$('pk-spk-test'), speakerSection=speakerTest&&speakerTest.closest('.pk-sect');\n if(deviceSection && speakerSection) speakerSection.after(deviceSection);\n refreshDeviceSelectors(n);\n b.innerHTML += '<div class=\"pk-ctl\" style=\"margin-top:0.5rem\"><button class=\"rp-btn danger\" id=\"pk-recover\" data-robot=\"'+esc(n.name)+'\">Recover robot</button><span class=\"pk-note\" style=\"margin:0\">clears stale sessions and queued triggers for this robot</span></div>';\n var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });\n ['pk-video-device','pk-audio-input','pk-audio-output'].forEach(function(field){\n var select=$(field);if(!select)return;\n select.addEventListener('change',function(){\n select.dataset.dirty='true';\n var note=$('pk-device-note');if(note)note.textContent='unsaved selection \u00B7 click Save device selection + greetings';\n if(field==='pk-audio-output'){\n var selectedLabel=$('pk-spk-selected'),option=select.options&&select.selectedIndex>=0?select.options[select.selectedIndex]:null;\n if(selectedLabel)selectedLabel.textContent=option?String(option.textContent||option.value):String(select.value||'default');\n }\n });\n });\n var micListen=$('pk-mic-listen');if(micListen)micListen.addEventListener('click',function(){setMicMonitorAudible(!micMonitorAudible);});\n var micVolume=$('pk-mic-volume');if(micVolume)micVolume.addEventListener('input',function(){micMonitorVolume=Math.max(0,Math.min(1,Number(micVolume.value||80)/100));if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;});\n var micGround=$('pk-mic-groundtruth');if(micGround)micGround.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicGroundtruth(n);});\n var micRound=$('pk-mic-roundtrip');if(micRound)micRound.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicRoundtrip(n);});\n var micApply=$('pk-mic-apply');if(micApply)micApply.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveDeviceSelectors(n);});\n var motorAdd=$('pk-motor-add');if(motorAdd)motorAdd.addEventListener('click',function(){motorEditorAddMotor();});\n var motorStep=$('pk-motor-step-add');if(motorStep)motorStep.addEventListener('click',function(){motorEditorAddStep();});\n var motorSave=$('pk-motor-save');if(motorSave)motorSave.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveMotorProfile(n);});\n var motorRun=$('pk-motor-run');if(motorRun)motorRun.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMotorSequence(n);});\n var motorTestAll=$('pk-motor-test-all');if(motorTestAll)motorTestAll.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}testAllMotorRelays(n);});\n var motorSeq=$('pk-motor-sequence');if(motorSeq)motorSeq.addEventListener('change',function(){selectMotorSequence(motorSeq.value);});\n var motorRegistry=$('pk-motor-registry');if(motorRegistry)motorRegistry.addEventListener('click',function(ev){var button=ev.target.closest('[data-motor-action]');if(!button)return;var index=Number(button.dataset.index);if(button.dataset.motorAction==='remove'){motorProfile.registry.splice(index,1);renderMotorEditor();}else if(button.dataset.motorAction==='test'){testMotorRelay(n,index);}});\n var motorCanvas=$('pk-motor-canvas');if(motorCanvas)motorCanvas.addEventListener('click',function(ev){var button=ev.target.closest('[data-step-remove]');if(button){captureMotorEditor();motorEditorSequence().steps.splice(Number(button.dataset.stepRemove),1);renderMotorEditor();}});\n loadMotorProfile(n);\n startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);\n _bindShellSection(n);\n _spkTestRenderHistory(schedId(n));\n var runSpkTest = $('pk-spk-runtest'); if(runSpkTest) runSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n); });\n var runSpkTone = $('pk-spk-tone'); if(runSpkTone) runSpkTone.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n,'tone'); });\n var quickSpkTest = $('pk-device-test-speaker'); if(quickSpkTest) quickSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n,'tts'); });\n var spkMode = $('pk-spk-mode'); if(spkMode) spkMode.addEventListener('change', function(){ if(runSpkTest) runSpkTest.textContent=spkMode.value==='tts'?'Play cached voice':'Run tone test'; });\n var gl=$('pk-golive');\n if(gl){\n gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });\n }\n var fullCam=$('pk-camera-fullscreen'); if(fullCam) fullCam.addEventListener('click',function(){ openCameraOperations(n); });\n var editCharacter=$('pk-edit-character'); if(editCharacter) editCharacter.addEventListener('click',function(){var id=$('pk-eff-char').value;if(id)openCharacterManager(id);else showOut('Choose a character to edit');});\n var editStack=$('pk-edit-stack'); if(editStack) editStack.addEventListener('click',function(){var id=$('pk-eff-voice').value;if(id)openVoiceStackManager(id);else showOut('Choose a voice stack to edit');});\n var newStack=$('pk-new-stack'); if(newStack) newStack.addEventListener('click',function(){openVoiceStackManager();});\n var pm=$('pk-prodmode'); if(pm) pm.addEventListener('change', function(){ if(rpReadOnly){ pm.checked=!pm.checked; showOut('read-only node'); return; } setProductionMode(n, pm.checked); });\n var svcCopy=$('pk-svc-clicmd-copy'); if(svcCopy) svcCopy.addEventListener('click', function(){ var i=$('pk-svc-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied \u2014 run it on the robot itself'); });\n var svcList=$('pk-svc-list'); if(svcList) svcList.addEventListener('click', function(ev){\n var btn=ev.target.closest('.pk-svc-restart-btn'); if(!btn) return;\n if(rpReadOnly){ showOut('read-only node'); return; }\n restartService(n, btn.dataset.svc, btn);\n });\n var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });\n var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });\n var pipelineStart=$('pk-pipeline-start'); if(pipelineStart) pipelineStart.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } startPipelineTest(n); });\n var unattended=$('pk-unattended-test'); if(unattended) unattended.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runUnattendedReadiness(n); });\n var pipelineStop=$('pk-pipeline-stop'); if(pipelineStop) pipelineStop.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } stopPipelineTest(n); });\n var historyPrev=$('pk-history-prev');if(historyPrev)historyPrev.addEventListener('click',function(){pipelineHistoryPage=Math.max(1,pipelineHistoryPage-1);loadPipelineHistory(n);});\n var historyNext=$('pk-history-next');if(historyNext)historyNext.addEventListener('click',function(){pipelineHistoryPage+=1;loadPipelineHistory(n);});\n var historyList=$('pk-pipeline-history');if(historyList)historyList.addEventListener('click',function(ev){var card=ev.target.closest('.pk-run-card');if(card)openSessionDetailModal({session_id:card.dataset.session});});\n pipelineHistoryPage=1;\n loadPipelineHistory(n);\n pollPipeline(n);\n if(pipelineTimer) clearInterval(pipelineTimer);\n pipelineTimer=setInterval(function(){ if(drawerId===n.id) pollPipeline(n); }, 1500);\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\n var motorProfile={registry:[],sequences:[],greeting_sequence_id:null,motor_server_url:'http://127.0.0.1:8001'};\n var motorSequenceId='';\n function motorEditorSequence(){\n var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});\n if(!sequence){sequence={id:motorSequenceId||('sequence-'+Date.now()),name:'New sequence',steps:[]};motorSequenceId=sequence.id;motorProfile.sequences.push(sequence);}\n if(!Array.isArray(sequence.steps))sequence.steps=[];\n return sequence;\n }\n function captureMotorEditor(){\n var rows=document.querySelectorAll('#pk-motor-registry .pk-motor-row');\n if(rows.length)motorProfile.registry=Array.prototype.map.call(rows,function(row,index){\n var fields=row.querySelectorAll('input');\n return {id:slugify(fields[0].value)||('motor-'+(index+1)),name:fields[1].value.trim()||('Motor '+(index+1)),gpio:Number(fields[2].value),active_high:fields[3].checked,max_duration_ms:Number(fields[4].value)||3000};\n });\n var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});\n if(sequence){\n var name=$('pk-motor-sequence-name');if(name)sequence.name=name.value.trim()||sequence.name||'Sequence';\n sequence.steps=Array.prototype.map.call(document.querySelectorAll('#pk-motor-canvas .pk-motor-node'),function(node){\n return {motor_id:node.querySelector('[data-step-motor]').value,delay_ms:Number(node.querySelector('[data-step-delay]').value)||0,duration_ms:Number(node.querySelector('[data-step-duration]').value)||300};\n });\n var greeting=$('pk-motor-greeting');if(greeting)motorProfile.greeting_sequence_id=greeting.checked?sequence.id:(motorProfile.greeting_sequence_id===sequence.id?null:motorProfile.greeting_sequence_id);\n }\n var url=$('pk-motor-url');if(url)motorProfile.motor_server_url=url.value.trim()||'http://127.0.0.1:8001';\n }\n function renderMotorEditor(){\n var registry=$('pk-motor-registry'),canvas=$('pk-motor-canvas'),select=$('pk-motor-sequence');if(!registry||!canvas||!select)return;\n registry.innerHTML=(motorProfile.registry||[]).map(function(motor,index){return '<div class=\"pk-motor-row\"><input value=\"'+esc(motor.id)+'\" aria-label=\"Motor id\"><input value=\"'+esc(motor.name)+'\" aria-label=\"Motor name\"><input type=\"number\" min=\"2\" max=\"27\" value=\"'+esc(motor.gpio)+'\" aria-label=\"BCM GPIO\"><label title=\"Active high\"><input type=\"checkbox\" '+(motor.active_high!==false?'checked':'')+'> high</label><input type=\"number\" min=\"50\" max=\"10000\" step=\"50\" value=\"'+esc(motor.max_duration_ms||3000)+'\" aria-label=\"Max pulse milliseconds\"><button class=\"rp-btn\" data-motor-action=\"test\" data-index=\"'+index+'\">Test</button><button class=\"rp-btn danger\" data-motor-action=\"remove\" data-index=\"'+index+'\">Remove</button></div>';}).join('')||'<div class=\"pk-note\">No relays registered. Add only wired BCM outputs.</div>';\n select.innerHTML=(motorProfile.sequences||[]).map(function(sequence){return '<option value=\"'+esc(sequence.id)+'\" '+(sequence.id===motorSequenceId?'selected':'')+'>'+esc(sequence.name||sequence.id)+'</option>';}).join('')+'<option value=\"__new__\">+ New sequence</option>';\n var sequence=motorEditorSequence(),name=$('pk-motor-sequence-name'),greeting=$('pk-motor-greeting'),url=$('pk-motor-url');\n if(name)name.value=sequence.name||'';if(greeting)greeting.checked=motorProfile.greeting_sequence_id===sequence.id;if(url)url.value=motorProfile.motor_server_url||'http://127.0.0.1:8001';\n var motorOptions=(motorProfile.registry||[]).map(function(motor){return '<option value=\"'+esc(motor.id)+'\">'+esc(motor.name+' | BCM '+motor.gpio)+'</option>';}).join('');\n canvas.innerHTML=(sequence.steps||[]).map(function(step,index){return '<div class=\"pk-motor-node\"><div class=\"pk-motor-node-num\">'+(index+1)+'</div><label>Relay<select data-step-motor>'+motorOptions.replace('value=\"'+esc(step.motor_id)+'\"','value=\"'+esc(step.motor_id)+'\" selected')+'</select></label><label>Delay ms<input data-step-delay type=\"number\" min=\"0\" max=\"30000\" step=\"50\" value=\"'+esc(step.delay_ms||0)+'\"></label><label>Pulse ms<input data-step-duration type=\"number\" min=\"50\" max=\"10000\" step=\"50\" value=\"'+esc(step.duration_ms||300)+'\"></label><button class=\"rp-btn danger\" data-step-remove=\"'+index+'\">Remove</button></div>';}).join('')||'<div class=\"pk-note\">No timed nodes. Add a node to build the sequence.</div>';\n }\n function selectMotorSequence(id){\n captureMotorEditor();\n if(id==='__new__'){var sequence={id:'sequence-'+Date.now(),name:'New sequence',steps:[]};motorProfile.sequences.push(sequence);motorSequenceId=sequence.id;}else motorSequenceId=id;\n renderMotorEditor();\n }\n function motorEditorAddMotor(){captureMotorEditor();var number=motorProfile.registry.length+1;motorProfile.registry.push({id:'motor-'+number,name:'Motor '+number,gpio:Math.min(27,Math.max(2,number+4)),active_high:true,max_duration_ms:3000});renderMotorEditor();}\n function motorEditorAddStep(){captureMotorEditor();if(!motorProfile.registry.length){motorStatus('Register a motor before adding a timed node','bad');return;}motorEditorSequence().steps.push({motor_id:motorProfile.registry[0].id,delay_ms:0,duration_ms:300});renderMotorEditor();}\n function motorStatus(message,kind){var host=$('pk-motor-status');if(host){host.textContent=message;host.className='pk-motor-status '+(kind||'');}}\n function loadMotorProfile(n){\n var robotId=schedId(n);motorStatus('Loading saved motor profile...');\n getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile').then(function(data){motorProfile={registry:data.registry||[],sequences:data.sequences||[],greeting_sequence_id:data.greeting_sequence_id||null,motor_server_url:(schedulerDevice(n)||{}).motor_server_url||data.motor_server_url||'http://127.0.0.1:8001'};if(!motorProfile.sequences.length)motorProfile.sequences=[{id:'greeting-motion',name:'Greeting motion',steps:[]}];motorSequenceId=motorProfile.greeting_sequence_id||motorProfile.sequences[0].id;renderMotorEditor();if(motorProfile.registry.length){motorStatus('Saved profile loaded | '+motorProfile.registry.length+' relay(s), '+motorProfile.sequences.length+' sequence(s)','ok');return;}motorStatus('No central registry; discovering robot-local relays...');return discoverMotorRelays(n,true);}).catch(function(error){motorStatus('Motor profile unavailable: '+error.message,'bad');});\n }\n function saveMotorProfile(n){\n captureMotorEditor();var robotId=schedId(n),url=motorProfile.motor_server_url;\n var parsedUrl=null;try{parsedUrl=new URL(url);}catch(e){}\n if(!parsedUrl||parsedUrl.protocol!=='http:'||(parsedUrl.hostname!=='127.0.0.1'&&parsedUrl.hostname!=='localhost')||!parsedUrl.port){motorStatus('Motor server must be robot-local: http://127.0.0.1:PORT','bad');return Promise.reject(new Error('motor server must be localhost'));}\n motorStatus('Validating and saving motor profile...');\n return rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(robotId),{motor_server_url:url}).then(function(){return rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile',{registry:motorProfile.registry,sequences:motorProfile.sequences,greeting_sequence_id:motorProfile.greeting_sequence_id});}).then(function(data){motorProfile.registry=data.registry||[];motorProfile.sequences=data.sequences||[];motorProfile.greeting_sequence_id=data.greeting_sequence_id||null;renderMotorEditor();motorStatus('Profile saved. Greeting motion will start on first TTS playback.','ok');return data;}).catch(function(error){motorStatus('Save failed: '+error.message,'bad');throw error;});\n }\n function runMotorSequence(n){\n var robotId=schedId(n),sequence=motorEditorSequence();motorStatus('Saving and queueing '+sequence.name+'...');\n saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-sequences/'+encodeURIComponent(sequence.id)+'/run',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-sequence');}).then(function(result){motorStatus(result.ok?'Sequence completed on robot.':'Sequence failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Run failed: '+error.message,'bad');});\n }\n function testMotorRelay(n,index){\n captureMotorEditor();var motor=motorProfile.registry[index];if(!motor)return;var robotId=schedId(n);motorStatus('Saving profile before safe 300 ms '+motor.name+' test...');\n saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/'+encodeURIComponent(motor.id)+'/test?duration_ms=300',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-test');}).then(function(result){motorStatus(result.ok?motor.name+' relay test completed.':'Relay test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Relay test failed: '+error.message,'bad');});\n }\n function discoverMotorRelays(n,automatic){\n var robotId=schedId(n);motorStatus((automatic?'Automatically discovering':'Discovering')+' relays from the robot motor server...');\n return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/discover',{}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-discover');}).then(function(result){\n if(!result.ok)throw new Error(result.error||'robot motor discovery failed');\n if(!result.registry||!result.registry.length)throw new Error('Robot motor server has no registered relays. Register the wired BCM pins once; unknown pins will not be pulsed.');\n motorProfile.registry=result.registry;renderMotorEditor();motorStatus('Discovered '+result.registry.length+' robot-local relay(s); saving centrally...','ok');return saveMotorProfile(n);\n });\n }\n function testAllMotorRelays(n){\n captureMotorEditor();var ready=motorProfile.registry.length?Promise.resolve():discoverMotorRelays(n,true);\n ready.then(function(){if(!window.confirm('Pulse all '+motorProfile.registry.length+' registered relays one at a time? Ensure the motor area is clear.'))return null;var robotId=schedId(n);motorStatus('Saving profile and testing '+motorProfile.registry.length+' registered GPIO relay(s), one at a time...');return saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/test-all?duration_ms=300&pause_ms=200',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','gpio-test-all');}).then(function(result){motorStatus(result.ok?'Full registered GPIO test completed: '+(result.completed_steps||[]).length+' relay(s).':'GPIO test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');});}).catch(function(error){motorStatus('GPIO test failed: '+error.message,'bad');});\n }\n\n var pipelineHistoryPage=1;\n var PIPELINE_LABELS={\n robot_online:'Robot online',camera_ready:'Camera detected',microphone_ready:'Microphone detected',speaker_ready:'Speaker detected',\n motion_detected:'Motion received',scheduler_session:'Scheduler session',livekit_join:'LiveKit joined',camera_published:'Camera published',\n microphone_published:'Microphone published',voice_worker:'Voice worker',stt_listening:'STT listening',llm_response:'LLM response',\n tts_subscribed:'TTS subscribed',playback_started:'Robot playback',motor_sequence:'Greeting motion',session_ended:'Session ended'\n };\n var PIPELINE_PHASES={robot_online:'hardware',camera_ready:'hardware',microphone_ready:'hardware',speaker_ready:'hardware',motion_detected:'trigger',scheduler_session:'session',livekit_join:'session',camera_published:'media',microphone_published:'media',voice_worker:'intelligence',stt_listening:'intelligence',llm_response:'intelligence',tts_subscribed:'playback',playback_started:'playback',motor_sequence:'playback',session_ended:'close'};\n function pipelineCurrentStage(stages,latest,running){\n for(var i=0;i<stages.length;i++){var bad=latest[stages[i]];if(bad&&(bad.status==='failed'||bad.status==='blocked'))return stages[i];}\n if(!running)return null;\n var lastOk=-1;\n for(var j=0;j<stages.length;j++){var ev=latest[stages[j]];if(ev&&(ev.status==='ok'||ev.status==='skipped'))lastOk=j;if(ev&&ev.status==='running')return stages[j];}\n return stages[Math.min(stages.length-1,lastOk+1)];\n }\n function renderPipeline(data){\n var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'),focus=$('pk-live-stage'); if(!host||!summary) return;\n var latest={}, prereqs=data.prerequisites||{}, session=data.latest_session||{}, sid=session.id||null;\n (data.events||[]).forEach(function(ev){\n if(ev.session_id && sid && ev.session_id!==sid) return;\n latest[ev.stage]=ev;\n });\n ['robot_online','camera_ready','microphone_ready','speaker_ready'].forEach(function(stage){\n if(!latest[stage]) latest[stage]={stage:stage,status:prereqs[stage]?'ok':'blocked',message:prereqs[stage]?'Ready':'Not reported'};\n });\n var running=data.overall==='running',stages=data.stages||Object.keys(PIPELINE_LABELS),authoritative=data.current_stage||{},current=authoritative.stage||pipelineCurrentStage(stages,latest,running);\n host.innerHTML=stages.map(function(stage,index){\n var ev=latest[stage],isCurrent=stage===current,status=(isCurrent&&authoritative.status)||ev&&ev.status||(isCurrent?'running':'pending');\n var detail=isCurrent&&(authoritative.message||authoritative.label)||ev&&(ev.message||ev.timestamp)||'waiting';\n var time=ev&&ev.timestamp?new Date(ev.timestamp+'Z').toLocaleString():'';\n var phase=PIPELINE_PHASES[stage]||'session';\n return '<div class=\"pk-pipe-step phase-'+phase+' '+esc(status)+(isCurrent?' current':'')+'\"'+(isCurrent?' aria-current=\"step\"':'')+'><span class=\"step-index\">'+String(index+1).padStart(2,'0')+'</span><span><span class=\"stage\">'+esc(PIPELINE_LABELS[stage]||stage)+'<em class=\"step-state\">'+esc(isCurrent?'ACTIVE':status)+'</em></span><span class=\"phase\">'+esc(phase)+'</span></span><span class=\"detail\">'+esc(detail)+(time?'<span class=\"time\">'+esc(time)+'</span>':'')+'</span></div>';\n }).join('');\n var blockers=data.blockers||[];\n if(focus){var focusStatus=authoritative.status||blockers.length?'blocked':running?'active':'waiting';if(authoritative.status)focusStatus=authoritative.status;focus.className='pk-live-stage-focus '+esc(focusStatus);focus.innerHTML='<span class=\"eyebrow\">Current production stage</span><b>'+esc(authoritative.label||PIPELINE_LABELS[current]||current||'Standing by')+'</b><span>'+esc(authoritative.message||blockers[0]||'Robot is armed and waiting for motion')+'</span>';}\n summary.className='pk-pipeline-summary '+esc(data.overall||'blocked');\n summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?('ACTIVE: '+(PIPELINE_LABELS[current]||current||'starting')+' | session '+(sid||'allocating')):'READY: camera-to-speaker remote test can start');\n var micHealth=$('pk-mic-health');if(micHealth){\n var micEvent=latest.microphone_published,trackOk=micEvent&&micEvent.status==='ok',inventoryOk=!!prereqs.microphone_ready;\n micHealth.innerHTML='<div class=\"pk-mic-check ok\"><div class=\"k\">Hardware lock</div><div class=\"v\">USB mic hw:3,0</div></div><div class=\"pk-mic-check '+(inventoryOk?'ok':'bad')+'\"><div class=\"k\">Inventory</div><div class=\"v\">'+(inventoryOk?'detected':'missing')+'</div></div><div class=\"pk-mic-check '+(trackOk?'ok':micEvent?'bad':'')+'\"><div class=\"k\">Production track</div><div class=\"v\">'+esc(micEvent?(micEvent.message||micEvent.status):'waiting for session')+'</div></div>';\n }\n }\n function loadPipelineHistory(n){\n var host=$('pk-pipeline-history'),pageLabel=$('pk-history-page');if(!host)return;\n host.innerHTML='<div class=\"pk-note\">Loading session page...</div>';\n getJson('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-history?page='+pipelineHistoryPage+'&page_size=6'+(QS?'&'+QS.slice(1):''))\n .then(function(data){\n pipelineHistoryPage=data.page||1;if(pageLabel)pageLabel.textContent='page '+pipelineHistoryPage+' / '+(data.pages||1)+' | '+(data.total||0)+' runs';\n var prev=$('pk-history-prev'),next=$('pk-history-next');if(prev)prev.disabled=pipelineHistoryPage<=1;if(next)next.disabled=pipelineHistoryPage>=(data.pages||1);\n host.innerHTML=(data.items||[]).map(function(run){\n var events=run.events||[],latest={};events.forEach(function(ev){latest[ev.stage]=ev;});\n var hasError=Number(run.error_count||0)>0,state=!run.ended_at?'running':hasError?'bad':'ok',label=!run.ended_at?'active':hasError?'attention':(run.end_reason||'complete');\n var started=run.started_at?new Date(run.started_at+'Z'):null,duration=run.duration_seconds!=null?run.duration_seconds:(started?Math.max(0,Math.floor((Date.now()-started.getTime())/1000)):0);\n var stageBar=Object.keys(PIPELINE_LABELS).map(function(stage){var ev=latest[stage];return '<i class=\"'+esc(ev&&ev.status||'pending')+'\" title=\"'+esc(PIPELINE_LABELS[stage])+'\"></i>';}).join('');\n return '<div class=\"pk-run-card\" data-session=\"'+esc(run.session_id)+'\"><div class=\"pk-run-top\"><div><div class=\"pk-run-title\">'+esc(run.session_id)+'</div><div class=\"pk-run-meta\">'+esc(started?started.toLocaleString():'unknown time')+' | '+esc(rpFmtDuration(duration))+' | '+esc(run.transcript_count||0)+' transcript turns | '+esc(run.pipeline_event_count||0)+' events</div></div><span class=\"pk-run-state '+state+'\">'+esc(label)+'</span></div><div class=\"pk-run-stages\">'+stageBar+'</div></div>';\n }).join('')||'<div class=\"pk-note\">No production sessions recorded for this robot.</div>';\n }).catch(function(e){host.innerHTML='<div class=\"pk-note\" style=\"color:var(--bad)\">History unavailable: '+esc(e.message)+'</div>';});\n }\n function pollPipeline(n){\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-status'+QS,{cache:'no-store'})\n .then(function(r){ if(!r.ok) throw new Error('scheduler '+r.status); return r.json(); })\n .then(renderPipeline)\n .catch(function(e){ var s=$('pk-pipeline-summary'); if(s){ s.className='pk-pipeline-summary blocked'; s.textContent='SCHEDULER UNREACHABLE: '+e.message; } });\n }\n function queuePipelineTest(n){\n var start=$('pk-pipeline-start'); if(start){ start.disabled=true; start.textContent='Starting...'; }\n if(!previewOn) startPreview(n);\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/start',{}).then(function(result){\n if(!result.queued) throw new Error(result.reason||'trigger was not queued');\n showOut('full production test queued for '+(n.label||n.name));\n pollPipeline(n);\n // The stream endpoint is initially idle and becomes active only after\n // the robot consumes the trigger. Retry through that transition.\n [400,1200,2500,4500].forEach(function(delay){setTimeout(function(){if(drawerId)startMicMonitor(n);},delay);});\n }).catch(function(e){ showOut('production test: '+e.message); pollPipeline(n); }).finally(function(){ if(start){ start.disabled=false; start.textContent='Start full test'; } });\n }\n function startPipelineTest(n){\n var device=schedulerDevice(n)||{};\n if(!device.id){ showOut('Robot has no scheduler device identity'); return; }\n if(!device.production_mode){\n rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(device.id),{production_mode:true}).then(function(){\n device.production_mode=true; var pm=$('pk-prodmode'); if(pm) pm.checked=true; queuePipelineTest(n);\n }).catch(function(e){ showOut('Could not enable production mode: '+e.message); });\n return;\n }\n queuePipelineTest(n);\n }\n function stopPipelineTest(n){\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/stop',{}).then(function(){\n showOut('conversation stopped for '+(n.label||n.name)); pollPipeline(n); startCam(n);\n }).catch(function(e){ showOut('stop test: '+e.message); });\n }\n function runUnattendedReadiness(n){\n var button=$('pk-unattended-test'),resultEl=$('pk-unattended-result'),robotId=schedId(n),device=schedulerDevice(n)||{};\n if(!device.id){showOut('Robot has no scheduler device identity');return;}\n if(button){button.disabled=true;button.textContent='Testing hardware...';}\n function report(text,bad){if(resultEl){resultEl.textContent=text;resultEl.style.color=bad?'var(--bad)':'var(--warn)';}}\n function delay(ms){return new Promise(function(resolve){setTimeout(resolve,ms);});}\n function status(){return getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/pipeline-status'+QS);}\n function waitForGreeting(deadline){\n return status().then(function(data){\n renderPipeline(data);\n var sid=data.latest_session&&data.latest_session.id,seen={};\n (data.events||[]).forEach(function(ev){if(!ev.session_id||!sid||ev.session_id===sid)seen[ev.stage]=ev;});\n var required=['scheduler_session','livekit_join','camera_published','microphone_published','tts_subscribed','playback_started'];\n var missing=required.filter(function(stage){return !seen[stage]||seen[stage].status!=='ok';});\n if(!missing.length)return {data:data,required:required};\n if(Date.now()>deadline)throw new Error('production greeting timed out; missing '+missing.join(', '));\n report('Pipeline running \u00B7 waiting for '+missing.join(', '));\n return delay(1500).then(function(){return waitForGreeting(deadline);});\n });\n }\n var inv=device.device_inventory||{},media=inv.media_health||{};\n var cameraProblems=[];\n if(media.camera_access===false)cameraProblems.push('camera permission denied');\n if(media.camera_stalled===true)cameraProblems.push('camera worker stalled');\n if(media.camera_frame_age_seconds!=null&&Number(media.camera_frame_age_seconds)>15)cameraProblems.push('camera frame '+Math.round(Number(media.camera_frame_age_seconds))+'s old');\n if(cameraProblems.length){report('FAILED \u00B7 '+cameraProblems.join(' \u00B7 '),true);if(button){button.disabled=false;button.textContent='Run unattended readiness';}return;}\n report('Camera healthy \u00B7 running physical speaker/mic round-trip');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{\n mode:'tone',playback_only:false,frequency:1000,duration:0.8,amplitude:0.6,threshold_db:-30,\n output:'1',input:'2',output_name:'USB Audio Device: -',input_name:'Usb Audio Device: USB Audio'\n }).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','speaker-test');})\n .then(function(acoustic){\n if(!acoustic||!acoustic.ok||acoustic.pass!==true)throw new Error((acoustic&&acoustic.error)||(acoustic&&acoustic.diagnostic)||'speaker/mic acoustic round-trip failed');\n report('Acoustic path passed \u00B7 starting production motion and greeting');\n if(button)button.textContent='Testing production loop...';\n var enable=device.production_mode?Promise.resolve():rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(device.id),{production_mode:true}).then(function(){device.production_mode=true;});\n return enable.then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/pipeline-test/start',{});});\n })\n .then(function(queued){if(!queued||!queued.queued)throw new Error(queued&&queued.reason||'production trigger was not queued');return waitForGreeting(Date.now()+45000);})\n .then(function(done){\n report('PASSED \u00B7 camera fresh \u00B7 acoustic speaker/mic path confirmed \u00B7 motion \u2192 scheduler \u2192 LiveKit \u2192 mic/camera \u2192 TTS \u2192 robot playback confirmed');\n if(resultEl)resultEl.style.color='var(--ok)';\n showOut('unattended production readiness passed for '+(n.label||n.name));\n })\n .catch(function(e){report('FAILED \u00B7 '+e.message,true);showOut('unattended readiness: '+e.message);})\n .then(function(){if(button){button.disabled=false;button.textContent='Run unattended readiness';}});\n }\n function refreshDrawer(){\n var n=drawerId?byId[drawerId]:null; if(!n) return;\n if(n.kind==='robot' && !deviceSelectorsReady && $('pk-video-device')){\n var devCheck=schedulerDevice(n);\n if(devCheck && hasRealInventory(devCheck.device_inventory)) refreshDeviceSelectors(n);\n }\n if(n.kind==='robot' && $('pk-cam')){\n var camDev=schedulerDevice(n);\n // Self-heal: a production-mode robot's feed should never sit idle for\n // long \u2014 if it's not currently live (no session, no preview running),\n // re-arm it. Also keep retrying the opencv vision overlay in case the\n // robot's lan_ip only became known after this panel was first drawn.\n if(camDev && camDev.production_mode && !previewOn && !lkRoom) startCam(n);\n if(!visionOn && lkRoom) tryVisionOverlay(n);\n }\n if(n.kind==='robot' && $('pk-svc-list')) refreshSupervisorPanel(n);\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 var ready=$('pk-readiness'); if(ready && row){ ready.textContent=(row.readiness_code||'unknown').replace(/_/g,' ')+' / '+(row.readiness_message||'no readiness detail'); ready.style.color=(row.readiness_code==='ready'||row.readiness_code==='in_session')?'var(--ok)':'var(--bad)'; }\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||row.current_session_id||row.status==='connecting'||row.status==='running';\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 var cameraOpsNode=null,cameraOpsTimer=null,cameraOpsRetryTimer=null;\n function cameraOpsFeedUrl(n){\n var ref=n&&(n.displayName||n.name||n.nodeId||n.id); if(!ref)return '';\n return '/fed/media/robots/'+encodeURIComponent(ref)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();\n }\n function refreshCameraOperations(){\n var n=cameraOpsNode;if(!n)return;var row=schedRow(n)||{},device=schedulerDevice(n)||{},hw=hwByNode[n.nodeId]||{};\n var readiness=String(row.readiness_code||(!n.connected?'offline':'mesh_online')),active=!!(row.active_session||row.in_session||row.session_active||row.current_session_id||row.status==='running'||row.status==='connecting');\n var currentStage=row.current_stage||{},currentStageLabel=currentStage.label||row.current_stage_label||(active?'Conversation starting':device.production_mode?'Armed - waiting for motion':'Production paused'),currentStageStatus=currentStage.status||row.current_stage_status||(active?'active':'waiting');\n var img=$('pk-camera-ops-img'),cameraLive=!!(img&&img.dataset.live==='true'),clockEl=$('pk-camera-ops-clock');if(clockEl)clockEl.textContent=clock(Date.now());\n var rec=$('pk-camera-ops-rec');if(rec)rec.textContent=(cameraLive?'REC / LIVE':'REC / CONNECTING')+' / '+String(n.displayName||n.name||'robot').toUpperCase();\n var overlay=$('pk-camera-ops-overlay');if(overlay)overlay.innerHTML='<div class=\"metric\"><b>'+esc(currentStageLabel)+'</b><span>active stage</span></div><div class=\"metric\"><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>readiness</span></div><div class=\"metric\"><b>'+(row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'--')+'</b><span>average latency</span></div><div class=\"metric\"><b>'+(row.drop_rate!=null?Math.round(row.drop_rate*100)+'%':'--')+'</b><span>session drops</span></div>';\n var insights=$('pk-camera-ops-insights');if(insights)insights.innerHTML='<span>Camera relay</span><b class=\"'+(cameraLive?'ok':'bad')+'\">'+(cameraLive?'LIVE':'CONNECTING')+'</b><span>Mesh node</span><b class=\"'+(n.connected?'ok':'bad')+'\">'+(n.connected?'connected':'offline')+'</b><span>Production mode</span><b class=\"'+(device.production_mode?'ok':'')+'\">'+(device.production_mode?'armed':'disabled')+'</b><span>Readiness</span><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>Heartbeat</span><b>'+(row.heartbeat_age_seconds!=null?Math.round(row.heartbeat_age_seconds)+'s ago':'--')+'</b><span>Current room</span><b>'+esc(String(row.current_session_id||row.active_session_id||'none').slice(0,24))+'</b><span>Triggers</span><b>'+esc(row.trigger_count!=null?row.trigger_count:0)+'</b><span>Sessions</span><b>'+esc(row.sessions_total!=null?row.sessions_total:0)+'</b>';\n var stages=[[currentStageLabel,true,'ACTIVE'],['Robot online',!!n.connected,'ready'],['Camera streaming',cameraLive,'ready'],['Motion detector armed',!!device.production_mode,'armed'],['Scheduler session',active,'active']],pipe=$('pk-camera-ops-pipeline');if(pipe)pipe.innerHTML=stages.map(function(s,index){return '<div class=\"'+(index===0?'fleet-stage-focus '+esc(currentStageStatus):'')+'\"><i class=\"'+(s[1]?'ok':'warn')+'\"></i><span>'+esc(s[0])+'</span><b>'+(s[1]?s[2]:'waiting')+'</b></div>';}).join('');\n var inv=device.device_inventory||{},mh=inv.media_health||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Camera permission</span><b class=\"'+(mh.camera_access===false?'bad':'ok')+'\">'+(mh.camera_access===false?'DENIED':mh.camera_access===true?'granted':'--')+'</b><span>Audio permission</span><b class=\"'+(mh.audio_access===false?'bad':'ok')+'\">'+(mh.audio_access===false?'DENIED':mh.audio_access===true?'granted':'--')+'</b><span>Camera worker</span><b class=\"'+(mh.camera_stalled?'bad':'ok')+'\">'+(mh.camera_stalled?'STALLED':mh.camera_worker?'streaming':'--')+'</b><span>Frame age</span><b>'+(mh.camera_frame_age_seconds!=null?Number(mh.camera_frame_age_seconds).toFixed(1)+'s':'--')+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';\n var prod=$('pk-camera-ops-production');if(prod){prod.textContent=device.production_mode?'Disable production':'Enable production';prod.classList.toggle('primary',!!device.production_mode);}\n }\n function reloadCameraOperations(){\n if(!cameraOpsNode)return;var img=$('pk-camera-ops-img'),empty=$('pk-camera-ops-empty');if(!img)return;img.dataset.live='false';if(empty){empty.style.display='block';empty.textContent='opening RoboVision camera...';}\n img.onload=function(){img.dataset.live='true';if(cameraOpsRetryTimer){clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=null;}if(empty)empty.style.display='none';refreshCameraOperations();};img.onerror=function(){var failedNode=cameraOpsNode;img.dataset.live='false';if(empty){empty.style.display='block';empty.textContent='camera relay unavailable / retrying';}refreshCameraOperations();if(cameraOpsRetryTimer)clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=setTimeout(function(){if(cameraOpsNode&&cameraOpsNode===failedNode)reloadCameraOperations();},3000);};img.src=cameraOpsFeedUrl(cameraOpsNode);refreshCameraOperations();\n }\n function openCameraOperations(n){\n cameraOpsNode=n;var host=$('pk-camera-ops');if(!host)return;host.classList.add('open');document.body.classList.add('camera-ops-open');var name=n.displayName||n.name||'robot',avatar=rpAvatarFor(name),av=$('pk-camera-ops-avatar');$('pk-camera-ops-name').textContent=name+' / live operations';$('pk-camera-ops-sub').textContent='RoboVision mesh relay / '+(n.connected?'connected':'offline');if(av){if(avatar){av.src=avatar+QS;av.style.display='block';}else{av.removeAttribute('src');av.style.display='none';}}reloadCameraOperations();if(cameraOpsTimer)clearInterval(cameraOpsTimer);cameraOpsTimer=setInterval(refreshCameraOperations,1000);\n }\n function closeCameraOperations(){\n var host=$('pk-camera-ops'),img=$('pk-camera-ops-img');if(host)host.classList.remove('open');document.body.classList.remove('camera-ops-open');if(cameraOpsRetryTimer){clearTimeout(cameraOpsRetryTimer);cameraOpsRetryTimer=null;}if(img){img.src='';img.dataset.live='false';}if(cameraOpsTimer){clearInterval(cameraOpsTimer);cameraOpsTimer=null;}cameraOpsNode=null;if(document.fullscreenElement&&document.exitFullscreen)document.exitFullscreen().catch(function(){});\n }\n (function bindCameraOperations(){\n var close=$('pk-camera-ops-close'),refresh=$('pk-camera-ops-refresh'),nativeBtn=$('pk-camera-ops-native'),trigger=$('pk-camera-ops-trigger'),prod=$('pk-camera-ops-production'),end=$('pk-camera-ops-end');\n if(close)close.addEventListener('click',closeCameraOperations);if(refresh)refresh.addEventListener('click',reloadCameraOperations);if(nativeBtn)nativeBtn.addEventListener('click',function(){var host=$('pk-camera-ops');if(host&&host.requestFullscreen)host.requestFullscreen().catch(function(e){showOut('fullscreen: '+e.message);});});if(trigger)trigger.addEventListener('click',function(){if(cameraOpsNode)triggerSession(schedId(cameraOpsNode));});if(prod)prod.addEventListener('click',function(){if(cameraOpsNode){var d=schedulerDevice(cameraOpsNode)||{};setProductionMode(cameraOpsNode,!d.production_mode);setTimeout(refreshCameraOperations,400);}});if(end)end.addEventListener('click',function(){if(cameraOpsNode)endSession(schedId(cameraOpsNode));});document.addEventListener('keydown',function(e){if(e.key==='Escape'&&cameraOpsNode)closeCameraOperations();});\n })();\n\n // \u2500\u2500 cam: target a LiveKit track; fall back to a styled preview \u2500\u2500\n function startCam(n){\n idleCam('connecting');\n tryVisionOverlay(n, true);\n startMicMonitor(n);\n return;\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){\n // No real session running. If this robot is in production mode,\n // keep its camera live via the same on-demand preview path \"Go\n // live\" uses, instead of showing an idle placeholder \u2014 production\n // mode means the feed should always be visible.\n // Production mode leaves the idle camera with the robot's motion\n // detector. Operator preview is started explicitly with Go live.\n idleCam(info.reason||'idle');\n return;\n }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); tryVisionOverlay(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 RoboVision camera relay\u2026', idle:'camera relay standing by', production_mode_off:'production mode off', 'no-sdk':'voice session active \u00B7 loading microphone\u2026', unreachable:'camera relay 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 selectedRobotMic(){var el=$('pk-audio-input');if(!el)return 'default';var option=el.options&&el.selectedIndex>=0?el.options[el.selectedIndex]:null;return option?String(option.textContent||option.value):String(el.value||'default');}\n function selectedRobotOutput(){var el=$('pk-audio-output');if(!el)return 'default';var option=el.options&&el.selectedIndex>=0?el.options[el.selectedIndex]:null;return option?String(option.textContent||option.value):String(el.value||'default');}\n function setMicMonitorAudible(enabled){\n micMonitorAudible=!!enabled;\n var btn=$('pk-mic-listen'),note=$('pk-mic-listen-note');\n if(micMonitorAudible){\n var AC=window.AudioContext||window.webkitAudioContext;\n if(AC) audioCtx=audioCtx||new AC();\n if(audioCtx&&audioCtx.state==='suspended') audioCtx.resume().catch(function(e){if(note)note.textContent='Browser audio resume failed: '+e.message;});\n }\n if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;\n if(btn){btn.textContent=micMonitorAudible?'Mute browser monitor':'Listen in browser';btn.classList.toggle('primary',micMonitorAudible);}\n if(note)note.textContent=micMonitorAudible?(lkAudioEls.length?'Robot mic is playing in this browser. Use headphones.':'Listening armed \u2014 waiting for the robot mic track.'):'Browser monitoring muted.';\n lkAudioEls.forEach(function(el){el.muted=true;});\n }\n function startMicMonitor(n){\n if(lkRoom||micMonitorConnecting)return;\n micMonitorConnecting=true;\n micMonitorState='checking active conversation session';\n var state=$('pk-micstate');if(state)state.textContent='selected '+selectedRobotMic()+' \u00B7 checking active session...';\n fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS,{cache:'no-store'})\n .then(function(r){if(!r.ok)throw new Error('scheduler '+r.status);return r.json();})\n .then(function(info){\n micMonitorConnecting=false;\n if(info&&info.active&&info.url&&info.token&&window.LivekitClient){micMonitorState='connecting to '+info.url;attachLiveKit(info,n,true);return;}\n micMonitorState=info&&info.reason?String(info.reason).replace(/_/g,' '):'waiting for conversation session';\n if(state)state.textContent='selected '+selectedRobotMic()+' \u00B7 waiting for conversation session';\n })\n .catch(function(e){micMonitorConnecting=false;micMonitorState='monitor unavailable: '+e.message;if(state)state.textContent='selected '+selectedRobotMic()+' \u00B7 '+micMonitorState;});\n }\n function attachLiveKit(info, n, audioOnly){\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,publication,participant){\n if(track.kind==='video'){\n if(audioOnly)return;\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'){\n // Track names are backend-dependent (\"mic\", device labels, or\n // empty). Identify the robot by participant instead; reject the\n // voice worker/viewer so this remains the physical robot mic.\n var identity=String(participant&&participant.identity||'');\n var expected=String(schedId(n)||'').toLowerCase();\n var nodeName=String(n.name||'').toLowerCase();\n var lowerIdentity=identity.toLowerCase();\n var isWorker=/(agent|operator|viewer)/.test(lowerIdentity);\n var isRobot=!isWorker&&(lowerIdentity===expected||lowerIdentity===nodeName||lowerIdentity.indexOf(expected)>-1||lowerIdentity.indexOf(nodeName)>-1);\n if(!isRobot){if(window.console)console.debug('RoboPark ignored non-robot audio track',identity,track.name,publication&&publication.source);return;}\n hookMicAnalyser(track);\n micMonitorState='live robot mic from '+identity;\n var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 live track published';\n // Muted by default: this is the camera-preview panel, running on\n // the SAME physical device as the robot in local testing. Auto-\n // playing the subscribed track (robot mic, or later the agent's\n // TTS) through the browser's speakers next to the robot's own\n // mic closes an acoustic feedback loop \u2014 heard as a nonstop echo\n // that drowns out the actual conversation. The VU meter above\n // (hookMicAnalyser) taps the raw track directly and works fine\n // muted.\n var audio=track.attach(); audio.autoplay=false; audio.playsInline=true;audio.setAttribute('playsinline','');audio.controls=false; audio.muted=true; audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);\n var listenNote=$('pk-mic-listen-note');if(listenNote&&micMonitorAudible)listenNote.textContent='Robot mic is playing in this browser. Use headphones.';\n }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){lkRoom=null;micAnalyser=null;if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}micMonitorSource=null;micMonitorGain=null;micMonitorConnecting=false;micMonitorState='session ended';var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 session ended';if(!audioOnly&&note)note.textContent='session ended';if(!audioOnly&&res)res.textContent='\u2014';});\n var livekitUrl=info.url;\n if((location.hostname==='localhost'||location.hostname==='127.0.0.1') && /^ws:\\/\\/localhost(?::|\\/)/i.test(livekitUrl)) livekitUrl=livekitUrl.replace(/^ws:\\/\\/localhost/i,'ws://127.0.0.1');\n lkRoom.connect(livekitUrl, info.token).then(function(){micMonitorState='connected, waiting for robot mic publication';var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 '+micMonitorState;if(!audioOnly&&note)note.textContent='connected \u00B7 waiting for camera track\u2026';})\n .catch(function(e){lkRoom=null;micMonitorConnecting=false;micMonitorState='LiveKit connection failed: '+(e&&e.message?e.message:'unreachable');var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' \u00B7 '+micMonitorState;if(!audioOnly){idleCam('unreachable');if(note)note.textContent='LiveKit connection failed';}if(window.console)console.error('RoboPark LiveKit connection failed',e);});\n }catch(e){ micMonitorConnecting=false;micMonitorState='LiveKit SDK unavailable';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(); if(audioCtx.state==='suspended') audioCtx.resume().catch(function(){});\n var ms=track.mediaStreamTrack; if(!ms) return;\n if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n var gain=audioCtx.createGain();gain.gain.value=micMonitorAudible?micMonitorVolume:0;src.connect(gain);gain.connect(audioCtx.destination);micMonitorSource=src;micMonitorGain=gain;\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){\n micAnalyser.getByteTimeDomainData(buf);\n var sum=0;\n for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; }\n var rms=Math.sqrt(sum/buf.length);\n // USB microphones commonly sit around -55 dBFS at room level and\n // -20 dBFS when spoken into. Map that useful range to 0..100 rather\n // than displaying normalized RMS directly (which looked stuck at 1%).\n var db=rms>0 ? 20*Math.log10(rms) : -90;\n lvl=Math.max(0,Math.min(1,(db+60)/45));\n }\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?('selected '+selectedRobotMic()+' \u00B7 '+(lvl>0.12?'live, hearing audio':'live, quiet')):('selected '+selectedRobotMic()+' \u00B7 '+micMonitorState));\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; we don't get a\n // published track, so the meter stays at \"\u2014\" unless a speaker\n // round-trip test is currently running. While a test plays, the\n // bars animate to show the live mic picking up the tone.\n var liveRms = window.__pkSpkLiveRms || 0;\n var pass_ = window.__pkSpkLivePass;\n for(var j=0;j<si.length;j++){\n var on = j<liveRms*si.length;\n si[j].style.height=(3+(on?liveRms*14:0))+'px';\n si[j].style.background = on\n ? (pass_===true ? 'var(--ok)'\n : pass_===false ? 'var(--bad)'\n : (j<si.length*0.7?'var(--ok)':j<si.length*0.9?'var(--warn)':'var(--bad)'))\n : 'var(--glass-border)';\n }\n set('pk-spkval', liveRms>0 ? Math.round(liveRms*100)+'%' : '\u2014');\n if(swx){\n swx.clearRect(0,0,sw.width,sw.height);\n // baseline\n swx.strokeStyle='rgba(255,255,255,0.10)';\n swx.lineWidth=1; swx.beginPath(); swx.moveTo(0,sw.height/2); swx.lineTo(sw.width,sw.height/2); swx.stroke();\n // recorded waveform (when we have one)\n var wav = window.__pkSpkWaveform;\n if(wav && wav.length){\n swx.strokeStyle = pass_===true ? 'rgba(110,224,166,0.95)'\n : pass_===false ? 'rgba(220,80,80,0.95)'\n : 'rgba(255,214,112,0.85)';\n swx.lineWidth=1.4; swx.beginPath();\n for(var x=0;x<sw.width;x++){\n var idx = Math.floor(x/wav.length * wav.length);\n var v = wav[idx] || 0;\n var yy = sw.height/2 - v * (sw.height*0.45);\n if(x===0) swx.moveTo(x,yy); else swx.lineTo(x,yy);\n }\n swx.stroke();\n } else {\n swx.strokeStyle='rgba(255,255,255,0.18)'; swx.lineWidth=1.4; swx.beginPath();\n 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); }\n swx.stroke();\n }\n }\n meterRAF=requestAnimationFrame(loop);\n })();\n }\n\n // \u2500\u2500 C2: speaker roundtrip test \u2500\u2500\n // Queues a \"play tone + record mic\" test on the robot's supervisor,\n // polls the result, and renders a PASS/FAIL banner + a tiny\n // history of the last 5 tests. The meter above mirrors the\n // recorded RMS while the test is running.\n var _spkTestHist = {}; // robotId -> [{t, pass, peak_db, ...}]\n\n function _spkTestSetLive(robotId, data){\n if(data && data.live){\n window.__pkSpkLiveRms = data.rms || 0;\n window.__pkSpkLivePass = data.pass;\n window.__pkSpkWaveform = data.waveform || null;\n } else {\n window.__pkSpkLiveRms = 0;\n window.__pkSpkLivePass = undefined;\n window.__pkSpkWaveform = null;\n }\n }\n function _spkTestRenderHistory(robotId){\n var host = $('pk-spk-test-history'); if(!host) return;\n var list = _spkTestHist[robotId] || [];\n if(!list.length){\n host.innerHTML = '<div class=\"pk-note\" style=\"margin-top:0.3rem\">no tests yet \u2014 press \u201CTest speaker\u201D</div>';\n return;\n }\n var rows = list.map(function(t){\n var cls = t.pass===true ? 'var(--ok)' : (t.pass===false ? 'var(--bad)' : 'var(--muted)');\n var mark = t.pass===true ? '\u2713' : (t.pass===false ? '\u2717' : '\u00B7');\n var when = new Date(t.t).toTimeString().slice(0,8);\n var peak = t.peak_db != null ? (t.peak_db+' dB') : '\u2014';\n var freq = t.mode==='tts' ? 'cached voice' : (t.frequency != null ? (Math.round(t.frequency)+' Hz') : '\u2014');\n return '<div class=\"pk-spk-hist-row\"><span class=\"pk-spk-mark\" style=\"color:'+cls+'\">'+mark+'</span>'\n + '<span class=\"pk-spk-when\">'+when+'</span>'\n + '<span class=\"pk-spk-freq\">'+freq+'</span>'\n + '<span class=\"pk-spk-peak\">peak '+peak+'</span>'\n + (t.diagnostic ? '<span class=\"pk-spk-diag\">'+esc(t.diagnostic)+'</span>' : '')\n + '</div>';\n }).join('');\n host.innerHTML = '<div class=\"pk-spk-hist-head\">last '+list.length+' tests</div>' + rows;\n }\n function _spkTestRenderResult(robotId, result){\n var host = $('pk-spk-test-result'); if(!host) return;\n if(!result){\n host.innerHTML = '';\n return;\n }\n if(!result.ok){\n host.innerHTML = '<div class=\"pk-spk-result bad\">'\n + '<div class=\"pk-spk-banner\">\u2717 round-trip test failed</div>'\n + '<div class=\"pk-spk-err\">'+esc(result.error||'(no response)')+'</div>'\n + '</div>';\n return;\n }\n var isTts=result.mode==='tts';\n var banner = result.pass\n ? '<div class=\"pk-spk-banner ok\">\u2713 '+(isTts?'cached voice playback OK':'speaker round-trip OK')+'</div>'\n : '<div class=\"pk-spk-banner bad\">\u2717 no signal detected</div>';\n var meta = '<div class=\"pk-spk-meta\">'\n + (result.playback_only?'':'<div><span class=\"l\">peak</span><span class=\"v\">'+esc(result.recorded_peak_db+' dB')+'</span></div><div><span class=\"l\">RMS</span><span class=\"v\">'+esc(result.recorded_rms_db+' dB')+'</span></div><div><span class=\"l\">threshold</span><span class=\"v\">'+esc(result.threshold_db+' dB')+'</span></div>')\n + (isTts?'<div><span class=\"l\">voice</span><span class=\"v\">'+esc((result.tts_provider||'tts')+' / '+(result.tts_voice||'default'))+'</span></div><div><span class=\"l\">cache</span><span class=\"v\">'+(result.cache_hit?'hit':'generated')+'</span></div>':'<div><span class=\"l\">frequency</span><span class=\"v\">'+esc(Math.round(result.frequency)+' Hz')+'</span></div>')\n + '<div><span class=\"l\">duration</span><span class=\"v\">'+esc(result.duration+' s')+'</span></div>'\n + '<div><span class=\"l\">latency</span><span class=\"v\">'+esc(result.duration_ms+' ms')+'</span></div>'\n + '<div><span class=\"l\">output</span><span class=\"v\">'+esc(result.output_device)+'</span></div>'\n + '<div><span class=\"l\">input</span><span class=\"v\">'+esc(result.input_device)+'</span></div>'\n + '<div><span class=\"l\">format</span><span class=\"v\">'+esc((result.output_sample_rate||result.sample_rate)+' Hz / '+(result.output_channels||1)+'ch out'+(result.playback_only?'':' \u00B7 '+(result.input_sample_rate||result.sample_rate)+' Hz / '+(result.input_channels||1)+'ch in'))+'</span></div>'\n + '</div>';\n var diag = result.diagnostic ? '<div class=\"pk-spk-diag\">'+esc(result.diagnostic)+'</div>' : '';\n host.innerHTML = '<div class=\"pk-spk-result '+(result.pass?'ok':'bad')+'\">'\n + banner + meta + diag + '</div>';\n }\n function _micTestRenderResult(result,label){\n var host=$('pk-mic-test-result'),state=$('pk-mic-test-state');if(!host)return;\n if(!result||!result.ok){var error=result&&result.error||'robot did not return a result';host.innerHTML='<div class=\"pk-mic-result bad\"><b>TEST FAILED</b><div style=\"margin-top:.3rem\">'+esc(error)+'</div></div>';if(state)state.textContent='Failed | '+error;return;}\n var passed=result.pass===true,mode=result.mode||label;\n host.innerHTML='<div class=\"pk-mic-result '+(passed?'ok':'bad')+'\"><b>'+(passed?'MIC PATH VERIFIED':'NO USABLE SIGNAL')+'</b><div class=\"pk-mic-readings\"><div class=\"pk-mic-reading\"><span>Peak</span><b>'+esc(result.recorded_peak_db!=null?result.recorded_peak_db+' dB':'--')+'</b></div><div class=\"pk-mic-reading\"><span>RMS</span><b>'+esc(result.recorded_rms_db!=null?result.recorded_rms_db+' dB':'--')+'</b></div><div class=\"pk-mic-reading\"><span>Nonzero</span><b>'+esc(result.nonzero_percent!=null?result.nonzero_percent+'%':'--')+'</b></div><div class=\"pk-mic-reading\"><span>Format</span><b>'+esc((result.sample_rate||result.input_sample_rate||'--')+' Hz / '+(result.channels||result.input_channels||1)+'ch')+'</b></div></div><div style=\"margin-top:.38rem;color:var(--muted)\">'+esc(result.input_device||'USB microphone')+' | '+esc(result.duration_ms||0)+' ms | '+esc(result.diagnostic||mode)+'</div></div>';\n if(state)state.textContent=passed?'Passed | '+mode+' confirmed on the robot':'Capture completed but no usable PCM signal was detected';\n }\n function setMicTestBusy(busy,text){\n [$('pk-mic-groundtruth'),$('pk-mic-roundtrip'),$('pk-mic-apply')].forEach(function(button){if(button)button.disabled=busy;});\n var state=$('pk-mic-test-state');if(state&&text)state.textContent=text;\n }\n function runMicGroundtruth(n){\n var robotId=schedId(n),duration=parseFloat((($('pk-mic-duration')||{}).value)||'4');setMicTestBusy(true,'Recording raw PCM on the robot for '+duration+' seconds...');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'mic_groundtruth',duration:duration,input:'2',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})\n .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-groundtruth');})\n .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'mic_groundtruth');setMicTestBusy(false);});\n }\n function runMicRoundtrip(n){\n var robotId=schedId(n);setMicTestBusy(true,'Playing a reference tone and recording the locked USB microphone...');\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'tone',playback_only:false,frequency:1000,duration:1,amplitude:.6,threshold_db:-30,output:'1',input:'2',output_name:'USB Audio Device: - (hw:2,0)',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})\n .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-roundtrip');})\n .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'roundtrip');setMicTestBusy(false);});\n }\n function runSpeakerTest(n, forcedMode){\n var robotId = schedId(n), mode=forcedMode||(($('pk-spk-mode')||{}).value||'tts');\n var buttons=[$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')];buttons.forEach(function(b){if(b)b.disabled=true;});\n var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = true; btn.textContent = mode==='tts'?'loading voice\u2026':'testing\u2026'; }\n var status = $('pk-spk-test-stateline');\n if(status) status.textContent = mode==='tts'?'loading cached character voice\u2026':'queued \u2014 waiting for robot\u2026';\n var freq = parseFloat(($('pk-spk-freq')||{}).value || '1000');\n var dur = parseFloat(($('pk-spk-dur')||{}).value || '0.6');\n var phrase=String((($('pk-spk-phrase')||{}).value)||'Hello, I am ready to talk.').trim();\n var output=(($('pk-audio-output')||{}).value)||'default';\n var input=(($('pk-audio-input')||{}).value)||'default';\n var outputName=selectedRobotOutput(),inputName=selectedRobotMic();\n // Animate the meter with a synthetic \"during-test\" peak so the\n // operator sees something happen immediately while waiting for\n // the real recording.\n _spkTestSetLive(robotId, {live:true, rms: 0.45, pass: undefined, waveform: null});\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',\n {mode:mode, playback_only:mode==='tts', text:phrase, frequency:freq, duration:dur, amplitude:0.6, threshold_db:-30, output:output, input:input, output_name:outputName, input_name:inputName})\n .then(function(q){\n // long-poll up to 8s, then fall back to 12s of result polling\n if(status) status.textContent = mode==='tts'?'playing cached voice on '+selectedRobotOutput()+'\u2026':'playing tone + recording\u2026';\n return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, '', 'speaker-test');\n })\n .catch(function(e){\n if(status) status.textContent = '\u2717 test failed: '+e.message;\n _spkTestSetLive(robotId, {live:false});\n _spkTestRenderResult(robotId, {ok:false, error:e.message});\n buttons.forEach(function(b){if(b)b.disabled=false;});\n if(btn){ btn.disabled = false; btn.textContent = mode==='tts'?'Play cached voice':'Run tone test'; }\n });\n }\n // Render a synthetic \"during-test\" waveform so the meter animates\n // while waiting for the round-trip result. Called by startMeters\n // to keep the bars interesting during the test.\n function _spkTestMark(robotId, result){\n // Append to history (cap at 5)\n var list = _spkTestHist[robotId] || [];\n list.unshift({\n t: Date.now(),\n pass: result && result.ok ? (result.pass===true) : null,\n peak_db: result && result.recorded_peak_db != null ? result.recorded_peak_db : null,\n frequency: result && result.frequency != null ? result.frequency : null,\n mode: result && result.mode || 'tone',\n diagnostic: result && result.diagnostic || (result && !result.ok ? result.error : null),\n });\n while(list.length > 5) list.pop();\n _spkTestHist[robotId] = list;\n _spkTestRenderHistory(robotId);\n }\n // Override the per-robot shell-result renderer for speaker_test so\n // we also update the speaker-test panel + meter. _shellWaitForResult\n // already calls _renderShellResult with the right shape.\n var _origRenderShellResult = _renderShellResult;\n _renderShellResult = function(robotId, result, label){\n _origRenderShellResult(robotId, result, label);\n if(label === 'speaker-test'){\n _spkTestSetLive(robotId, {live:false});\n _spkTestMark(robotId, result);\n _spkTestRenderResult(robotId, result);\n [$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')].forEach(function(b){if(b)b.disabled=false;});\n var status = $('pk-spk-test-stateline');\n if(status){\n if(!result || !result.ok){ status.textContent = '\u2717 test failed'; }\n else if(result.playback_only&&result.played){ status.textContent = '\u2713 speaker playback completed'; }\n else if(result.pass){ status.textContent = '\u2713 pass \u00B7 '+result.recorded_peak_db+' dB peak'; }\n else { status.textContent = '\u2717 no signal \u2014 '+result.recorded_peak_db+' dB peak'; }\n }\n var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = (($('pk-spk-mode')||{}).value==='tone'?'Run tone test':'Play cached voice'); }\n }\n if(label==='mic-groundtruth'||label==='mic-roundtrip'){\n _micTestRenderResult(result,label);\n setMicTestBusy(false);\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, updateTelemetry:updateTelemetry };\n })();\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n setInterval(loadParkSlots, 7000);\n // Pre-load character presets and voice stacks on startup so the Park\n // robot-detail drawer has populated dropdowns even if the user never visits the robopark tab.\n loadCharacterPresets();\n loadVoiceStacks();\n setInterval(function(){ loadCharacterPresets(); loadVoiceStacks(); }, 12000);\n // C3: load the operator name (or prompt for one) before the first\n // tab opens, so the very first audit-attributed action already has\n // the right name attached.\n loadOperatorName();\n maybePromptOperator();\n initTab();\n})();\n</script>\n</body>\n</html>";