infinicode 2.8.100 → 2.8.103

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.
@@ -26,7 +26,7 @@ export const DASHBOARD_HTML = `<!doctype html>
26
26
  <head>
27
27
  <meta charset="utf-8">
28
28
  <meta name="viewport" content="width=device-width, initial-scale=1">
29
- <meta name="robopark-ui-build" content="voice-media-20260716-3">
29
+ <meta name="robopark-ui-build" content="provisioning-token-20260717-1">
30
30
  <title>RoboPark Control</title>
31
31
  <style>
32
32
  /* ── premium tech glass theme ── dark is the committed default; light is a frosted toggle ── */
@@ -363,20 +363,24 @@ button.act:disabled{opacity:0.5;cursor:default;}
363
363
  .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
364
364
  .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
365
365
  .pk-pipeline{display:grid;gap:0.42rem;margin-top:0.7rem;position:relative;}
366
+ .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);}
367
+ .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)}
366
368
  .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;}
367
369
  .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}
368
370
  .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;}
369
371
  .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;}
370
- .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;}
372
+ .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)}
371
373
  .pk-pipe-step.ok{border-color:color-mix(in srgb,var(--ok) 22%,transparent)}
374
+ .pk-pipe-step.ok .step-state{color:var(--ok)}.pk-pipe-step.pending{opacity:.52}.pk-pipe-step.pending .step-index{filter:saturate(.3)}
372
375
  .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));}
373
- .pk-pipe-step.current{transform:translateX(4px);border-color:var(--phase);background:linear-gradient(105deg,color-mix(in srgb,var(--phase) 19%,transparent),rgba(255,255,255,.035));box-shadow:0 0 0 1px color-mix(in srgb,var(--phase) 18%,transparent),0 8px 28px rgba(0,0,0,.22);}
376
+ .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;}
374
377
  .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;}
375
378
  .pk-pipe-step.current .step-index{box-shadow:0 0 14px color-mix(in srgb,var(--phase) 55%,transparent);animation:pulse 1.15s infinite;}
376
379
  .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;}
377
380
  .pk-pipeline-summary.ready{color:var(--ok);border-color:rgba(70,220,150,.35);}
378
381
  .pk-pipeline-summary.running{color:var(--warn);border-color:rgba(255,196,70,.35);}
379
382
  .pk-pipeline-summary.blocked{color:var(--bad);border-color:rgba(255,90,90,.4);}
383
+ .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)}
380
384
  .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)}
381
385
  .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}
382
386
  .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)}
@@ -790,7 +794,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
790
794
  <h3><span>Live activity</span><span style="color:var(--muted)" id="pk-src">/fed/stream</span></h3>
791
795
  <div class="rows" id="pk-rows"></div>
792
796
  </aside>
793
- <div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker</div>
797
+ <div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker · <b>ctrl+drag a robot</b> to reposition (persists)</div>
794
798
  </div>
795
799
  <div class="pk-drawer" id="pk-drawer">
796
800
  <div class="pk-dh">
@@ -1126,18 +1130,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
1126
1130
  <div class="rp-form" id="rp-addrobot">
1127
1131
  <h3>Add robot (mint enrollment token)</h3>
1128
1132
  <div class="rp-quickrow">
1129
- <div class="rp-field"><label>Robot name</label><input id="rb-name" placeholder="jaguar"></div>
1130
- <div class="rp-field"><label>Character preset</label><select id="rb-char"><option value="">— auto (matched from name) —</option></select></div>
1131
- <button class="rp-btn primary" id="rb-add" style="height:fit-content">Add robot</button>
1133
+ <div class="rp-field"><label>Robot name</label><input id="rb-name" placeholder="jaguar"></div>
1134
+ <div class="rp-field"><label>Character preset</label><select id="rb-char"><option value="">— auto (matched from name) —</option></select></div>
1135
+ <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>
1136
+ <div class="rp-field"><label>Mesh token</label><input id="rb-token" spellcheck="false" autocomplete="off" placeholder="required in generated command"></div>
1137
+ <button class="rp-btn primary" id="rb-add" style="height:fit-content">Add robot</button>
1132
1138
  </div>
1133
1139
  <div class="rp-autonote" id="rb-autonote">the robot self-reports its IP and hardware on first heartbeat — you only need a name and (optionally) a character preset to get started.</div>
1134
1140
  <div class="rp-quickrow" id="rb-clicmd-row" style="display:none;">
1135
- <div class="rp-field" style="flex:1;"><label>run this ON THE ROBOT (no need to click Add — this mints its own token)</label><input id="rb-clicmd" readonly style="cursor:text;"></div>
1141
+ <div class="rp-field" style="flex:1;"><label>complete production one-liner — run this ON THE ROBOT</label><input id="rb-clicmd" readonly style="cursor:text;"></div>
1136
1142
  <button class="rp-btn" id="rb-clicmd-copy" style="height:fit-content">📋 Copy</button>
1137
1143
  </div>
1138
1144
  <details class="rp-advanced">
1139
- <summary>Advanced — pre-set network/voice config before the robot connects</summary>
1140
- <div class="rp-advanced-grid">
1145
+ <summary>Advanced — pre-set network/voice config before the robot connects</summary>
1146
+ <div class="rp-advanced-grid">
1147
+ <div class="rp-field"><label>Site</label><input id="rb-site" placeholder="Tel Aviv"></div>
1141
1148
  <div class="rp-field"><label>Tailscale IP</label><input id="rb-tsip" placeholder="auto-detected once connected"></div>
1142
1149
  <div class="rp-field"><label>LAN IP</label><input id="rb-lanip" placeholder="auto-detected once connected"></div>
1143
1150
  <div class="rp-field"><label>Motor server URL</label><input id="rb-motor" placeholder="http://...:5000"></div>
@@ -1241,6 +1248,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
1241
1248
  (function(){
1242
1249
  var TOKEN = new URLSearchParams(location.search).get('token') || '';
1243
1250
  var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
1251
+ function dashboardMeshToken(){
1252
+ if(TOKEN)return TOKEN;
1253
+ try{var saved=JSON.parse(localStorage.getItem('rp-command-env-v1')||'null');return saved&&String(saved.token||'').trim()||'';}catch(e){return '';}
1254
+ }
1244
1255
  var UI_BUILD=(document.querySelector('meta[name="robopark-ui-build"]')||{}).content||'unknown';
1245
1256
  fetch('/fed/ui-build'+QS,{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(info){
1246
1257
  if(!info||!info.build||info.build===UI_BUILD)return;
@@ -1532,6 +1543,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1532
1543
  + '<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>'
1533
1544
  + (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>'
1534
1545
  +(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>'):'');
1546
+ 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>';}
1535
1547
 
1536
1548
  if((tele||String(n.role||'').toLowerCase()==='satellite'||String(n.role||'').toLowerCase()==='robot')&&cameraRef){
1537
1549
  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>';
@@ -2248,16 +2260,20 @@ button.act:disabled{opacity:0.5;cursor:default;}
2248
2260
 
2249
2261
  // ── robust CLI one-liner generation, live as you type — no API call
2250
2262
  // needed to see it; 'robopark add-robot' mints its own token when run ──
2251
- function buildAddRobotCommand(name, characterId){
2252
- if(!name) return '';
2253
- var hubUrl=location.origin;
2254
- var schedulerUrl=(lastStatus && lastStatus.control && lastStatus.control.schedulerUrl) || '';
2255
- var argv=['robopark','add-robot',name];
2256
- if(characterId) argv.push('--character', characterId);
2257
- if(hubUrl) argv.push('--hub-url', hubUrl);
2258
- if(TOKEN) argv.push('--token', TOKEN);
2259
- if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);
2260
- argv.push('--start');
2263
+ function buildAddRobotCommand(name, characterId){
2264
+ if(!name) return '';
2265
+ var hubUrl=location.origin.replace(/\\/+$/,'');
2266
+ var schedulerUrl=hubUrl+'/robopark';
2267
+ var requestedNetwork=$('rb-network')&&$('rb-network').value||'auto';
2268
+ var network=requestedNetwork==='auto'?(/^100\\./.test(location.hostname)||/\\.ts\\.net$/i.test(location.hostname)?'tailscale':'lan'):requestedNetwork;
2269
+ var argv=['robopark','add-robot',name];
2270
+ if(characterId) argv.push('--character', characterId);
2271
+ var site=$('rb-site')&&$('rb-site').value.trim();if(site)argv.push('--site',site);
2272
+ if(hubUrl) argv.push('--hub-url', hubUrl);
2273
+ var meshToken=$('rb-token')&&$('rb-token').value.trim()||dashboardMeshToken()||'<mesh-token>';
2274
+ argv.push('--token', meshToken);
2275
+ if(schedulerUrl) argv.push('--scheduler-url', schedulerUrl);
2276
+ argv.push(network==='tailscale'?'--tailscale':'--lan','--start','--auto-start','--yes');
2261
2277
  return argv.map(function(a){ return /[^a-zA-Z0-9_./:=,-]/.test(a) ? '"'+a.replace(/"/g,'\\"')+'"' : a; }).join(' ');
2262
2278
  }
2263
2279
  function refreshRobotCliCommand(){
@@ -2267,8 +2283,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
2267
2283
  inp.value=buildAddRobotCommand(name, cid);
2268
2284
  row.style.display='flex';
2269
2285
  }
2270
- var rbNameEl=$('rb-name'); if(rbNameEl) rbNameEl.addEventListener('input', refreshRobotCliCommand);
2271
- var rbCharEl=$('rb-char'); if(rbCharEl) rbCharEl.addEventListener('change', refreshRobotCliCommand);
2286
+ var rbNameEl=$('rb-name'); if(rbNameEl) rbNameEl.addEventListener('input', refreshRobotCliCommand);
2287
+ var rbCharEl=$('rb-char'); if(rbCharEl) rbCharEl.addEventListener('change', refreshRobotCliCommand);
2288
+ var rbNetworkEl=$('rb-network'); if(rbNetworkEl) rbNetworkEl.addEventListener('change', refreshRobotCliCommand);
2289
+ var rbSiteEl=$('rb-site'); if(rbSiteEl) rbSiteEl.addEventListener('input', refreshRobotCliCommand);
2290
+ var rbTokenEl=$('rb-token'); if(rbTokenEl){ rbTokenEl.value=dashboardMeshToken(); rbTokenEl.addEventListener('input', refreshRobotCliCommand); }
2272
2291
  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 — run it on the robot itself'); });
2273
2292
 
2274
2293
  function addRobot(){
@@ -2653,7 +2672,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
2653
2672
  var RP_COMMAND_ENV_KEY='rp-command-env-v1';
2654
2673
  var RP_CUSTOM_COMMANDS_KEY='rp-custom-commands-v1';
2655
2674
  var rpVisibleCommands=[];
2656
- function rpCommandDefaults(){return {lan:'192.168.1.12',tail:'100.84.147.24',site:'Tel Aviv',robot:'bmw',enrollment:'',token:TOKEN};}
2675
+ function rpCommandDefaults(){return {lan:'192.168.1.12',tail:'100.84.147.24',site:'Tel Aviv',robot:'bmw',enrollment:'',token:dashboardMeshToken()};}
2657
2676
  function rpLoadStoredJson(key,fallback){try{var value=JSON.parse(localStorage.getItem(key)||'null');return value&&typeof value==='object'?value:fallback;}catch(e){return fallback;}}
2658
2677
  function rpCommandEnv(){
2659
2678
  var d=rpCommandDefaults(),s=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});
@@ -2705,7 +2724,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
2705
2724
  }
2706
2725
  function initCommandSheet(){
2707
2726
  var fields=['lan','tail','site','robot','enrollment','token'],defaults=rpCommandDefaults(),saved=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});
2708
- fields.forEach(function(name){var input=$('rp-command-'+name);if(!input)return;input.value=saved[name]!=null?saved[name]:defaults[name];input.addEventListener('input',function(){rpSaveCommandEnv();renderCommandSheet();});});
2727
+ 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();});});
2709
2728
  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');
2710
2729
  if(search)search.addEventListener('input',renderCommandSheet);if(category)category.addEventListener('change',renderCommandSheet);
2711
2730
  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');});
@@ -4078,21 +4097,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
4078
4097
  // Pond / curved water feature at the lower-left of the plaza.
4079
4098
  var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};
4080
4099
  // Street traffic along the top road. Each car has x, y (world), color,
4081
- // a per-car speed (world units / second), a direction (+1 = right, -1 =
4082
- // left). y is one of two lane bands (≈ -0.65 for outbound, ≈ -1.18 for
4083
- // inbound) so cars don't overlap. Speeds are deliberately varied and
4084
- // fast enough to read as movement at 60fps — the road is short, so
4085
- // a 4-7s pass across the screen feels natural.
4100
+ // a per-car base speed (world units / second), a direction (+1 = right,
4101
+ // -1 = left), and a per-frame jitter range so cars never settle into
4102
+ // a stable cluster. y is one of two lane bands (≈ -0.65 for outbound,
4103
+ // -1.18 for inbound) so cars don't overlap.
4104
+ //
4105
+ // Stability fix: the previous version always re-spawned a wrapped car
4106
+ // at x = -0.5 (or PARK_W + 0.5) — every car in the same lane teleported
4107
+ // to the same spot, so the slowest car instantly caught the fastest
4108
+ // one and the whole lane collapsed into a clump. We now (a) spawn
4109
+ // each car at a random x within the visible road, and (b) add a
4110
+ // ±5% per-frame speed jitter so cars gradually drift apart. Speeds
4111
+ // are also more spread (2.2 → 4.6) so the natural ordering stays
4112
+ // stable long enough to look like real traffic.
4086
4113
  var ROAD_Y0=-1.5, ROAD_Y1=-0.35;
4087
4114
  var CARS=[
4088
- {x:1.5, y:-0.65, color:'#f2c14e', speed:3.2, dir:+1},
4089
- {x:4.0, y:-1.18, color:'#7fb4ff', speed:3.6, dir:-1},
4090
- {x:6.5, y:-0.65, color:'#e0a23c', speed:2.8, dir:+1},
4091
- {x:9.0, y:-1.18, color:'#ff7e6e', speed:4.0, dir:-1},
4092
- {x:11.5, y:-0.65, color:'#d8d8e0', speed:3.0, dir:+1},
4093
- {x:0.5, y:-1.18, color:'#5b8bd6', speed:3.4, dir:-1},
4094
- {x:13.0, y:-0.65, color:'#c87ed4', speed:2.6, dir:+1},
4095
- {x:7.0, y:-1.18, color:'#f2c14e', speed:3.8, dir:-1},
4115
+ {x:1.5, y:-0.65, color:'#f2c14e', speed:2.4, dir:+1},
4116
+ {x:6.5, y:-0.65, color:'#e0a23c', speed:3.6, dir:+1},
4117
+ {x:11.5, y:-0.65, color:'#d8d8e0', speed:4.4, dir:+1},
4118
+ {x:4.0, y:-1.18, color:'#7fb4ff', speed:2.6, dir:-1},
4119
+ {x:9.0, y:-1.18, color:'#ff7e6e', speed:3.4, dir:-1},
4120
+ {x:0.5, y:-1.18, color:'#5b8bd6', speed:4.2, dir:-1},
4096
4121
  ];
4097
4122
  // Per-car phase offsets so headlights / taillights / wheels don't all
4098
4123
  // blink in lockstep — gives a natural "different cars, different
@@ -4369,12 +4394,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
4369
4394
  connected: match?!!match.connected:false,
4370
4395
  load: (match&&match.load!=null)?match.load:0,
4371
4396
  platform: match?(match.platform||''):'', version: match?(match.version||'?'):'—',
4372
- site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
4373
- schedulerRobotId: match?(match.schedulerRobotId||match.schedulerDeviceId||''):'',
4374
- schedulerDeviceId: match?(match.schedulerDeviceId||match.schedulerRobotId||''):'',
4397
+ site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
4398
+ schedulerRobotId: match?(match.schedulerRobotId||match.schedulerDeviceId||''):'',
4399
+ schedulerDeviceId: match?(match.schedulerDeviceId||match.schedulerRobotId||''):'',
4375
4400
  telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
4401
+ // slotId is the stable handle we use to persist operator-
4402
+ // chosen positions. slotNx/slotNy are the preset's default
4403
+ // coordinates so the user can always reset back.
4404
+ slotId: slot.id, slotNx: slot.nx, slotNy: slot.ny,
4376
4405
  wx:wx, wy:wy, _hit:prevHit(id)
4377
- };
4406
+ };
4378
4407
  // Attach a status derived from the latest scheduler telemetry
4379
4408
  // (push-poll, so it can lag up to 3s behind /fed/status).
4380
4409
  if(kind === 'robot'){
@@ -4406,6 +4435,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
4406
4435
  if(!s) return;
4407
4436
  nodes = layout(s.nodes||[]);
4408
4437
  byId={}; nodes.forEach(function(n){ byId[n.id]=n; });
4438
+ // re-apply any persisted Ctrl+drag overrides on top of the
4439
+ // fresh layout (the server may have also returned updated
4440
+ // slot nx/ny — those go through the override map too)
4441
+ _applyPosOverrides();
4409
4442
  // if not streaming, animate from the polled activity tail
4410
4443
  if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }
4411
4444
  }
@@ -4450,16 +4483,187 @@ button.act:disabled{opacity:0.5;cursor:default;}
4450
4483
  }
4451
4484
 
4452
4485
  // ── canvas + projection ──
4486
+ // State for Ctrl+drag robot repositioning on the park map. When
4487
+ // the user holds Ctrl and mousedown's a robot, the canvas stops
4488
+ // panning and instead drags that node around. On mouseup, the
4489
+ // new (nx, ny) is written to _parkPosOverrides and pushed to
4490
+ // the scheduler via a debounced PUT. Survives reloads because
4491
+ // the server stores nx/ny in the character_presets table.
4492
+ var _parkPosOverrides = {}; // presetId -> {nx, ny} (normalized 0..1)
4493
+ var _dragNode = null; // the robot node currently being dragged
4494
+ var _dragOffset = {x:0, y:0}; // screen-space offset from node center
4495
+ var _posSaveTimer = null; // debounce timer for the server PUT
4496
+ var _posSaveQueue = {}; // presetId -> {nx, ny} pending save
4497
+ function _isoToWorld(sx, sy){
4498
+ // Inverse of iso() in the same coordinate system. Given a
4499
+ // screen point, return the (wx, wy) world point. Used by the
4500
+ // Ctrl+drag handler to translate a mouse position back into
4501
+ // the iso coordinate space so the robot follows the cursor.
4502
+ var dx = (sx - (W-300)/2 - cam.x) / (TW*cam.z);
4503
+ var dy = (sy - H*0.42 - cam.y) / (TH*cam.z);
4504
+ // from iso(): dx = (wx-PARK_W/2) - (wy-PARK_D/2); dy = (wx-PARK_W/2) + (wy-PARK_D/2)
4505
+ // so wx = (dx+dy)/2 + PARK_W/2; wy = (dy-dx)/2 + PARK_D/2
4506
+ return { x: (dx+dy)*0.5 + PARK_W*0.5, y: (dy-dx)*0.5 + PARK_D*0.5 };
4507
+ }
4508
+ function _applyPosOverrides(){
4509
+ // Walk nodes and override their wx/wy from _parkPosOverrides if
4510
+ // we have a stored position. The slot's default nx/ny stays in
4511
+ // node.slotNx/slotNy so the user can "reset" back to defaults
4512
+ // if we ever expose that control.
4513
+ for(var i=0; i<nodes.length; i++){
4514
+ var n = nodes[i];
4515
+ if(!n.slotId) continue;
4516
+ var ov = _parkPosOverrides[n.slotId];
4517
+ if(ov){
4518
+ n.wx = Math.max(0, Math.min(PARK_W, ov.nx * PARK_W));
4519
+ n.wy = Math.max(0, Math.min(PARK_D, ov.ny * PARK_D));
4520
+ n._posOverridden = true;
4521
+ } else if(n._posOverridden){
4522
+ // The server just gave us a fresh layout and we don't have
4523
+ // a local override — fall back to the slot's default position.
4524
+ n.wx = n.slotNx * PARK_W;
4525
+ n.wy = n.slotNy * PARK_D;
4526
+ n._posOverridden = false;
4527
+ }
4528
+ }
4529
+ }
4530
+ function _schedulePosSave(){
4531
+ // Debounce: collapse a flurry of drag-end events into one
4532
+ // PUT after 350ms of quiet. Saves queue is merged so the final
4533
+ // PUT always reflects the latest position for each preset.
4534
+ if(_posSaveTimer) clearTimeout(_posSaveTimer);
4535
+ _posSaveTimer = setTimeout(_flushPosSaves, 350);
4536
+ }
4537
+ function _flushPosSaves(){
4538
+ var entries = [];
4539
+ for(var id in _posSaveQueue){
4540
+ if(Object.prototype.hasOwnProperty.call(_posSaveQueue, id)){
4541
+ entries.push({ id: id, nx: _posSaveQueue[id].nx, ny: _posSaveQueue[id].ny });
4542
+ }
4543
+ }
4544
+ _posSaveQueue = {};
4545
+ _posSaveTimer = null;
4546
+ if(!entries.length) return;
4547
+ // Persist to the scheduler. We POST to the dedicated
4548
+ // /api/character-presets/positions batch endpoint which clamps
4549
+ // nx/ny to [0.04, 0.96] so a stray drag can't park a robot
4550
+ // off the island.
4551
+ var body = JSON.stringify({ positions: entries });
4552
+ try{
4553
+ fetch('/api/character-presets/positions'+QS, {
4554
+ method: 'PUT',
4555
+ headers: { 'Content-Type': 'application/json' },
4556
+ body: body,
4557
+ keepalive: true
4558
+ }).then(function(r){
4559
+ if(r.ok) return r.json();
4560
+ throw new Error('HTTP '+r.status);
4561
+ }).then(function(j){
4562
+ if(typeof showOut === 'function'){
4563
+ showOut('park position saved · '+(j.updated||entries.length)+' preset'+(entries.length===1?'':'s'));
4564
+ }
4565
+ }).catch(function(err){
4566
+ if(typeof showOut === 'function') showOut('park position save failed: '+err.message);
4567
+ });
4568
+ }catch(e){ /* offline — keep override in memory */ }
4569
+ }
4570
+ function _hitTestNode(mx, my){
4571
+ // Walk all visible nodes and return the first whose hit-box
4572
+ // contains (mx, my). Mirrors the existing click hit-test
4573
+ // but returns the node directly (and skips early if a drag
4574
+ // is already in progress).
4575
+ for(var i=0; i<nodes.length; i++){
4576
+ var n = nodes[i];
4577
+ var hb = n._hit;
4578
+ if(hb && mx>=hb.x && mx<=hb.x+hb.w && my>=hb.y && my<=hb.y+hb.h){
4579
+ return n;
4580
+ }
4581
+ }
4582
+ return null;
4583
+ }
4453
4584
  function ensure(){
4454
4585
  if(C) return;
4455
4586
  C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();
4456
4587
  DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));
4457
- var d=false,px,py;
4458
- C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});
4459
- window.addEventListener('mouseup',function(){d=false;});
4460
- window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });
4588
+ // Panning: drag on the canvas (no Ctrl) pans the iso camera.
4589
+ // Ctrl+drag on a robot reposition the robot on the park map.
4590
+ // Each gesture is mutually exclusive — we keep one _isPanning
4591
+ // flag and one _dragNode at a time.
4592
+ var _isPanning = false;
4593
+ var _px = 0, _py = 0;
4594
+ C.addEventListener('mousedown',function(e){
4595
+ var r = C.getBoundingClientRect();
4596
+ var mx = e.clientX - r.left;
4597
+ var my = e.clientY - r.top;
4598
+ // Ctrl+drag: if a robot is under the cursor, pick it up.
4599
+ // Only ROBOT nodes are draggable — the hub, parked cars in the
4600
+ // lot, and ghosts for offline nodes stay locked to their slot.
4601
+ if(e.ctrlKey){
4602
+ var hit = _hitTestNode(mx, my);
4603
+ if(hit && hit.slotId && hit.kind === 'robot'){
4604
+ _dragNode = hit;
4605
+ var p = iso(hit.wx, hit.wy);
4606
+ _dragOffset.x = mx - p.x;
4607
+ _dragOffset.y = my - p.y;
4608
+ // prevent text selection while dragging
4609
+ e.preventDefault();
4610
+ return;
4611
+ }
4612
+ }
4613
+ // otherwise: start a pan
4614
+ _isPanning = true;
4615
+ _px = e.clientX; _py = e.clientY;
4616
+ });
4617
+ window.addEventListener('mouseup',function(){
4618
+ if(_dragNode){
4619
+ // commit the drag: record override, schedule server save
4620
+ var nx = _dragNode.wx / PARK_W;
4621
+ var ny = _dragNode.wy / PARK_D;
4622
+ var id = _dragNode.slotId;
4623
+ if(id){
4624
+ _parkPosOverrides[id] = { nx: nx, ny: ny };
4625
+ _posSaveQueue[id] = { nx: nx, ny: ny };
4626
+ _schedulePosSave();
4627
+ }
4628
+ _dragNode = null;
4629
+ }
4630
+ _isPanning = false;
4631
+ });
4632
+ window.addEventListener('mousemove',function(e){
4633
+ if(_dragNode){
4634
+ var r = C.getBoundingClientRect();
4635
+ var mx = e.clientX - r.left;
4636
+ var my = e.clientY - r.top;
4637
+ // screen point = mouse minus the original grab offset, so
4638
+ // the robot doesn't snap its center to the cursor
4639
+ var worldPt = _isoToWorld(mx - _dragOffset.x, my - _dragOffset.y);
4640
+ // clamp to the visible grass rectangle (with a small
4641
+ // inner margin so the avatar doesn't get clipped by the
4642
+ // gold wall or the hedge)
4643
+ var MARGIN = 0.4;
4644
+ _dragNode.wx = Math.max(MARGIN, Math.min(PARK_W - MARGIN, worldPt.x));
4645
+ _dragNode.wy = Math.max(MARGIN, Math.min(PARK_D - MARGIN, worldPt.y));
4646
+ return;
4647
+ }
4648
+ if(!_isPanning) return;
4649
+ cam.x += e.clientX - _px;
4650
+ cam.y += e.clientY - _py;
4651
+ _px = e.clientX; _py = e.clientY;
4652
+ });
4653
+ // Escape cancels an in-progress drag without saving
4654
+ window.addEventListener('keydown',function(e){
4655
+ if((e.key === 'Escape' || e.key === 'Esc') && _dragNode){
4656
+ // restore to the original override (or slot default)
4657
+ _dragNode.wx = _dragNode.slotNx * PARK_W;
4658
+ _dragNode.wy = _dragNode.slotNy * PARK_D;
4659
+ _dragNode = null;
4660
+ }
4661
+ });
4461
4662
  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});
4462
4663
  C.addEventListener('click',function(e){
4664
+ // Suppress the click that fires at the end of a drag — we
4665
+ // don't want a single drag-release to also open the drawer.
4666
+ if(_dragNode) return;
4463
4667
  var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
4464
4668
  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]; }
4465
4669
  if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }
@@ -4605,9 +4809,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
4605
4809
  // the warm sun")
4606
4810
  // The underside and drop shadow give the park real depth so it
4607
4811
  // doesn't look like a flat sticker on the sky.
4608
- // Build the grass perimeter polygon once (offset slightly outward
4609
- // so the gold rim sits just outside the turf edge).
4610
- var GRASS_RIM = 0.18; // world units of gold rim thickness
4812
+ // Build the grass perimeter polygon once (the same corners the
4813
+ // grass field uses, so the gold wall's top edge sits flush
4814
+ // against the grass). We use grassPerim(0) no offset — for
4815
+ // the walls, and the perimeter itself is used again later for
4816
+ // the bottom face of the floating island (see UNDERSIDE_LIFT).
4611
4817
  function grassPerim(offset){
4612
4818
  // Use the same world corners as the grass field, offset outward.
4613
4819
  var o = offset;
@@ -4724,39 +4930,80 @@ button.act:disabled{opacity:0.5;cursor:default;}
4724
4930
  X.fill();
4725
4931
  }
4726
4932
  X.restore();
4727
- // 3. GOLD RIMthick gold band tracing the grass perimeter.
4728
- // Drawn AFTER the underside so the top edge of the gold
4729
- // sits flush with the top of the grass. Two strokes: a wide
4730
- // gold core + a thin lighter inner highlight on the top edge.
4731
- var rimOuter = grassPerim(GRASS_RIM);
4933
+ // 3. RAISED GOLD WALLSproper 3D parapet around the park.
4934
+ // The wall follows the grass perimeter (4-corner rectangle).
4935
+ // Each side has a visible front face that extends downward in
4936
+ // screen space, plus a thin top-edge highlight that catches
4937
+ // the warm sun light. Drawn AFTER the underside so the top
4938
+ // edge of the wall sits flush with the top of the grass.
4939
+ //
4940
+ // Geometry: the wall's TOP edge is the grass perimeter line.
4941
+ // Its BOTTOM edge is the same line shifted down by WALL_H
4942
+ // pixels. We draw each of the 4 sides as a quad (TL, TR,
4943
+ // BR, BL), then trace a thin gold line along the top of
4944
+ // each side for the highlight.
4732
4945
  var rimInner = grassPerim(0);
4733
- var rimOuterScreen = rimOuter.map(function(p){ return iso(p[0], p[1]); });
4734
4946
  var rimInnerScreen = rimInner.map(function(p){ return iso(p[0], p[1]); });
4947
+ var WALL_H = 26 * z; // wall height in screen pixels
4948
+ // Build bottom-edge screen points by shifting each top point
4949
+ // down by WALL_H.
4950
+ var rimBotScreen = rimInnerScreen.map(function(s){
4951
+ return { x: s.x, y: s.y + WALL_H };
4952
+ });
4953
+ // Front face: a single closed polygon (the grass perimeter top
4954
+ // + the grass perimeter bottom, traversed in reverse). Drawn
4955
+ // in two passes — first a darker base, then a lighter overlay
4956
+ // stripe near the top to give the wall some "warm-light from
4957
+ // above" shading.
4735
4958
  X.save();
4736
- // outer glow
4737
- var glowGrad = X.createLinearGradient(0, rimOuterScreen[0].y - 30, 0, rimOuterScreen[3].y + 30);
4738
- X.fillStyle = 'rgba(255, 200, 110, 0.0)';
4739
- // fill the rim band as a quad
4959
+ // base wall (slightly darker gold)
4960
+ X.fillStyle = 'rgba(176, 124, 44, 0.96)';
4740
4961
  X.beginPath();
4741
- X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
4742
- X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
4743
- X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
4744
- X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
4962
+ X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4963
+ X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
4964
+ X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
4965
+ X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4966
+ X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);
4967
+ X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);
4968
+ X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);
4969
+ X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);
4745
4970
  X.closePath();
4746
4971
  X.fill();
4747
- // gold band via two strokes: thick base + thin highlight
4748
- X.strokeStyle = 'rgba(255, 196, 92, 0.95)'; // base gold
4749
- X.lineWidth = 5.0*z;
4972
+ // warm-light gradient overlay fades from bright gold at the
4973
+ // top of the wall to transparent at the bottom, so the wall
4974
+ // reads as if the top is catching the sun and the bottom is
4975
+ // in shadow.
4976
+ var wallGrad = X.createLinearGradient(0, rimInnerScreen[0].y, 0, rimInnerScreen[0].y + WALL_H);
4977
+ wallGrad.addColorStop(0, 'rgba(255, 226, 132, 0.85)');
4978
+ wallGrad.addColorStop(0.35, 'rgba(255, 196, 92, 0.55)');
4979
+ wallGrad.addColorStop(1, 'rgba(176, 124, 44, 0)');
4980
+ X.fillStyle = wallGrad;
4750
4981
  X.beginPath();
4751
4982
  X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4752
4983
  X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
4753
4984
  X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
4754
4985
  X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4986
+ X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);
4987
+ X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);
4988
+ X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);
4989
+ X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);
4990
+ X.closePath();
4991
+ X.fill();
4992
+ // outer dark base line at the bottom of the wall (the bottom
4993
+ // edge against the sky) — sells the "raised" effect
4994
+ X.strokeStyle = 'rgba(124, 84, 28, 0.95)';
4995
+ X.lineWidth = 1.4 * z;
4996
+ X.beginPath();
4997
+ X.moveTo(rimBotScreen[0].x, rimBotScreen[0].y);
4998
+ X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);
4999
+ X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);
5000
+ X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);
4755
5001
  X.closePath();
4756
5002
  X.stroke();
4757
- // inner highlight (thinner, brighter)
4758
- X.strokeStyle = 'rgba(255, 232, 158, 0.95)';
4759
- X.lineWidth = 1.4*z;
5003
+ // top edge — gold band right at the grass line, with a thin
5004
+ // lighter highlight on top to catch the sun.
5005
+ X.strokeStyle = 'rgba(196, 138, 48, 0.95)';
5006
+ X.lineWidth = 4.0 * z;
4760
5007
  X.beginPath();
4761
5008
  X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4762
5009
  X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
@@ -4764,16 +5011,26 @@ button.act:disabled{opacity:0.5;cursor:default;}
4764
5011
  X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4765
5012
  X.closePath();
4766
5013
  X.stroke();
4767
- // outer deep-gold edge (slightly darker outer stroke for definition)
4768
- X.strokeStyle = 'rgba(190, 134, 48, 0.85)';
4769
- X.lineWidth = 1.2*z;
5014
+ // bright top highlight (thin, hottest color)
5015
+ X.strokeStyle = 'rgba(255, 232, 158, 0.95)';
5016
+ X.lineWidth = 1.2 * z;
4770
5017
  X.beginPath();
4771
- X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
4772
- X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
4773
- X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
4774
- X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
5018
+ X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
5019
+ X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
5020
+ X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
5021
+ X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4775
5022
  X.closePath();
4776
5023
  X.stroke();
5024
+ // corner studs — small gold dots at each of the 4 perimeter
5025
+ // vertices so the wall reads as constructed panels, not a
5026
+ // single continuous block.
5027
+ for(var sci=0; sci<rimInnerScreen.length; sci++){
5028
+ var sPt = rimInnerScreen[sci];
5029
+ X.fillStyle = 'rgba(255, 220, 140, 0.95)';
5030
+ X.beginPath(); X.arc(sPt.x, sPt.y, 2.2 * z, 0, Math.PI*2); X.fill();
5031
+ X.fillStyle = 'rgba(196, 138, 48, 0.85)';
5032
+ X.beginPath(); X.arc(sPt.x, sPt.y, 3.4 * z, 0, Math.PI*2); X.stroke();
5033
+ }
4777
5034
  X.restore();
4778
5035
  // ── flower spots — deterministic small dots in 3 colors, scattered in the lawn only ──
4779
5036
  X.save();
@@ -6263,21 +6520,40 @@ button.act:disabled{opacity:0.5;cursor:default;}
6263
6520
  // Cars share the same elapsed-dt clock so a tab-switch doesn't
6264
6521
  // catapult every car off the road at once. speed is world units
6265
6522
  // per second, dt is milliseconds. cdt is the already-clamped dt.
6523
+ // Per-frame speed jitter (±5%) so the same lane never settles into
6524
+ // a stable cluster — the fastest car gradually pulls away from the
6525
+ // slowest one and the spread stays alive even after many wraps.
6266
6526
  var cdt = Math.min(64, dt);
6267
6527
  for(var ci=0; ci<CARS.length; ci++){
6268
6528
  var car = CARS[ci];
6269
- car.x += car.dir * car.speed * (cdt/1000);
6529
+ // Deterministic per-car jitter based on wall-clock + index so it
6530
+ // changes every frame but isn't pure noise (smooth-ish accel).
6531
+ var jitter = 1 + (Math.sin(now/380 + ci*1.7) * 0.05);
6532
+ car.x += car.dir * car.speed * jitter * (cdt/1000);
6270
6533
  // Wrap the car around the road. When it leaves one end, it
6271
- // re-enters the opposite end on the other lane so oncoming
6272
- // traffic feels continuous.
6534
+ // re-enters the opposite end on the other lane and the
6535
+ // re-entry x is randomized across the visible road so two
6536
+ // cars that arrived at the boundary together don't land on
6537
+ // top of each other.
6273
6538
  if(car.dir > 0 && car.x > PARK_W + 0.5){
6274
- car.x = -0.5;
6539
+ // Outbound (right-bound) car reached the right edge —
6540
+ // re-enter on the left. We swap to the OTHER lane so two
6541
+ // lanes of traffic are visible at all times; the wrap dir
6542
+ // is explicitly set to +1 so the car continues in its
6543
+ // original direction on the new lane. (Previous version
6544
+ // computed dir from the *post-wrap* y, which always
6545
+ // flipped the car to the opposite direction — cars would
6546
+ // oscillate at the road edge instead of crossing it.)
6547
+ car.x = -0.5 - Math.random() * (PARK_W * 0.5);
6275
6548
  car.y = (car.y < -0.9) ? -0.65 : -1.18;
6276
- car.dir = (car.y < -0.9) ? +1 : -1;
6549
+ car.dir = +1;
6277
6550
  } else if(car.dir < 0 && car.x < -0.5){
6278
- car.x = PARK_W + 0.5;
6551
+ // Inbound (left-bound) car reached the left edge —
6552
+ // re-enter on the right, swap to the other lane, keep
6553
+ // direction at -1. (See note above about the prior bug.)
6554
+ car.x = PARK_W + 0.5 + Math.random() * (PARK_W * 0.5);
6279
6555
  car.y = (car.y < -0.9) ? -0.65 : -1.18;
6280
- car.dir = (car.y < -0.9) ? +1 : -1;
6556
+ car.dir = -1;
6281
6557
  }
6282
6558
  }
6283
6559
  }
@@ -6489,6 +6765,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
6489
6765
  ensure();
6490
6766
  loadSDK();
6491
6767
  loadImages();
6768
+ // Pull the latest operator-saved positions from the scheduler
6769
+ // and seed the in-memory override map. Without this step the
6770
+ // dashboard would only know about local (this-session) drags
6771
+ // and would reset to slot defaults on every reload.
6772
+ try{
6773
+ getJson('/api/character-presets'+QS).then(function(list){
6774
+ if(Array.isArray(list)){
6775
+ for(var pi=0; pi<list.length; pi++){
6776
+ var p = list[pi];
6777
+ if(p && p.id && typeof p.nx === 'number' && typeof p.ny === 'number'){
6778
+ _parkPosOverrides[p.id] = { nx: p.nx, ny: p.ny };
6779
+ }
6780
+ }
6781
+ }
6782
+ // re-apply on the live node list (if it's already populated
6783
+ // by a prior /fed/status frame)
6784
+ if(typeof _applyPosOverrides === 'function') _applyPosOverrides();
6785
+ }).catch(function(){ /* offline — use slot defaults */ });
6786
+ }catch(e){}
6492
6787
  if(lastStatus) onStatus(lastStatus);
6493
6788
  resize();
6494
6789
  connectStream();
@@ -7164,7 +7459,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
7164
7459
  +'<div class="pk-note" id="pk-camnote">seeking live feed…</div>'
7165
7460
  +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">↻ 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>'
7166
7461
  +'<label class="pk-prodtoggle"><input type="checkbox" id="pk-prodmode"'+(devForPm.production_mode?' checked':'')+'><span>Production mode — continuous motion-triggered loop, camera always live</span></label>')
7167
- + sect('Remote production test','<div id="pk-pipeline-summary" class="pk-pipeline-summary">Checking robot, camera, scheduler and LiveKit...</div>'
7462
+ + 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>'
7168
7463
  + '<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>'
7169
7464
  + '<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>'
7170
7465
  + '<div class="pk-pipeline" id="pk-pipeline"></div>')
@@ -7356,7 +7651,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
7356
7651
  return stages[Math.min(stages.length-1,lastOk+1)];
7357
7652
  }
7358
7653
  function renderPipeline(data){
7359
- var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'); if(!host||!summary) return;
7654
+ var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'),focus=$('pk-live-stage'); if(!host||!summary) return;
7360
7655
  var latest={}, prereqs=data.prerequisites||{}, session=data.latest_session||{}, sid=session.id||null;
7361
7656
  (data.events||[]).forEach(function(ev){
7362
7657
  if(ev.session_id && sid && ev.session_id!==sid) return;
@@ -7365,15 +7660,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
7365
7660
  ['robot_online','camera_ready','microphone_ready','speaker_ready'].forEach(function(stage){
7366
7661
  if(!latest[stage]) latest[stage]={stage:stage,status:prereqs[stage]?'ok':'blocked',message:prereqs[stage]?'Ready':'Not reported'};
7367
7662
  });
7368
- var running=data.overall==='running',stages=data.stages||Object.keys(PIPELINE_LABELS),current=pipelineCurrentStage(stages,latest,running);
7663
+ var running=data.overall==='running',stages=data.stages||Object.keys(PIPELINE_LABELS),authoritative=data.current_stage||{},current=authoritative.stage||pipelineCurrentStage(stages,latest,running);
7369
7664
  host.innerHTML=stages.map(function(stage,index){
7370
- var ev=latest[stage],isCurrent=stage===current,status=ev&&ev.status||(isCurrent?'running':'pending');
7371
- var detail=ev&&(ev.message||ev.timestamp)||'waiting';
7665
+ var ev=latest[stage],isCurrent=stage===current,status=(isCurrent&&authoritative.status)||ev&&ev.status||(isCurrent?'running':'pending');
7666
+ var detail=isCurrent&&(authoritative.message||authoritative.label)||ev&&(ev.message||ev.timestamp)||'waiting';
7372
7667
  var time=ev&&ev.timestamp?new Date(ev.timestamp+'Z').toLocaleString():'';
7373
7668
  var phase=PIPELINE_PHASES[stage]||'session';
7374
- return '<div class="pk-pipe-step phase-'+phase+' '+esc(status)+(isCurrent?' current':'')+'"><span class="step-index">'+String(index+1).padStart(2,'0')+'</span><span><span class="stage">'+esc(PIPELINE_LABELS[stage]||stage)+'</span><span class="phase">'+esc(phase)+'</span></span><span class="detail">'+esc(detail)+(time?'<span class="time">'+esc(time)+'</span>':'')+'</span></div>';
7669
+ 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>';
7375
7670
  }).join('');
7376
7671
  var blockers=data.blockers||[];
7672
+ 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>';}
7377
7673
  summary.className='pk-pipeline-summary '+esc(data.overall||'blocked');
7378
7674
  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');
7379
7675
  var micHealth=$('pk-mic-health');if(micHealth){
@@ -7519,11 +7815,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
7519
7815
  function refreshCameraOperations(){
7520
7816
  var n=cameraOpsNode;if(!n)return;var row=schedRow(n)||{},device=schedulerDevice(n)||{},hw=hwByNode[n.nodeId]||{};
7521
7817
  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');
7818
+ 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');
7522
7819
  var img=$('pk-camera-ops-img'),cameraLive=!!(img&&img.dataset.live==='true'),clockEl=$('pk-camera-ops-clock');if(clockEl)clockEl.textContent=clock(Date.now());
7523
7820
  var rec=$('pk-camera-ops-rec');if(rec)rec.textContent=(cameraLive?'REC / LIVE':'REC / CONNECTING')+' / '+String(n.displayName||n.name||'robot').toUpperCase();
7524
- var overlay=$('pk-camera-ops-overlay');if(overlay)overlay.innerHTML='<div class="metric"><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>readiness</span></div><div class="metric"><b>'+esc(active?'in session':'standing by')+'</b><span>conversation</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>';
7821
+ 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>';
7525
7822
  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>';
7526
- var stages=[['Robot online',!!n.connected],['Camera streaming',cameraLive],['Motion detector armed',!!device.production_mode],['Scheduler session',active],['Voice pipeline',readiness==='in_session'||active]],pipe=$('pk-camera-ops-pipeline');if(pipe)pipe.innerHTML=stages.map(function(s){return '<div><i class="'+(s[1]?'ok':'warn')+'"></i><span>'+esc(s[0])+'</span><b>'+(s[1]?'ready':'waiting')+'</b></div>';}).join('');
7823
+ 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('');
7527
7824
  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>';
7528
7825
  var prod=$('pk-camera-ops-production');if(prod){prod.textContent=device.production_mode?'Disable production':'Enable production';prod.classList.toggle('primary',!!device.production_mode);}
7529
7826
  }