infinicode 2.8.35 → 2.8.37

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.
@@ -217,13 +217,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
217
217
  .rp-advanced-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.6rem;margin-top:0.7rem;}
218
218
  .rp-btn.primary{background:linear-gradient(160deg,color-mix(in srgb,var(--gold-bright) 30%,var(--panel-2)),var(--panel-2));border-color:var(--gold-bright);color:var(--gold-bright);font-weight:600;}
219
219
  .rp-btn.primary:hover{box-shadow:0 0 0 3px color-mix(in srgb,var(--gold-bright) 16%,transparent);}
220
- .rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
221
- background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
222
- border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
223
- .rp-card-top{display:flex;align-items:flex-start;gap:0.55rem;}
224
- .rp-card-img{width:42px;height:42px;border-radius:11px;overflow:hidden;flex:none;border:1px solid var(--glass-border);background:var(--panel-2);}
225
- .rp-card-img img{width:100%;height:100%;object-fit:cover;display:block;}
226
- .rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
220
+ .rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
221
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
222
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
223
+ .rp-card-top{display:flex;align-items:flex-start;gap:0.55rem;}
224
+ .rp-card-img{width:42px;height:42px;border-radius:11px;overflow:hidden;flex:none;border:1px solid var(--glass-border);background:var(--panel-2);}
225
+ .rp-card-img img{width:100%;height:100%;object-fit:cover;display:block;}
226
+ .rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
227
227
  .rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}
228
228
  .rp-m{display:flex;flex-direction:column;}
229
229
  .rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}
@@ -281,10 +281,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
281
281
  transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}
282
282
  .pk-drawer.open{transform:translateX(0);}
283
283
  .pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}
284
- .pk-av{width:40px;height:40px;border-radius:11px;background:radial-gradient(circle at 30% 25%,rgba(255,214,112,0.28),rgba(0,0,0,0.25));
285
- border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;overflow:hidden;}
286
- .pk-av.hasimg{background:var(--panel-2);}
287
- .pk-av img{width:100%;height:100%;object-fit:cover;display:block;}
284
+ .pk-av{width:40px;height:40px;border-radius:11px;background:radial-gradient(circle at 30% 25%,rgba(255,214,112,0.28),rgba(0,0,0,0.25));
285
+ border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;overflow:hidden;}
286
+ .pk-av.hasimg{background:var(--panel-2);}
287
+ .pk-av img{width:100%;height:100%;object-fit:cover;display:block;}
288
288
  .pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}
289
289
  .pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}
290
290
  .pk-dh .x{margin-left:auto;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;width:30px;height:30px;cursor:pointer;font-size:1rem;}
@@ -319,6 +319,170 @@ button.act:disabled{opacity:0.5;cursor:default;}
319
319
  .pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
320
320
  .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
321
321
  .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
322
+ .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;}
323
+ .pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}
324
+ .pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}
325
+ .pk-prodtoggle:has(input:checked){border-color:var(--ok);background:rgba(60,180,110,0.12);}
326
+ .pk-svc-list{display:flex;flex-direction:column;gap:0.35rem;}
327
+ .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;}
328
+ .pk-svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--muted);}
329
+ .pk-svc-row.up .pk-svc-dot{background:var(--ok);box-shadow:0 0 6px var(--ok);}
330
+ .pk-svc-row.down .pk-svc-dot{background:var(--bad);}
331
+ .pk-svc-row.disabled .pk-svc-dot{background:var(--muted);}
332
+ .pk-svc-name{font-weight:600;color:var(--ink);min-width:6.5em;}
333
+ .pk-svc-meta{color:var(--muted);flex:1;}
334
+ .pk-svc-restart-btn{font-size:0.6rem;padding:0.2rem 0.5rem;flex-shrink:0;}
335
+
336
+ /* ── RoboPark Sessions & Telemetry — sub-tab control room ── */
337
+ .rp-subtabs{display:flex;gap:0.15rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);
338
+ background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
339
+ position:sticky;top:97px;z-index:18;flex-wrap:wrap;}
340
+ .rp-subtab{font-family:var(--mono);font-size:0.72rem;padding:0.55rem 0.85rem;cursor:pointer;color:var(--muted);background:none;border:none;
341
+ border-bottom:2px solid transparent;display:inline-flex;align-items:center;gap:0.4rem;transition:color .12s,border-color .12s;}
342
+ .rp-subtab:hover{color:var(--ink);}
343
+ .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);}
344
+ .rp-subtab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}
345
+ .rp-subtab .badge{font-family:var(--mono);font-size:0.6rem;padding:0.04em 0.45em;border-radius:100px;background:var(--panel-2);
346
+ border:1px solid var(--glass-border);color:var(--muted);font-weight:600;}
347
+ .rp-subtab.active .badge{color:var(--gold-bright);border-color:var(--border-strong);}
348
+ .rp-sub{display:none;padding:0.8rem 1.1rem 1.5rem;}
349
+ .rp-sub.active{display:block;}
350
+
351
+ .rp-kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.7rem;margin-bottom:0.9rem;}
352
+ .rp-kpi{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);
353
+ -webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
354
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);position:relative;overflow:hidden;}
355
+ .rp-kpi::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(135deg,rgba(255,214,112,0.04),transparent 60%);}
356
+ .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;}
357
+ .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;}
358
+ .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);}
359
+ .rp-kpi .s{font-family:var(--mono);font-size:0.62rem;color:var(--muted);margin-top:0.2rem;}
360
+
361
+ .rp-mini-chart-wrap{border-radius:13px;padding:0.7rem 0.85rem;background:var(--panel);
362
+ -webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
363
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);margin-bottom:0.9rem;}
364
+ .rp-mini-chart-wrap h4{font-family:var(--mono);font-size:0.62rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);
365
+ margin-bottom:0.4rem;display:flex;justify-content:space-between;align-items:baseline;}
366
+ .rp-mini-chart-wrap h4 .now{color:var(--muted);font-size:0.6rem;}
367
+ .rp-mini-chart{width:100%;height:120px;display:block;}
368
+
369
+ .rp-section-h{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-bright);
370
+ margin:0.2rem 0 0.55rem;display:flex;align-items:center;gap:0.5rem;justify-content:space-between;}
371
+ .rp-section-h .meta{color:var(--muted);font-size:0.62rem;text-transform:none;letter-spacing:0.02em;font-weight:400;}
372
+
373
+ .rp-top3{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:0.6rem;margin-bottom:0.9rem;}
374
+ .rp-top3 .rp-card{padding:0.55rem 0.7rem;}
375
+
376
+ .rp-stream{border-radius:13px;background:var(--panel);
377
+ -webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
378
+ 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;}
379
+ .rp-stream .line{padding:0.22rem 0.4rem;}
380
+ .rp-stream .line .ic{font-size:0.8rem;flex:none;}
381
+
382
+ .rp-filterbar{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;padding:0.5rem 0.7rem;background:var(--panel-2);
383
+ border:1px solid var(--glass-border);border-radius:11px;margin-bottom:0.6rem;}
384
+ .rp-filterbar .rp-field{flex:1;min-width:120px;max-width:240px;}
385
+ .rp-filterbar .rp-field label{font-size:0.6rem;}
386
+ .rp-filterbar .rp-field input,.rp-filterbar .rp-field select{font-size:0.7rem;padding:0.35rem 0.5rem;}
387
+ .rp-filterbar .rp-btn{padding:0.32rem 0.55rem;}
388
+
389
+ .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);
390
+ -webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
391
+ border:1px solid var(--glass-border);border-radius:11px;overflow:hidden;box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
392
+ .rp-sess-table th,.rp-history-table th,.rp-audit-table th{padding:0.55rem 0.7rem;text-align:left;font-weight:600;
393
+ font-size:0.6rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-bright);background:var(--panel-2);
394
+ border-bottom:1px solid var(--glass-border);position:sticky;top:0;z-index:1;}
395
+ .rp-sess-table td,.rp-history-table td,.rp-audit-table td{padding:0.5rem 0.7rem;border-bottom:1px solid var(--glass-border);
396
+ color:var(--ink);vertical-align:middle;}
397
+ .rp-sess-table tr:last-child td,.rp-history-table tr:last-child td,.rp-audit-table tr:last-child td{border-bottom:none;}
398
+ .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);}
399
+ .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;}
400
+ .rp-sess-table .t-rel{color:var(--ink);display:block;font-size:0.7rem;}
401
+ .rp-sess-table .t-abs{color:var(--muted);font-size:0.58rem;}
402
+ .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;}
403
+ .rp-tbl-wrap{max-height:520px;overflow:auto;border-radius:11px;}
404
+ .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);}
405
+ .rp-tbl-pager .rp-btn{padding:0.3rem 0.55rem;}
406
+ .rp-tbl-empty{padding:1.5rem;text-align:center;color:var(--muted);font-family:var(--mono);font-size:0.75rem;}
407
+ .rp-end-badge{display:inline-block;padding:0.05em 0.45em;border-radius:5px;font-size:0.6rem;font-family:var(--mono);
408
+ border:1px solid var(--glass-border);color:var(--muted);background:var(--panel-2);}
409
+ .rp-end-badge.operator{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent);background:var(--bad-bg);}
410
+ .rp-end-badge.silence{color:var(--muted);}
411
+ .rp-end-badge.error{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}
412
+ .rp-end-badge.timeout{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);background:var(--warn-bg);}
413
+ .rp-end-badge.completed{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:var(--ok-bg);}
414
+ .rp-end-badge.active,.rp-end-badge.running{color:var(--ok);border-color:color(--ok);background:var(--ok-bg);}
415
+
416
+ .rp-cat-chip{font-family:var(--mono);font-size:0.62rem;padding:0.28em 0.7em;border-radius:100px;
417
+ border:1px solid var(--glass-border);background:var(--panel-2);color:var(--muted);cursor:pointer;
418
+ display:inline-flex;align-items:center;gap:0.3rem;transition:all .12s;}
419
+ .rp-cat-chip:hover{color:var(--ink);border-color:var(--border-strong);}
420
+ .rp-cat-chip.active{color:var(--gold-bright);border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 10%,transparent);}
421
+ .rp-cat-chip .ct{font-size:0.55rem;opacity:0.8;}
422
+
423
+ .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);
424
+ display:none;align-items:flex-start;justify-content:center;padding:6vh 1rem 1rem;overflow-y:auto;}
425
+ .rp-modal-back.open{display:flex;}
426
+ .rp-modal{position:relative;width:min(720px,100%);max-height:80vh;background:linear-gradient(180deg,var(--panel-2),var(--panel));
427
+ -webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);
428
+ 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;}
429
+ .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);}
430
+ .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;}
431
+ .rp-modal-head .x{background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;
432
+ width:30px;height:30px;cursor:pointer;font-size:0.95rem;flex:none;}
433
+ .rp-modal-head .x:hover{color:var(--ink);border-color:var(--border-strong);}
434
+ .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);}
435
+ .rp-modal-body .kv{display:grid;grid-template-columns:160px 1fr;gap:0.4rem 0.8rem;margin-bottom:0.4rem;}
436
+ .rp-modal-body .kv .k{color:var(--muted);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.05em;}
437
+ .rp-modal-body .kv .v{color:var(--ink);word-break:break-word;white-space:pre-wrap;}
438
+ .rp-modal-body pre{background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;
439
+ font-size:0.66rem;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;color:var(--ink);}
440
+
441
+ /* ── TTS voice picker (used inside the generic modal) ── */
442
+ .rp-voice-picker{display:flex;flex-direction:column;gap:0.6rem;min-height:320px;}
443
+ .rp-voice-picker-toolbar{display:flex;gap:0.4rem;align-items:stretch;}
444
+ .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;}
445
+ .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);}
446
+ .rp-voice-picker-status{font-size:0.66rem;color:var(--muted);display:flex;align-items:center;gap:0.5rem;min-height:1.1em;}
447
+ .rp-voice-picker-status.busy{color:var(--gold-bright);}
448
+ .rp-voice-picker-status.bad{color:var(--bad);}
449
+ .rp-voice-picker-status.warn{color:var(--warn,#e0a23a);}
450
+ .rp-voice-picker-status .rp-mono{font-family:var(--mono);}
451
+ .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;}
452
+ @keyframes rp-vp-spin{to{transform:rotate(360deg);}}
453
+ .rp-voice-picker-results{display:flex;flex-direction:column;gap:0.7rem;max-height:55vh;overflow-y:auto;padding-right:0.25rem;}
454
+ .rp-voice-picker-empty{color:var(--muted);font-style:italic;padding:1rem;text-align:center;border:1px dashed var(--glass-border);border-radius:8px;}
455
+ .rp-vp-group{display:flex;flex-direction:column;gap:0.3rem;}
456
+ .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;}
457
+ .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;}
458
+ .rp-vp-item:hover{border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 6%,var(--panel-solid));}
459
+ .rp-vp-item:active{transform:translateY(1px);}
460
+ .rp-vp-item:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
461
+ .rp-vp-row1{display:flex;align-items:baseline;gap:0.5rem;justify-content:space-between;}
462
+ .rp-vp-name{font-size:0.78rem;font-weight:600;color:var(--ink);}
463
+ .rp-vp-meta{font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;flex:none;}
464
+ .rp-vp-id{font-size:0.62rem;color:var(--muted);margin-top:0.1rem;word-break:break-all;}
465
+ .rp-vp-desc{font-size:0.64rem;color:var(--muted);margin-top:0.2rem;line-height:1.4;}
466
+ .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);}
467
+ .rp-voice-picker-foot .rp-muted{color:var(--muted);}
468
+
469
+ .rp-fleet-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:0.7rem;padding:0;}
470
+ .rp-fleet-card{position:relative;}
471
+ .rp-fleet-card .spark{margin-top:0.5rem;height:36px;}
472
+ .rp-fleet-card .rp-actions{margin-top:0.6rem;}
473
+ .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);}
474
+ .rp-fleet-card .pulse .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);}
475
+ .rp-fleet-card .pulse.live .dot{background:var(--ok);box-shadow:0 0 8px var(--ok);animation:pulse 2.4s infinite;}
476
+ .rp-fleet-card .pulse.idle .dot{background:var(--muted);}
477
+ .rp-fleet-card .pulse.offline .dot{background:var(--bad);}
478
+ .rp-spark{width:100%;height:36px;display:block;opacity:0.92;}
479
+
480
+ .rp-setup-grid{display:grid;grid-template-columns:1fr 1fr;gap:0.9rem;}
481
+ @media (max-width:980px){.rp-setup-grid{grid-template-columns:1fr;}}
482
+
483
+ .rp-form .rp-form{margin:0.5rem 0 0;padding:0.6rem 0.75rem;}
484
+
485
+ .rp-input-grow{flex:1;min-width:140px;}
322
486
  </style>
323
487
  </head>
324
488
  <body>
@@ -395,120 +559,264 @@ button.act:disabled{opacity:0.5;cursor:default;}
395
559
  </div>
396
560
  </div>
397
561
 
398
- <!-- Add LiveKit server -->
399
- <div class="rp-form" id="rp-addserver">
400
- <h3>Add LiveKit server</h3>
401
- <div class="rp-quickrow">
402
- <div class="rp-field"><label>Name</label><input id="srv-name" placeholder="LiveKit Server 1"></div>
403
- <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">🔍 Auto-detect &amp; fill</button>
404
- <button class="rp-btn" id="srv-add" style="height:fit-content">Add server</button>
562
+ <!-- Sub-tab nav bar (sticky, just under main tabs) -->
563
+ <div class="rp-subtabs" id="rp-subtabs" role="tablist">
564
+ <button class="rp-subtab active" data-sub="overview" id="rpsbtn-overview" role="tab" aria-selected="true">Overview</button>
565
+ <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>
566
+ <button class="rp-subtab" data-sub="history" id="rpsbtn-history" role="tab" aria-selected="false">History</button>
567
+ <button class="rp-subtab" data-sub="audit" id="rpsbtn-audit" role="tab" aria-selected="false">Audit Log</button>
568
+ <button class="rp-subtab" data-sub="fleet" id="rpsbtn-fleet" role="tab" aria-selected="false">Fleet</button>
569
+ <button class="rp-subtab" data-sub="setup" id="rpsbtn-setup" role="tab" aria-selected="false">Setup</button>
570
+ </div>
571
+
572
+ <!-- ═══ Overview ═══ -->
573
+ <div class="rp-sub active" id="rp-sub-overview">
574
+ <div class="rp-kpi-grid" id="rp-overview-kpis"></div>
575
+ <div class="rp-mini-chart-wrap">
576
+ <h4>Fleet health · sessions (last 60 min) <span class="now" id="rp-overview-now">—</span></h4>
577
+ <canvas class="rp-mini-chart" id="rp-overview-chart" aria-label="Sessions over time"></canvas>
405
578
  </div>
406
- <div class="rp-autonote" id="srv-autonote"></div>
407
- <details class="rp-advanced">
408
- <summary>Advanced ID, URL, credentials, capacity</summary>
409
- <div class="rp-advanced-grid">
410
- <div class="rp-field"><label>ID</label><input id="srv-id" placeholder="auto-filled from name"></div>
411
- <div class="rp-field"><label>URL (ws:// or wss://)</label><input id="srv-url" placeholder="ws://host:7880 — auto-detect fills this"></div>
412
- <div class="rp-field"><label>API key</label><input id="srv-key" placeholder="devkey" value="devkey"></div>
413
- <div class="rp-field"><label>API secret</label><input type="password" id="srv-secret" placeholder="secretsecretsecretsecretsecretsecret"></div>
414
- <div class="rp-field"><label>Max sessions</label><input id="srv-max" type="number" value="8"></div>
415
- </div>
416
- </details>
579
+ <div class="rp-section-h">Top robots by activity <span class="meta" id="rp-overview-top-meta"></span></div>
580
+ <div class="rp-top3" id="rp-overview-top"></div>
581
+ <div class="rp-section-h">Recent activity <span class="meta" id="rp-overview-stream-meta">last 20</span></div>
582
+ <div class="rp-stream" id="rp-overview-stream"></div>
417
583
  </div>
418
584
 
419
- <!-- Add robot / device -->
420
- <div class="rp-form" id="rp-addrobot">
421
- <h3>Add robot (mint enrollment token)</h3>
422
- <div class="rp-quickrow">
423
- <div class="rp-field"><label>Robot name</label><input id="rb-name" placeholder="jaguar"></div>
424
- <div class="rp-field"><label>Character preset</label><select id="rb-char"><option value="">— auto (matched from name) —</option></select></div>
425
- <button class="rp-btn primary" id="rb-add" style="height:fit-content">Add robot</button>
585
+ <!-- ═══ Live Sessions ═══ -->
586
+ <div class="rp-sub" id="rp-sub-live">
587
+ <div class="rp-filterbar">
588
+ <div class="rp-field"><label>Search</label><input id="rp-live-search" placeholder="session id, robot…"></div>
589
+ <div class="rp-field"><label>Robot</label><select id="rp-live-robot"><option value="">all robots</option></select></div>
590
+ <label class="rp-btn" style="display:inline-flex;align-items:center;gap:0.35rem;cursor:pointer;">
591
+ <input type="checkbox" id="rp-live-autoref" checked style="width:auto;margin:0;accent-color:var(--gold-bright);"> auto-refresh
592
+ </label>
593
+ <button class="rp-btn" id="rp-live-refresh">↻ Refresh now</button>
426
594
  </div>
427
- <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>
428
- <div class="rp-quickrow" id="rb-clicmd-row" style="display:none;">
429
- <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>
430
- <button class="rp-btn" id="rb-clicmd-copy" style="height:fit-content">📋 Copy</button>
595
+ <div class="rp-tbl-wrap" id="rp-live-wrap">
596
+ <table class="rp-sess-table" id="rp-live-table">
597
+ <thead><tr>
598
+ <th>Robot</th><th>Session</th><th>Started</th><th>Duration</th><th>End reason</th><th>Actions</th>
599
+ </tr></thead>
600
+ <tbody id="rp-live-tbody"></tbody>
601
+ </table>
431
602
  </div>
432
- <details class="rp-advanced">
433
- <summary>Advanced — pre-set network/voice config before the robot connects</summary>
434
- <div class="rp-advanced-grid">
435
- <div class="rp-field"><label>Tailscale IP</label><input id="rb-tsip" placeholder="auto-detected once connected"></div>
436
- <div class="rp-field"><label>LAN IP</label><input id="rb-lanip" placeholder="auto-detected once connected"></div>
437
- <div class="rp-field"><label>Motor server URL</label><input id="rb-motor" placeholder="http://...:5000"></div>
438
- <div class="rp-field"><label>LiveKit URL</label><input id="rb-lk" placeholder=" uses the registered server above —"></div>
439
- <div class="rp-field"><label>Voice stack override</label><select id="rb-voice"><option value="">— from character preset —</option></select></div>
603
+ <div class="rp-tbl-empty" id="rp-live-empty" style="display:none;">No active sessions right now — sessions appear here the moment a robot joins a LiveKit room.</div>
604
+ </div>
605
+
606
+ <!-- ═══ History ═══ -->
607
+ <div class="rp-sub" id="rp-sub-history">
608
+ <div class="rp-filterbar">
609
+ <div class="rp-field"><label>Search</label><input id="rp-hist-search" placeholder="session id, robot…"></div>
610
+ <div class="rp-field"><label>Robot</label><select id="rp-hist-robot"><option value="">all robots</option></select></div>
611
+ <div class="rp-field"><label>End reason</label><select id="rp-hist-reason">
612
+ <option value="">any</option>
613
+ <option value="operator">operator</option>
614
+ <option value="silence">silence</option>
615
+ <option value="error">error</option>
616
+ <option value="timeout">timeout</option>
617
+ <option value="completed">completed</option>
618
+ <option value="unknown">unknown</option>
619
+ </select></div>
620
+ <div class="rp-field"><label>Range</label><select id="rp-hist-range">
621
+ <option value="24h">last 24h</option>
622
+ <option value="7d" selected>last 7 days</option>
623
+ <option value="30d">last 30 days</option>
624
+ <option value="all">all</option>
625
+ </select></div>
626
+ <button class="rp-btn" id="rp-hist-refresh">↻ Refresh</button>
627
+ </div>
628
+ <div class="rp-tbl-wrap" id="rp-hist-wrap">
629
+ <table class="rp-history-table" id="rp-hist-table">
630
+ <thead><tr>
631
+ <th>Session</th><th>Robot</th><th>Started</th><th>Ended</th><th>Duration</th><th>End reason</th><th>Triggers</th><th>Latency</th><th>Actions</th>
632
+ </tr></thead>
633
+ <tbody id="rp-hist-tbody"></tbody>
634
+ </table>
635
+ </div>
636
+ <div class="rp-tbl-empty" id="rp-hist-empty" style="display:none;">No sessions match the current filters.</div>
637
+ <div class="rp-tbl-pager" id="rp-hist-pager">
638
+ <span id="rp-hist-info">—</span>
639
+ <div>
640
+ <button class="rp-btn" id="rp-hist-prev">‹ Prev</button>
641
+ <button class="rp-btn" id="rp-hist-next">Next ›</button>
440
642
  </div>
441
- </details>
643
+ </div>
442
644
  </div>
443
645
 
444
- <div class="rp-grid" id="rp-grid"></div>
646
+ <!-- ═══ Audit Log ═══ -->
647
+ <div class="rp-sub" id="rp-sub-audit">
648
+ <div class="rp-filterbar" style="gap:0.4rem;">
649
+ <span class="rp-cat-chip active" data-cat="" id="rp-audit-cat-all">All</span>
650
+ <span class="rp-cat-chip" data-cat="robot" id="rp-audit-cat-robot">🤖 Robot</span>
651
+ <span class="rp-cat-chip" data-cat="device" id="rp-audit-cat-device">📡 Device</span>
652
+ <span class="rp-cat-chip" data-cat="session" id="rp-audit-cat-session">🎙 Session</span>
653
+ <span class="rp-cat-chip" data-cat="server" id="rp-audit-cat-server">🖥 Server</span>
654
+ <span class="rp-cat-chip" data-cat="voice" id="rp-audit-cat-voice">🗣 Voice</span>
655
+ <span class="rp-cat-chip" data-cat="character" id="rp-audit-cat-character">🎭 Character</span>
656
+ <div style="flex:1;"></div>
657
+ <button class="rp-btn" id="rp-audit-refresh">↻ Refresh</button>
658
+ </div>
659
+ <div class="rp-tbl-wrap" id="rp-audit-wrap">
660
+ <table class="rp-audit-table" id="rp-audit-table">
661
+ <thead><tr>
662
+ <th>Time</th><th>Category</th><th>Action</th><th>Target</th><th>Details</th><th>Source</th>
663
+ </tr></thead>
664
+ <tbody id="rp-audit-tbody"></tbody>
665
+ </table>
666
+ </div>
667
+ <div class="rp-tbl-empty" id="rp-audit-empty" style="display:none;">No audit events match the current filter.</div>
668
+ </div>
445
669
 
446
- <!-- Config templates -->
447
- <div class="rp-form" id="rp-templates" style="grid-template-columns:1fr 1fr; margin-top:1.2rem;">
448
- <h3 style="grid-column:1/-1">Config templates</h3>
449
- <div>
450
- <h4 style="font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;">Voice stacks</h4>
451
- <div id="rp-voice-list" style="display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;"></div>
452
- <div style="display:flex;gap:0.4rem;margin-top:0.55rem;">
453
- <input id="rp-newvoice-name" placeholder="new stack name" style="flex:1;min-width:0;">
454
- <button class="rp-btn" id="rp-addvoice">Add</button>
670
+ <!-- ═══ Fleet ═══ -->
671
+ <div class="rp-sub" id="rp-sub-fleet">
672
+ <div class="rp-section-h">Robots <span class="meta" id="rp-fleet-meta">live + idle + offline</span></div>
673
+ <div class="rp-fleet-grid" id="rp-grid"></div>
674
+ </div>
675
+
676
+ <!-- ═══ Setup ═══ -->
677
+ <div class="rp-sub" id="rp-sub-setup">
678
+ <!-- Add LiveKit server -->
679
+ <div class="rp-form" id="rp-addserver">
680
+ <h3>Add LiveKit server</h3>
681
+ <div class="rp-quickrow">
682
+ <div class="rp-field"><label>Name</label><input id="srv-name" placeholder="LiveKit Server 1"></div>
683
+ <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">🔍 Auto-detect &amp; fill</button>
684
+ <button class="rp-btn" id="srv-add" style="height:fit-content">Add server</button>
455
685
  </div>
456
- <div class="rp-form" id="rp-voice-edit" style="display:none;grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
457
- <h4 style="grid-column:1/-1;margin:0 0 0.2rem;">Edit voice stack: <span id="rpve-name-label"></span></h4>
458
- <div class="rp-field"><label>STT provider</label><select id="rpve-stt-provider"><option value="speaches">speaches</option><option value="groq">groq</option><option value="vibevoice">vibevoice</option></select></div>
459
- <div class="rp-field"><label>STT model</label><input id="rpve-stt-model"></div>
460
- <div class="rp-field"><label>STT language</label><input id="rpve-stt-language" placeholder="en / auto"></div>
461
- <div class="rp-field"><label>LLM provider</label><select id="rpve-llm-provider"><option value="ollama">ollama</option><option value="groq">groq</option><option value="gemini">gemini</option><option value="moltbot">moltbot</option></select></div>
462
- <div class="rp-field"><label>LLM model</label><input id="rpve-llm-model"></div>
463
- <div class="rp-field"><label>LLM API key</label><input type="password" id="rpve-llm-key" placeholder="(uses agent's own if blank)"></div>
464
- <div class="rp-field"><label>Temperature</label><input id="rpve-temperature" type="number" step="0.1" value="0.7"></div>
465
- <div class="rp-field"><label>Context size (num_ctx)</label><input id="rpve-num-ctx" type="number" value="8192"></div>
466
- <div class="rp-field"><label>TTS provider</label><select id="rpve-tts-provider"><option value="elevenlabs">elevenlabs</option><option value="kokoro">kokoro</option><option value="piper">piper</option></select></div>
467
- <div class="rp-field"><label>TTS voice</label><input id="rpve-tts-voice"></div>
468
- <div class="rp-field"><label>TTS language</label><input id="rpve-tts-language" placeholder="en"></div>
469
- <div class="rp-field"><label>Max turns</label><input id="rpve-max-turns" type="number" value="20"></div>
470
- <div class="rp-field"><label>Min endpointing delay (s)</label><input id="rpve-endpointing" type="number" step="0.1" value="0.7"></div>
471
- <div class="rp-field"><label><input type="checkbox" id="rpve-interruptions" checked style="width:auto;margin-right:0.35rem;">Allow interruptions</label></div>
472
- <div class="rp-field"><label><input type="checkbox" id="rpve-wake-enabled" style="width:auto;margin-right:0.35rem;">Wake word enabled</label></div>
473
- <div class="rp-field"><label>Wake word model</label><input id="rpve-wake-model" placeholder="models/hey_jarvis.onnx"></div>
474
- <div class="rp-field"><label>Wake threshold</label><input id="rpve-wake-threshold" type="number" step="0.05" value="0.5"></div>
475
- <div class="rp-field"><label>Wake timeout (s)</label><input id="rpve-wake-timeout" type="number" step="0.5" value="3.0"></div>
476
- <div style="grid-column:1/-1;display:flex;gap:0.4rem;">
477
- <button class="rp-btn" id="rpve-save">Save</button>
478
- <button class="rp-btn" id="rpve-cancel">Cancel</button>
686
+ <div class="rp-autonote" id="srv-autonote"></div>
687
+ <details class="rp-advanced">
688
+ <summary>Advanced ID, URL, credentials, capacity</summary>
689
+ <div class="rp-advanced-grid">
690
+ <div class="rp-field"><label>ID</label><input id="srv-id" placeholder="auto-filled from name"></div>
691
+ <div class="rp-field"><label>URL (ws:// or wss://)</label><input id="srv-url" placeholder="ws://host:7880 auto-detect fills this"></div>
692
+ <div class="rp-field"><label>API key</label><input id="srv-key" placeholder="devkey" value="devkey"></div>
693
+ <div class="rp-field"><label>API secret</label><input type="password" id="srv-secret" placeholder="secretsecretsecretsecretsecretsecret"></div>
694
+ <div class="rp-field"><label>Max sessions</label><input id="srv-max" type="number" value="8"></div>
479
695
  </div>
696
+ </details>
697
+ </div>
698
+
699
+ <!-- Add robot / device -->
700
+ <div class="rp-form" id="rp-addrobot">
701
+ <h3>Add robot (mint enrollment token)</h3>
702
+ <div class="rp-quickrow">
703
+ <div class="rp-field"><label>Robot name</label><input id="rb-name" placeholder="jaguar"></div>
704
+ <div class="rp-field"><label>Character preset</label><select id="rb-char"><option value="">— auto (matched from name) —</option></select></div>
705
+ <button class="rp-btn primary" id="rb-add" style="height:fit-content">Add robot</button>
706
+ </div>
707
+ <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>
708
+ <div class="rp-quickrow" id="rb-clicmd-row" style="display:none;">
709
+ <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>
710
+ <button class="rp-btn" id="rb-clicmd-copy" style="height:fit-content">📋 Copy</button>
480
711
  </div>
712
+ <details class="rp-advanced">
713
+ <summary>Advanced — pre-set network/voice config before the robot connects</summary>
714
+ <div class="rp-advanced-grid">
715
+ <div class="rp-field"><label>Tailscale IP</label><input id="rb-tsip" placeholder="auto-detected once connected"></div>
716
+ <div class="rp-field"><label>LAN IP</label><input id="rb-lanip" placeholder="auto-detected once connected"></div>
717
+ <div class="rp-field"><label>Motor server URL</label><input id="rb-motor" placeholder="http://...:5000"></div>
718
+ <div class="rp-field"><label>LiveKit URL</label><input id="rb-lk" placeholder="— uses the registered server above —"></div>
719
+ <div class="rp-field"><label>Voice stack override</label><select id="rb-voice"><option value="">— from character preset —</option></select></div>
720
+ </div>
721
+ </details>
481
722
  </div>
482
- <div>
483
- <h4 style="font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;">Character presets</h4>
484
- <div id="rp-char-list" style="display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;"></div>
485
- <div style="display:flex;gap:0.4rem;margin-top:0.55rem;">
486
- <input id="rp-newchar-name" placeholder="new preset name" style="flex:1;min-width:0;">
487
- <input id="rp-newchar-stack" list="rp-newchar-stack-list" placeholder="voice stack id" style="flex:1;min-width:0;">
488
- <datalist id="rp-newchar-stack-list"></datalist>
489
- <button class="rp-btn" id="rp-addchar">Add</button>
723
+
724
+ <!-- Local runtime / Docker manager -->
725
+ <div class="rp-form" id="rp-runtime" style="grid-template-columns:1fr;">
726
+ <h3>Runtime health · scheduler · LiveKit · Docker</h3>
727
+ <div class="rp-quickrow">
728
+ <div class="rp-autonote" id="rp-runtime-summary" style="flex:1;margin:0;">not checked</div>
729
+ <button class="rp-btn" id="rp-runtime-refresh">↻ Refresh health</button>
490
730
  </div>
491
- <div class="rp-form" id="rp-char-edit" style="display:none;grid-template-columns:1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
492
- <h4 style="margin:0 0 0.2rem;">Edit character: <span id="rpce-name-label"></span></h4>
493
- <div class="rp-field"><label>Name</label><input id="rpce-name"></div>
494
- <div class="rp-field"><label>Description</label><input id="rpce-description"></div>
495
- <div class="rp-field"><label>System prompt</label><textarea id="rpce-prompt" rows="4" style="font-family:var(--mono);font-size:0.72rem;width:100%;resize:vertical;"></textarea></div>
496
- <div class="rp-field"><label>Voice stack</label><select id="rpce-stack"></select></div>
497
- <div class="rp-field"><label>Motors (comma-separated)</label><input id="rpce-motors" placeholder="drive, wave"></div>
498
- <div style="display:flex;gap:0.4rem;">
499
- <div class="rp-field" style="flex:1"><label>Pad X (0-1)</label><input id="rpce-nx" type="number" step="0.01" min="0" max="1"></div>
500
- <div class="rp-field" style="flex:1"><label>Pad Y (0-1)</label><input id="rpce-ny" type="number" step="0.01" min="0" max="1"></div>
731
+ <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>
732
+ <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>
733
+ </div>
734
+
735
+ <!-- Config templates -->
736
+ <div class="rp-form" id="rp-templates" style="grid-template-columns:1fr 1fr; margin-top:1.2rem;">
737
+ <h3 style="grid-column:1/-1">Config templates</h3>
738
+ <div>
739
+ <h4 style="font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;">Voice stacks</h4>
740
+ <div id="rp-voice-list" style="display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;"></div>
741
+ <div style="display:flex;gap:0.4rem;margin-top:0.55rem;">
742
+ <input id="rp-newvoice-name" placeholder="new stack name" style="flex:1;min-width:0;">
743
+ <button class="rp-btn" id="rp-addvoice">Add</button>
501
744
  </div>
502
- <div class="rp-field"><label>Avatar image path</label><input id="rpce-img" placeholder="/static/robopark/jaguar.png"></div>
503
- <div style="display:flex;gap:0.4rem;">
504
- <button class="rp-btn" id="rpce-save">Save</button>
505
- <button class="rp-btn" id="rpce-cancel">Cancel</button>
745
+ <div class="rp-form" id="rp-voice-edit" style="display:none;grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
746
+ <h4 style="grid-column:1/-1;margin:0 0 0.2rem;">Edit voice stack: <span id="rpve-name-label"></span></h4>
747
+ <div class="rp-field"><label>STT provider</label><select id="rpve-stt-provider"><option value="speaches">speaches</option><option value="groq">groq</option><option value="vibevoice">vibevoice</option></select></div>
748
+ <div class="rp-field"><label>STT model</label><input id="rpve-stt-model"></div>
749
+ <div class="rp-field"><label>STT language</label><input id="rpve-stt-language" placeholder="en / auto"></div>
750
+ <div class="rp-field"><label>LLM provider</label><select id="rpve-llm-provider"><option value="ollama">ollama</option><option value="groq">groq</option><option value="gemini">gemini</option><option value="moltbot">moltbot</option></select></div>
751
+ <div class="rp-field"><label>LLM model</label><input id="rpve-llm-model"></div>
752
+ <div class="rp-field"><label>LLM API key</label><input type="password" id="rpve-llm-key" placeholder="(uses agent's own if blank)"></div>
753
+ <div class="rp-field"><label>Temperature</label><input id="rpve-temperature" type="number" step="0.1" value="0.7"></div>
754
+ <div class="rp-field"><label>Context size (num_ctx)</label><input id="rpve-num-ctx" type="number" value="8192"></div>
755
+ <div class="rp-field"><label>TTS provider</label><select id="rpve-tts-provider"><option value="elevenlabs">elevenlabs</option><option value="kokoro">kokoro</option><option value="piper">piper</option></select></div>
756
+ <div class="rp-field">
757
+ <label>TTS voice</label>
758
+ <div style="display:flex;gap:0.35rem;align-items:stretch;">
759
+ <select id="rpve-tts-voice" style="flex:1;min-width:0;"></select>
760
+ <button type="button" class="rp-btn" id="rpve-tts-voice-browse" title="Browse the full voice catalog for the selected provider" style="white-space:nowrap;">Browse…</button>
761
+ </div>
762
+ <div class="rp-autonote" id="rpve-tts-voice-note" style="margin-top:0.35rem;"></div>
763
+ </div>
764
+ <div class="rp-field"><label>TTS language</label><input id="rpve-tts-language" placeholder="en"></div>
765
+ <div class="rp-field"><label>Max turns</label><input id="rpve-max-turns" type="number" value="20"></div>
766
+ <div class="rp-field"><label>Min endpointing delay (s)</label><input id="rpve-endpointing" type="number" step="0.1" value="0.7"></div>
767
+ <div class="rp-field"><label><input type="checkbox" id="rpve-interruptions" checked style="width:auto;margin-right:0.35rem;">Allow interruptions</label></div>
768
+ <div class="rp-field"><label><input type="checkbox" id="rpve-wake-enabled" style="width:auto;margin-right:0.35rem;">Wake word enabled</label></div>
769
+ <div class="rp-field"><label>Wake word model</label><input id="rpve-wake-model" placeholder="models/hey_jarvis.onnx"></div>
770
+ <div class="rp-field"><label>Wake threshold</label><input id="rpve-wake-threshold" type="number" step="0.05" value="0.5"></div>
771
+ <div class="rp-field"><label>Wake timeout (s)</label><input id="rpve-wake-timeout" type="number" step="0.5" value="3.0"></div>
772
+ <div style="grid-column:1/-1;display:flex;gap:0.4rem;">
773
+ <button class="rp-btn" id="rpve-save">Save</button>
774
+ <button class="rp-btn" id="rpve-cancel">Cancel</button>
775
+ </div>
776
+ </div>
777
+ </div>
778
+ <div>
779
+ <h4 style="font-family:var(--mono);font-size:0.75rem;color:var(--gold-bright);margin:0.3rem 0 0.5rem;">Character presets</h4>
780
+ <div id="rp-char-list" style="display:flex;flex-direction:column;gap:0.4rem;max-height:260px;overflow:auto;"></div>
781
+ <div style="display:flex;gap:0.4rem;margin-top:0.55rem;">
782
+ <input id="rp-newchar-name" placeholder="new preset name" style="flex:1;min-width:0;">
783
+ <input id="rp-newchar-stack" list="rp-newchar-stack-list" placeholder="voice stack id" style="flex:1;min-width:0;">
784
+ <datalist id="rp-newchar-stack-list"></datalist>
785
+ <button class="rp-btn" id="rp-addchar">Add</button>
786
+ </div>
787
+ <div class="rp-form" id="rp-char-edit" style="display:none;grid-template-columns:1fr;gap:0.4rem;margin-top:0.6rem;padding:0.6rem;border-color:var(--border-strong);">
788
+ <h4 style="margin:0 0 0.2rem;">Edit character: <span id="rpce-name-label"></span></h4>
789
+ <div class="rp-field"><label>Name</label><input id="rpce-name"></div>
790
+ <div class="rp-field"><label>Description</label><input id="rpce-description"></div>
791
+ <div class="rp-field"><label>System prompt</label><textarea id="rpce-prompt" rows="4" style="font-family:var(--mono);font-size:0.72rem;width:100%;resize:vertical;"></textarea></div>
792
+ <div class="rp-field"><label>Voice stack</label><select id="rpce-stack"></select></div>
793
+ <div class="rp-field"><label>Motors (comma-separated)</label><input id="rpce-motors" placeholder="drive, wave"></div>
794
+ <div style="display:flex;gap:0.4rem;">
795
+ <div class="rp-field" style="flex:1"><label>Pad X (0-1)</label><input id="rpce-nx" type="number" step="0.01" min="0" max="1"></div>
796
+ <div class="rp-field" style="flex:1"><label>Pad Y (0-1)</label><input id="rpce-ny" type="number" step="0.01" min="0" max="1"></div>
797
+ </div>
798
+ <div class="rp-field"><label>Avatar image path</label><input id="rpce-img" placeholder="/static/robopark/jaguar.png"></div>
799
+ <div style="display:flex;gap:0.4rem;">
800
+ <button class="rp-btn" id="rpce-save">Save</button>
801
+ <button class="rp-btn" id="rpce-cancel">Cancel</button>
802
+ </div>
506
803
  </div>
507
804
  </div>
508
805
  </div>
509
806
  </div>
510
807
  </div>
511
808
 
809
+ <!-- Generic modal: title + scrollable body, used for session + audit detail views -->
810
+ <div class="rp-modal-back" id="rp-modal-back" role="dialog" aria-modal="true">
811
+ <div class="rp-modal" id="rp-modal">
812
+ <div class="rp-modal-head">
813
+ <h3 id="rp-modal-title">Details</h3>
814
+ <button class="x" id="rp-modal-close" aria-label="Close">✕</button>
815
+ </div>
816
+ <div class="rp-modal-body" id="rp-modal-body"></div>
817
+ </div>
818
+ </div>
819
+
512
820
  <footer class="cmd">
513
821
  <span class="sel">on <b id="selName">this node</b></span>
514
822
  <input id="cmdInput" placeholder="/status /nodes /health (Enter to run)" autocomplete="off" spellcheck="false">
@@ -526,12 +834,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
526
834
  var rpReadOnly = false, pmBusy = false;
527
835
  var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
528
836
  var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
837
+ var schedulerDevicesById = {}; // enrolled device rows + camera/audio inventory
838
+ var schedulerDevicesByName = {}; // same rows, keyed by device name (id != name, e.g. dev_8984a80e vs jaguar2)
529
839
  var voiceStacks = []; // scheduler voice stacks (shared with setup forms + templates)
530
840
  var FALLBACK_SLOTS = [
531
841
  {label:'VOLT', id:'volt', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},
532
842
  {label:'TESLA', id:'tesla', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},
533
843
  {label:'JAGUAR', id:'jaguar', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},
534
- {label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.54, ny:0.66, sub:'BMW', img:'/static/robopark/bmw.png'},
844
+ {label:'VIXEN', id:'vixen', keys:['vixen','bmw'], nx:0.40, ny:0.68, sub:'BMW', img:'/static/robopark/bmw.png'},
535
845
  {label:'PANDA', id:'panda', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}
536
846
  ];
537
847
  var parkSlots = FALLBACK_SLOTS.slice();
@@ -771,20 +1081,75 @@ button.act:disabled{opacity:0.5;cursor:default;}
771
1081
  $('offline').style.display = (!ok && everOk) ? 'block' : 'none';
772
1082
  }
773
1083
 
1084
+ // Scheduler-enrolled robots are not necessarily infinicode mesh peers. Add
1085
+ // them to the same Fleet/Park collection so a robot running only the
1086
+ // preview agent is still visible and selectable in the full dashboard.
1087
+ function getJson(path){
1088
+ // QS is "?token=..."; a path that already carries its own query string
1089
+ // (e.g. "/robopark/api/sessions?active_only=false&limit=200") needs "&"
1090
+ // instead, or the token param lands inside an invalid second "?" and
1091
+ // the request goes out unauthenticated -> 401.
1092
+ var url = path + (path.indexOf('?')>=0 ? QS.replace('?','&') : QS);
1093
+ return fetch(url, {cache:'no-store'}).then(function(r){
1094
+ if(!r.ok) throw new Error(path+' '+r.status);
1095
+ return r.json();
1096
+ });
1097
+ }
1098
+ function mergeSchedulerRobots(nodes){
1099
+ return Promise.all([getJson('/robopark/api/robots'), getJson('/robopark/api/devices')]).then(function(data){
1100
+ var rows = data[0], devices = data[1];
1101
+ var deviceById = {}, deviceByName = {};
1102
+ (Array.isArray(devices)?devices:[]).forEach(function(device){ deviceById[device.id] = device; if(device.name) deviceByName[device.name] = device; });
1103
+ schedulerDevicesById = deviceById;
1104
+ schedulerDevicesByName = deviceByName;
1105
+ var merged = (nodes||[]).slice();
1106
+ (Array.isArray(rows)?rows:[]).forEach(function(row){
1107
+ var id = row.id || row.robot_id;
1108
+ if(!id) return;
1109
+ var name = row.name || id;
1110
+ var duplicate = merged.some(function(n){
1111
+ return n.schedulerRobotId===id || n.displayName===name || n.nodeId===id;
1112
+ });
1113
+ if(duplicate) return;
1114
+ var device = deviceById[id] || {};
1115
+ var status = String(device.status || row.status || '').toLowerCase();
1116
+ var connected = ['online','detecting','connecting','running'].indexOf(status)>=0;
1117
+ var keys = [String(id).toLowerCase(), String(name).toLowerCase()];
1118
+ merged.push({
1119
+ nodeId: 'scheduler-robot-'+id,
1120
+ schedulerRobotId: id,
1121
+ displayName: name,
1122
+ role: 'robot',
1123
+ platform: 'RoboPark robot',
1124
+ version: 'scheduler',
1125
+ capabilities: ['camera','microphone','voice'],
1126
+ connected: connected,
1127
+ load: 0,
1128
+ lastSeenAt: device.last_heartbeat ? Date.parse(device.last_heartbeat) : (row.last_heartbeat ? Date.parse(row.last_heartbeat) : null),
1129
+ telemKeys: keys,
1130
+ });
1131
+ });
1132
+ return merged;
1133
+ }).catch(function(){ return nodes||[]; });
1134
+ }
1135
+
774
1136
  function poll(){
775
1137
  fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){
776
1138
  if(!r.ok) throw new Error('status '+r.status);
777
1139
  return r.json();
778
1140
  }).then(function(s){
779
- everOk = true; setLive(true); lastStatus = s;
780
- if(!selfId && s.self) selfId = s.self.nodeId;
781
- if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
782
- updateHw(s);
783
- renderSummary(s);
784
- renderFleet(s.nodes||[]);
785
- renderHw(s.hardware);
786
- renderFeed(s.activity||[]);
787
- Park.onStatus(s);
1141
+ return mergeSchedulerRobots(s.nodes||[]).then(function(nodes){
1142
+ s.nodes = nodes;
1143
+ everOk = true; setLive(true); lastStatus = s;
1144
+ if(!selfId && s.self) selfId = s.self.nodeId;
1145
+ if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
1146
+ updateHw(s);
1147
+ renderSummary(s);
1148
+ renderFleet(s.nodes||[]);
1149
+ renderHw(s.hardware);
1150
+ renderFeed(s.activity||[]);
1151
+ Park.onStatus(s);
1152
+ });
788
1153
  }).catch(function(){ setLive(false); });
789
1154
  }
790
1155
 
@@ -839,7 +1204,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
839
1204
  $('pane-park').classList.toggle('active', name==='park');
840
1205
  $('pane-robopark').classList.toggle('active', name==='robopark');
841
1206
  if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
842
- if (name==='robopark') pollRobopark();
1207
+ if (name==='robopark'){
1208
+ pollRobopark();
1209
+ initRpSubTabs();
1210
+ }
843
1211
  if (name==='park') Park.activate(); else Park.deactivate();
844
1212
  }
845
1213
  var initHash = location.hash.replace('#','');
@@ -909,11 +1277,41 @@ button.act:disabled{opacity:0.5;cursor:default;}
909
1277
  showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();
910
1278
  }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });
911
1279
  }
1280
+
1281
+ function runtimeDockerAction(action, container){
1282
+ if(!container) return;
1283
+ if(!confirm(action+' '+container+'?')) return;
1284
+ 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); });
1285
+ }
1286
+ function pollRuntimeHealth(){
1287
+ var summary=$('rp-runtime-summary'), tbody=$('rp-runtime-containers'); if(!summary||!tbody) return;
1288
+ getJson('/robopark/runtime/health').then(function(h){
1289
+ var docker=h.docker||{}, scheduler=h.scheduler||{};
1290
+ var containers=Array.isArray(h.containers&&h.containers.data)?h.containers.data:[];
1291
+ summary.textContent=(docker.ok?'Docker connected':'Docker unavailable')+' · '+(scheduler.ok?'scheduler reachable':'scheduler unavailable')+' · '+containers.filter(function(c){return /^(up|running)/i.test(String(c.State||''));}).length+'/'+containers.length+' containers running';
1292
+ summary.style.color=(docker.ok&&scheduler.ok)?'var(--ok)':'var(--bad)';
1293
+ tbody.innerHTML=containers.length?containers.map(function(c){
1294
+ var name=c.Names||c.Name||'container', state=c.State||'unknown', running=/^(up|running)/i.test(state);
1295
+ return '<tr><td class="mono">'+esc(name)+'</td><td>'+esc(state)+'</td><td class="mono">'+esc(c.Image||'—')+'</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>';
1296
+ }).join(''):'<tr><td colspan="4">No Docker containers found.</td></tr>';
1297
+ if(!docker.ok) $('rp-runtime-note').textContent='Docker unavailable: '+(docker.error||'check Docker Desktop');
1298
+ }).catch(function(e){ summary.textContent='runtime health unavailable'; summary.style.color='var(--bad)'; });
1299
+ }
1300
+ var runtimeRefresh=$('rp-runtime-refresh'); if(runtimeRefresh) runtimeRefresh.addEventListener('click', pollRuntimeHealth);
1301
+ 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); });
1302
+ function recoverRobot(id){
1303
+ if(!confirm('Recover '+id+'? This closes any stale LiveKit session and returns the robot to idle.')) return;
1304
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/recover',{}).then(function(res){
1305
+ showOut('recovered '+id+' — queued triggers cleared and robot returned to idle');
1306
+ pollRobopark(); Park.refreshDrawer();
1307
+ }).catch(function(e){ showOut('recover ('+id+'): '+e.message); });
1308
+ }
912
1309
  $('rp-grid').addEventListener('click', function(e){
913
1310
  var b=e.target.closest?e.target.closest('.rp-btn'):null;
914
1311
  if(b){ var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');
915
1312
  if(act==='end') endSession(id);
916
1313
  if(act==='trigger') triggerSession(id);
1314
+ if(act==='recover') recoverRobot(id);
917
1315
  if(act==='preview'){ selectTab('park'); Park.openDrawerForRobot(id); }
918
1316
  return;
919
1317
  }
@@ -932,10 +1330,115 @@ button.act:disabled{opacity:0.5;cursor:default;}
932
1330
 
933
1331
  var characterPresets=[];
934
1332
  function loadCharacterPresets(){
935
- fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); }).catch(function(){});
1333
+ 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(); }).catch(function(){});
936
1334
  }
937
1335
  function loadVoiceStacks(){
938
- fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); }).catch(function(){});
1336
+ 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(); }).catch(function(){});
1337
+ }
1338
+
1339
+ // ── TTS voice catalog (per provider) ──
1340
+ // Holds the dropdown options for whichever TTS provider is currently
1341
+ // selected in the voice-stack editor. We cache the catalog per-provider
1342
+ // in ttsVoiceCache so flipping back and forth between kokoro/elevenlabs
1343
+ // is instant. Each entry is { provider, source, count, voices, warning }.
1344
+ var ttsVoiceCache = {}; // provider -> TtsVoiceList payload
1345
+ var ttsVoiceLoading = {}; // provider -> in-flight Promise (de-dupes concurrent loads)
1346
+ function loadTtsVoiceCatalog(provider, opts){
1347
+ if(!provider) return Promise.resolve(null);
1348
+ opts = opts || {};
1349
+ var force = !!opts.force;
1350
+ if(ttsVoiceCache[provider] && !force) return Promise.resolve(ttsVoiceCache[provider]);
1351
+ if(ttsVoiceLoading[provider] && !force) return ttsVoiceLoading[provider];
1352
+ var p = fetch('/robopark/api/voices/'+encodeURIComponent(provider)+QS,{cache:'no-store'})
1353
+ .then(function(r){ return r.json(); })
1354
+ .then(function(d){
1355
+ ttsVoiceCache[provider] = d || {provider:provider, source:'static', count:0, voices:[], warning:'Empty response'};
1356
+ return ttsVoiceCache[provider];
1357
+ })
1358
+ .catch(function(e){
1359
+ var fallback = {provider:provider, source:'static', count:0, voices:[], warning:'Could not reach scheduler: '+(e&&e.message||e)};
1360
+ ttsVoiceCache[provider] = fallback;
1361
+ return fallback;
1362
+ })
1363
+ .then(function(v){ ttsVoiceLoading[provider]=null; return v; });
1364
+ ttsVoiceLoading[provider] = p;
1365
+ return p;
1366
+ }
1367
+ // Build the option list for the inline TTS-voice <select>. The current
1368
+ // value (s.tts_voice) is always included as a synthetic option if it
1369
+ // isn't in the catalog — that way editing a stack that was saved with a
1370
+ // legacy / unknown voice still shows what the user actually had.
1371
+ function fillTtsVoiceSelect(provider, currentVoiceId){
1372
+ var sel = $('rpve-tts-voice');
1373
+ var note = $('rpve-tts-voice-note');
1374
+ if(!sel) return;
1375
+ sel.innerHTML = '';
1376
+ var cache = ttsVoiceCache[provider];
1377
+ if(!cache){
1378
+ sel.innerHTML = '<option value="">— loading voices… —</option>';
1379
+ if(note){ note.className='rp-autonote busy'; note.textContent = 'Loading '+provider+' voice catalog…'; }
1380
+ return;
1381
+ }
1382
+ var voices = Array.isArray(cache.voices) ? cache.voices : [];
1383
+ if(!voices.length){
1384
+ var ph = currentVoiceId
1385
+ ? '<option value="'+esc(currentVoiceId)+'" selected>'+esc(currentVoiceId)+' (current)</option>'
1386
+ : '<option value="">— no voices available —</option>';
1387
+ sel.innerHTML = ph;
1388
+ } else {
1389
+ var html = '';
1390
+ var seen = {};
1391
+ for(var i=0;i<voices.length;i++){
1392
+ var v = voices[i];
1393
+ var id = v.id || v.voice_id || '';
1394
+ if(!id || seen[id]) continue;
1395
+ seen[id] = 1;
1396
+ var label = v.name || id;
1397
+ var subbits = [];
1398
+ if(v.language) subbits.push(v.language);
1399
+ if(v.gender) subbits.push(v.gender);
1400
+ if(v.category) subbits.push(v.category);
1401
+ if(subbits.length) label = label + ' · ' + subbits.join(' · ');
1402
+ html += '<option value="'+esc(id)+'"'+(id===currentVoiceId?' selected':'')+'>'+esc(label)+'</option>';
1403
+ }
1404
+ // Preserve an out-of-catalog current value so the user can still see
1405
+ // what was previously saved.
1406
+ if(currentVoiceId && !seen[currentVoiceId]){
1407
+ html += '<option value="'+esc(currentVoiceId)+'" selected>'+esc(currentVoiceId)+' (saved value, not in catalog)</option>';
1408
+ }
1409
+ sel.innerHTML = html;
1410
+ }
1411
+ // status note (source + warning)
1412
+ if(note){
1413
+ var src = cache.source || 'static';
1414
+ var cnt = cache.count != null ? cache.count : voices.length;
1415
+ if(cache.warning){
1416
+ note.className='rp-autonote bad';
1417
+ note.textContent = '⚠ '+cache.warning+(cnt?(' — showing '+cnt+' voices.'):'');
1418
+ } else {
1419
+ note.className='rp-autonote';
1420
+ note.textContent = cnt+' '+provider+' voices · source: '+src+(currentVoiceId?(' · current: '+currentVoiceId):'');
1421
+ }
1422
+ }
1423
+ }
1424
+ function refreshTtsVoiceSelect(force){
1425
+ var provEl = $('rpve-tts-provider');
1426
+ var sel = $('rpve-tts-voice');
1427
+ if(!provEl || !sel) return;
1428
+ var provider = provEl.value || 'elevenlabs';
1429
+ var cur = sel.value || (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].value) || '';
1430
+ // Prefer the live value from the input field if the user has been
1431
+ // editing it. openVoiceStackEdit sets it before this runs, so .value
1432
+ // is the saved voice. The above cur is only used as a fallback.
1433
+ var savedVoice = (sel && sel.__savedVoice) || cur;
1434
+ if(force || !ttsVoiceCache[provider]){
1435
+ fillTtsVoiceSelect(provider, savedVoice);
1436
+ loadTtsVoiceCatalog(provider, {force:!!force}).then(function(){
1437
+ fillTtsVoiceSelect(provider, savedVoice);
1438
+ });
1439
+ } else {
1440
+ fillTtsVoiceSelect(provider, savedVoice);
1441
+ }
939
1442
  }
940
1443
  function loadParkSlots(){
941
1444
  fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
@@ -946,7 +1449,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
946
1449
  if(!id) continue;
947
1450
  mapped.push({
948
1451
  label:p.name||id.toUpperCase(), id:id,
949
- keys:[id].concat((p.keys||[])).concat((p.name||'').toLowerCase().split(/\s+/)).filter(function(x){return !!x;}),
1452
+ keys:[id].concat((p.keys||[])).concat((p.name||'').toLowerCase().split(/\\s+/)).filter(function(x){return !!x;}),
950
1453
  nx:(p.nx!=null?p.nx:0.5), ny:(p.ny!=null?p.ny:0.5),
951
1454
  sub:p.sub||'', img:p.img||''
952
1455
  });
@@ -976,6 +1479,45 @@ button.act:disabled{opacity:0.5;cursor:default;}
976
1479
  }
977
1480
  }
978
1481
  }
1482
+ // Rebuild the option list inside the currently-open Park robot drawer so
1483
+ // preset/voice dropdowns populate even if the data finishes loading AFTER
1484
+ // the drawer was opened. Idempotent: no-op if the drawer is closed.
1485
+ function refreshDrawerPresetOptions(){
1486
+ var b=$('pk-body'); if(!b) return;
1487
+ var setupChar=$('pk-setup-char');
1488
+ if(setupChar){
1489
+ var curSetup=setupChar.value;
1490
+ setupChar.innerHTML='<option value="">— auto —</option>'+characterPresets.map(function(p){
1491
+ var id=p.id||p.preset_id||p.character_id||'';
1492
+ var name=(p.name||p.id||p.preset_id||'preset')+(p.description?(' — '+p.description):'');
1493
+ return '<option value="'+esc(id)+'"'+(id===curSetup?' selected':'')+'>'+esc(name)+'</option>';
1494
+ }).join('');
1495
+ }
1496
+ var setupVoice=$('pk-setup-voice');
1497
+ if(setupVoice){
1498
+ var curVoice=setupVoice.value;
1499
+ setupVoice.innerHTML='<option value="">— from character preset —</option>'+voiceStacks.map(function(s){
1500
+ var id=s.id||''; var name=(s.name||s.id||'stack')+(s.tts_voice?(' · '+s.tts_voice):'');
1501
+ return '<option value="'+esc(id)+'"'+(id===curVoice?' selected':'')+'>'+esc(name)+'</option>';
1502
+ }).join('');
1503
+ }
1504
+ var effChar=$('pk-eff-char');
1505
+ if(effChar){
1506
+ var curEffChar=effChar.value;
1507
+ effChar.innerHTML=characterPresets.map(function(p){
1508
+ var id=p.id||p.preset_id||p.character_id||''; var name=p.name||p.id||p.preset_id||'';
1509
+ return '<option value="'+esc(id)+'"'+(id===curEffChar?' selected':'')+'>'+esc(name)+'</option>';
1510
+ }).join('');
1511
+ }
1512
+ var effVoice=$('pk-eff-voice');
1513
+ if(effVoice){
1514
+ var curEffVoice=effVoice.value;
1515
+ effVoice.innerHTML=voiceStacks.map(function(s){
1516
+ var id=s.id||''; var name=s.name||s.id||'';
1517
+ return '<option value="'+esc(id)+'"'+(id===curEffVoice?' selected':'')+'>'+esc(name)+'</option>';
1518
+ }).join('');
1519
+ }
1520
+ }
979
1521
 
980
1522
  function slugify(s){ return (s||'').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-|-$/g,'').slice(0,40); }
981
1523
 
@@ -1058,7 +1600,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
1058
1600
  function clearRobotForm(){ $('rb-name').value=''; $('rb-char').value=''; $('rb-tsip').value=''; $('rb-lanip').value=''; $('rb-motor').value=''; $('rb-lk').value=''; $('rb-voice').value=''; refreshRobotCliCommand(); }
1059
1601
  var srvDetectBtn=$('srv-detect'); if(srvDetectBtn) srvDetectBtn.addEventListener('click', detectLocalLiveKit);
1060
1602
  function triggerSession(id){
1061
- rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/trigger-session',{confidence:0.9}).then(function(){ showOut('triggered motion for '+id); pollRobopark(); Park.refreshDrawer(); }).catch(function(e){ showOut('trigger-session ('+id+'): '+e.message); });
1603
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/simulate-trigger',{}).then(function(res){
1604
+ if(res && res.queued===false) throw new Error(res.reason||'trigger was not queued');
1605
+ showOut('motion trigger queued for '+id+' — waiting for robot heartbeat'); pollRobopark(); Park.refreshDrawer();
1606
+ }).catch(function(e){ showOut('motion trigger ('+id+'): '+e.message); });
1062
1607
  }
1063
1608
  function updateRobotCharacter(id, cid){
1064
1609
  if(!cid){ showOut('Choose a character preset'); return; }
@@ -1111,7 +1656,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
1111
1656
  $('rpve-temperature').value=(s.temperature!=null?s.temperature:0.7);
1112
1657
  $('rpve-num-ctx').value=(s.num_ctx!=null?s.num_ctx:8192);
1113
1658
  $('rpve-tts-provider').value=s.tts_provider||'elevenlabs';
1114
- $('rpve-tts-voice').value=s.tts_voice||'';
1659
+ // Stash the saved voice so refreshTtsVoiceSelect can keep it selected
1660
+ // when the catalog hasn't loaded yet. fillTtsVoiceSelect reads this
1661
+ // and includes it as a synthetic option if the catalog is missing it.
1662
+ var sel = $('rpve-tts-voice'); if(sel) sel.__savedVoice = s.tts_voice || '';
1663
+ refreshTtsVoiceSelect(true);
1115
1664
  $('rpve-tts-language').value=s.tts_language||'en';
1116
1665
  $('rpve-max-turns').value=(s.max_turns!=null?s.max_turns:20);
1117
1666
  $('rpve-endpointing').value=(s.min_endpointing_delay!=null?s.min_endpointing_delay:0.7);
@@ -1140,6 +1689,38 @@ button.act:disabled{opacity:0.5;cursor:default;}
1140
1689
  var vl=$('rp-voice-list'); if(vl) vl.addEventListener('click', function(e){ var c=e.target.closest?e.target.closest('.rp-card'):null; if(!c) return; var id=c.getAttribute('data-vsid'); if(id) openVoiceStackEdit(id); });
1141
1690
  var vsSave=$('rpve-save'); if(vsSave) vsSave.addEventListener('click', saveVoiceStackEdit);
1142
1691
  var vsCancel=$('rpve-cancel'); if(vsCancel) vsCancel.addEventListener('click', function(){ $('rp-voice-edit').style.display='none'; editingVoiceStackId=null; });
1692
+ // TTS provider change: re-populate the voice <select> for the new provider.
1693
+ // We pass force=true so the catalog is refetched if we haven't seen this
1694
+ // provider yet; otherwise we use the cached list to repopulate instantly.
1695
+ var ttsProvEl = $('rpve-tts-provider');
1696
+ if(ttsProvEl) ttsProvEl.addEventListener('change', function(){
1697
+ var sel = $('rpve-tts-voice');
1698
+ if(sel) sel.__savedVoice = sel.value || sel.__savedVoice || '';
1699
+ refreshTtsVoiceSelect(true);
1700
+ });
1701
+ // "Browse…" button: open the voice picker modal for the current provider.
1702
+ var ttsBrowseBtn = $('rpve-tts-voice-browse');
1703
+ if(ttsBrowseBtn) ttsBrowseBtn.addEventListener('click', function(){
1704
+ var provEl = $('rpve-tts-provider');
1705
+ var provider = (provEl && provEl.value) || 'elevenlabs';
1706
+ openTtsVoicePicker(provider, function(voiceId){
1707
+ var sel = $('rpve-tts-voice');
1708
+ if(!sel) return;
1709
+ // Make sure the chosen id exists as an option (it may have been
1710
+ // absent from the inline dropdown because the catalog hadn't loaded
1711
+ // yet). If it isn't there, append a synthetic option and select it.
1712
+ var exists = false;
1713
+ for(var i=0;i<sel.options.length;i++){ if(sel.options[i].value === voiceId){ exists=true; break; } }
1714
+ if(!exists){
1715
+ var o = document.createElement('option'); o.value=voiceId; o.textContent=voiceId+' (selected via Browse)';
1716
+ sel.appendChild(o);
1717
+ }
1718
+ sel.value = voiceId;
1719
+ sel.__savedVoice = voiceId;
1720
+ var note = $('rpve-tts-voice-note');
1721
+ if(note){ note.className='rp-autonote ok'; note.textContent='✓ selected '+voiceId+' from the '+provider+' catalog.'; }
1722
+ });
1723
+ });
1143
1724
 
1144
1725
  // ── character-preset edit panel ──
1145
1726
  var editingCharPresetId=null;
@@ -1193,6 +1774,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
1193
1774
  // stash per-robot rows so the Park drawer can show live session stats
1194
1775
  roboparkByName = {};
1195
1776
  robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });
1777
+ // push the same map into the Park so the canvas can color robots
1778
+ // by live/talking/waiting/ended status. updateTelemetry also
1779
+ // re-derives the readiness → status mapping for every node.
1780
+ if(Park && Park.updateTelemetry) Park.updateTelemetry(roboparkByName);
1196
1781
  if (d && (d.disabled || d.ok===false)){
1197
1782
  totals.innerHTML=''; $('pm-wrap').hidden=true;
1198
1783
  grid.innerHTML = '<div class="empty" style="grid-column:1/-1">'+esc(d.error||'no scheduler linked')+' — start the hub with <span class="mono">--scheduler-url http://&lt;scheduler&gt;:8080</span></div>';
@@ -1210,14 +1795,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
1210
1795
  var html='';
1211
1796
  for (var i=0;i<robots.length;i++){
1212
1797
  var x = robots[i];
1213
- var name = robotName(x);
1214
- var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||x.character_preset_id||name)});
1215
- var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—';
1798
+ var name = robotName(x);
1799
+ var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||x.character_preset_id||name)});
1800
+ var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—';
1216
1801
  var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';
1217
1802
  var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'—';
1218
- var active = x.active_session||x.in_session||x.session_active;
1803
+ var active = x.active_session||x.in_session||x.session_active||x.current_session_id||x.status==='connecting'||x.status==='running';
1804
+ var readiness = x.readiness_code||'unknown';
1805
+ var readinessBad = readiness!=='ready' && readiness!=='in_session';
1806
+ var readinessClass = readiness==='ready'?'ok':(readiness==='in_session'?'':'bad');
1219
1807
  var idAttr = esc(name);
1220
- html += '<div class="rp-card" data-name="'+esc(name)+'"><div class="rp-card-top">'+(avatar?'<div class="rp-card-img"><img alt="'+esc(name)+'" src="'+esc(avatar+QS)+'"></div>':'')+'<div style="min-width:0;flex:1;"><h4>'+esc(name)+(active?' <span class="chip" style="color:var(--ok)">live</span>':'')+'</h4></div></div><div class="rp-metrics">'
1808
+ 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">'
1221
1809
  + '<div class="rp-m"><span class="v">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class="l">triggers</span></div>'
1222
1810
  + '<div class="rp-m"><span class="v">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class="l">sessions</span></div>'
1223
1811
  + '<div class="rp-m"><span class="v">'+lat+'</span><span class="l">avg latency</span></div>'
@@ -1227,6 +1815,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
1227
1815
  + '<button class="rp-btn" data-act="preview" data-robot="'+idAttr+'">Preview</button>'
1228
1816
  + '<button class="rp-btn danger" data-act="end" data-robot="'+idAttr+'"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'
1229
1817
  + '</div>'
1818
+ + '<div class="pk-note '+readinessClass+'" style="margin-top:0.5rem;color:'+(readinessBad?'var(--bad)':'var(--ok)')+'">'+esc(readiness.replace(/_/g,' '))+' · '+esc(x.readiness_message||'no readiness detail')+'</div>'
1819
+ + (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>':'')
1230
1820
  + '<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||'— auto —')+'</option></select></div></div>';
1231
1821
  }
1232
1822
  grid.innerHTML = html;
@@ -1238,6 +1828,836 @@ button.act:disabled{opacity:0.5;cursor:default;}
1238
1828
  });
1239
1829
  }
1240
1830
 
1831
+ // ══════════════════════════════════════════════════════════════════
1832
+ // Sessions & Telemetry — sub-tab control room
1833
+ // Six sub-tabs share a single pane-robopark pane:
1834
+ // Overview · Live Sessions · History · Audit Log · Fleet · Setup
1835
+ // Each sub-tab owns a poll function + a setInterval handle; the handles
1836
+ // are cleared on switch so the browser only fetches when its pane is
1837
+ // actually visible.
1838
+ // ══════════════════════════════════════════════════════════════════
1839
+
1840
+ // ── shared time / formatting helpers ──
1841
+ function rpFmtRelative(ts){
1842
+ if(!ts) return '—';
1843
+ var t=typeof ts==='number'?ts:Date.parse(ts);
1844
+ if(!t||isNaN(t)) return '—';
1845
+ var diff=Math.floor((Date.now()-t)/1000);
1846
+ if(diff<0) diff=0;
1847
+ if(diff<5) return 'just now';
1848
+ if(diff<60) return diff+'s ago';
1849
+ if(diff<3600) return Math.floor(diff/60)+'m ago';
1850
+ if(diff<86400) return Math.floor(diff/3600)+'h ago';
1851
+ return Math.floor(diff/86400)+'d ago';
1852
+ }
1853
+ function rpFmtAbs(ts){
1854
+ if(!ts) return '—';
1855
+ var t=typeof ts==='number'?ts:Date.parse(ts);
1856
+ if(!t||isNaN(t)) return '—';
1857
+ var d=new Date(t);
1858
+ function p(n){return (n<10?'0':'')+n;}
1859
+ return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
1860
+ }
1861
+ function rpFmtDuration(sec){
1862
+ if(sec==null||isNaN(sec)) return '—';
1863
+ sec=Math.max(0,Math.floor(sec));
1864
+ var h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
1865
+ if(h) return h+'h '+m+'m';
1866
+ if(m) return m+'m '+s+'s';
1867
+ return s+'s';
1868
+ }
1869
+ function rpEndReasonClass(r){
1870
+ var x=String(r||'').toLowerCase();
1871
+ if(x==='operator'||x==='end_session'||x==='manual') return 'operator';
1872
+ if(x==='error'||x==='abnormal'||x==='failed') return 'error';
1873
+ if(x==='timeout'||x==='silence_timeout') return 'timeout';
1874
+ if(x==='silence') return 'silence';
1875
+ if(x==='completed'||x==='normal'||x==='done') return 'completed';
1876
+ return '';
1877
+ }
1878
+ function rpCategoryIcon(cat){
1879
+ var c=String(cat||'').toLowerCase();
1880
+ if(c==='robot') return '🤖';
1881
+ if(c==='device') return '📡';
1882
+ if(c==='session') return '🎙';
1883
+ if(c==='server') return '🖥';
1884
+ if(c==='voice') return '🗣';
1885
+ if(c==='character') return '🎭';
1886
+ if(c==='preview') return '👁';
1887
+ if(c==='settings') return '⚙';
1888
+ return '✦';
1889
+ }
1890
+
1891
+ // ── Sub-tab routing ──
1892
+ var rpSubTab = 'overview';
1893
+ var rpIntervals = {}; // sub-tab name -> setInterval handle
1894
+ var rpHistoryAll = []; // full recent-session list (we paginate client-side)
1895
+ function setRpSubTab(name){
1896
+ if(!name) return;
1897
+ rpSubTab = name;
1898
+ var btns = document.querySelectorAll('.rp-subtab');
1899
+ for(var i=0;i<btns.length;i++){
1900
+ var b=btns[i], sub=b.getAttribute('data-sub');
1901
+ var isActive = sub===name;
1902
+ b.classList.toggle('active', isActive);
1903
+ b.setAttribute('aria-selected', isActive?'true':'false');
1904
+ }
1905
+ var subs = document.querySelectorAll('.rp-sub');
1906
+ for(var j=0;j<subs.length;j++){
1907
+ var s=subs[j];
1908
+ s.classList.toggle('active', s.id==='rp-sub-'+name);
1909
+ }
1910
+ // stop every sub-tab interval, then start only the active one
1911
+ var names = ['overview','live','history','audit','fleet','setup'];
1912
+ for(var k=0;k<names.length;k++){
1913
+ var n=names[k];
1914
+ if(rpIntervals[n]){ clearInterval(rpIntervals[n]); rpIntervals[n]=null; }
1915
+ }
1916
+ if(name==='overview'){ pollOverview(); rpIntervals.overview = setInterval(pollOverview, 4000); }
1917
+ if(name==='live'){ pollLiveSessions(); startLiveAutoRefresh(); }
1918
+ if(name==='history'){ rpHistoryPage=0; pollHistory(); }
1919
+ if(name==='audit'){ pollAuditLog(); rpIntervals.audit = setInterval(pollAuditLog, 6000); }
1920
+ if(name==='fleet'){ pollFleet(); rpIntervals.fleet = setInterval(pollFleet, 5000); }
1921
+ if(name==='setup'){ pollSettings(); loadCharacterPresets(); loadVoiceStacks(); pollRuntimeHealth(); }
1922
+ // remember last sub-tab in URL hash
1923
+ try{ history.replaceState(null,'','#robopark/'+name); }catch(e){}
1924
+ }
1925
+ function buildRpSubTabs(){
1926
+ var btns = document.querySelectorAll('.rp-subtab');
1927
+ for(var i=0;i<btns.length;i++){
1928
+ (function(btn){
1929
+ btn.addEventListener('click', function(){ setRpSubTab(btn.getAttribute('data-sub')); });
1930
+ })(btns[i]);
1931
+ }
1932
+ // restore from hash like #robopark/history
1933
+ var h=String(location.hash||'').replace('#','');
1934
+ var m = h.match(/^robopark\\/(.+)$/);
1935
+ var initial = (m && m[1]) ? m[1] : 'overview';
1936
+ if(['overview','live','history','audit','fleet','setup'].indexOf(initial)<0) initial='overview';
1937
+ setRpSubTab(initial);
1938
+ }
1939
+
1940
+ // ── Modal helper ──
1941
+ function showModal(title, bodyHtml){
1942
+ var back = $('rp-modal-back');
1943
+ if(!back) return;
1944
+ $('rp-modal-title').textContent = title || 'Details';
1945
+ $('rp-modal-body').innerHTML = bodyHtml || '';
1946
+ back.classList.add('open');
1947
+ }
1948
+ function closeModal(){
1949
+ var back = $('rp-modal-back');
1950
+ if(!back) return;
1951
+ back.classList.remove('open');
1952
+ $('rp-modal-body').innerHTML = '';
1953
+ }
1954
+ // wire the close handlers (once)
1955
+ (function(){
1956
+ var closeBtn = $('rp-modal-close');
1957
+ if(closeBtn) closeBtn.addEventListener('click', closeModal);
1958
+ var back = $('rp-modal-back');
1959
+ if(back) back.addEventListener('click', function(e){ if(e.target===back) closeModal(); });
1960
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeModal(); });
1961
+ })();
1962
+
1963
+ // ── TTS voice picker modal ──
1964
+ // A robust, searchable, debounced-fetched picker for any TTS provider that
1965
+ // exposes /api/voices/<provider> on the scheduler. Designed specifically
1966
+ // for ElevenLabs (hundreds of voices, needs search + loading + error
1967
+ // states) but works for any provider — Kokoro, Piper, etc.
1968
+ //
1969
+ // Usage: openTtsVoicePicker('elevenlabs', function(voiceId){ ... });
1970
+ // The callback receives the picked voice_id and the modal closes itself.
1971
+ // If the user dismisses the modal (Esc, backdrop, ✕) the callback is NOT
1972
+ // called. The catalog is cached in ttsVoiceCache[provider] and re-used
1973
+ // on subsequent opens; a manual "Refresh" button forces a refetch.
1974
+ var ttsPickerState = {
1975
+ provider: null,
1976
+ voices: [],
1977
+ filter: '',
1978
+ loading: false,
1979
+ error: null,
1980
+ onPick: null,
1981
+ };
1982
+ function openTtsVoicePicker(provider, onPick){
1983
+ ttsPickerState.provider = provider || 'elevenlabs';
1984
+ ttsPickerState.onPick = (typeof onPick === 'function') ? onPick : null;
1985
+ ttsPickerState.filter = '';
1986
+ showModal('Browse '+ttsPickerState.provider+' voices', buildTtsPickerBody());
1987
+ // Focus the search box on the next paint so the modal animation has
1988
+ // started and the input is visible.
1989
+ setTimeout(function(){
1990
+ var inp = $('rp-voice-picker-search');
1991
+ if(inp){ try{ inp.focus(); inp.select && inp.select(); }catch(e){} }
1992
+ }, 30);
1993
+ // Re-render after the catalog is loaded (may already be cached).
1994
+ renderTtsPicker();
1995
+ loadTtsVoiceCatalog(ttsPickerState.provider, {force:false}).then(function(){
1996
+ // The modal may have been closed while the fetch was in flight.
1997
+ if(ttsPickerState.provider !== provider) return;
1998
+ renderTtsPicker();
1999
+ });
2000
+ }
2001
+ function buildTtsPickerBody(){
2002
+ // Toolbar (search + refresh) + scrollable results area. The two
2003
+ // containers (#rp-voice-picker-results + #rp-voice-picker-status) are
2004
+ // re-rendered in place by renderTtsPicker(), so opening the modal
2005
+ // repeatedly is cheap and we don't leak listeners.
2006
+ return ''
2007
+ + '<div class="rp-voice-picker">'
2008
+ + '<div class="rp-voice-picker-toolbar">'
2009
+ + '<input type="search" id="rp-voice-picker-search" placeholder="Search by name, id, language, or category…" autocomplete="off" spellcheck="false">'
2010
+ + '<button type="button" class="rp-btn" id="rp-voice-picker-refresh" title="Refetch the voice catalog from the scheduler">↻ Refresh</button>'
2011
+ + '</div>'
2012
+ + '<div class="rp-voice-picker-status" id="rp-voice-picker-status"></div>'
2013
+ + '<div class="rp-voice-picker-results" id="rp-voice-picker-results"></div>'
2014
+ + '<div class="rp-voice-picker-foot">'
2015
+ + '<span class="rp-muted">Click a voice to select it · Esc to cancel</span>'
2016
+ + '</div>'
2017
+ + '</div>';
2018
+ }
2019
+ function renderTtsPicker(){
2020
+ var status = $('rp-voice-picker-status');
2021
+ var results = $('rp-voice-picker-results');
2022
+ if(!status || !results) return; // modal was closed
2023
+ var cache = ttsVoiceCache[ttsPickerState.provider];
2024
+ var voices = (cache && Array.isArray(cache.voices)) ? cache.voices : [];
2025
+ var warning = cache && cache.warning;
2026
+ var source = cache && cache.source;
2027
+
2028
+ // ── status row (loading / error / counts / warning) ──
2029
+ if(ttsPickerState.loading && !cache){
2030
+ status.className = 'rp-voice-picker-status busy';
2031
+ status.innerHTML = '<span class="rp-spinner"></span> Loading '+esc(ttsPickerState.provider)+' voice catalog…';
2032
+ results.innerHTML = '';
2033
+ return;
2034
+ }
2035
+ if(!cache){
2036
+ status.className = 'rp-voice-picker-status bad';
2037
+ status.innerHTML = '⚠ No catalog data for '+esc(ttsPickerState.provider)+'. Click Refresh to retry.';
2038
+ results.innerHTML = '';
2039
+ return;
2040
+ }
2041
+ if(warning && !voices.length){
2042
+ status.className = 'rp-voice-picker-status bad';
2043
+ status.innerHTML = '⚠ '+esc(warning);
2044
+ results.innerHTML = '';
2045
+ return;
2046
+ }
2047
+ var bits = [];
2048
+ bits.push('<b>'+voices.length+'</b> '+esc(ttsPickerState.provider)+' voice'+(voices.length===1?'':'s'));
2049
+ if(source) bits.push('· source: <span class="rp-mono">'+esc(source)+'</span>');
2050
+ if(warning) bits.push('<span style="color:var(--bad)">⚠ '+esc(warning)+'</span>');
2051
+ status.className = warning ? 'rp-voice-picker-status warn' : 'rp-voice-picker-status';
2052
+ status.innerHTML = bits.join(' ');
2053
+
2054
+ // ── filtered results ──
2055
+ var q = String(ttsPickerState.filter || '').toLowerCase().trim();
2056
+ var filtered = voices;
2057
+ if(q){
2058
+ filtered = voices.filter(function(v){
2059
+ var blob = [
2060
+ v.id, v.name, v.language, v.gender, v.category, v.description
2061
+ ].map(function(x){return x==null?'':String(x);}).join(' ').toLowerCase();
2062
+ return blob.indexOf(q) >= 0;
2063
+ });
2064
+ }
2065
+ if(!filtered.length){
2066
+ results.innerHTML = '<div class="rp-voice-picker-empty">'
2067
+ + (q ? ('No voices match “'+esc(q)+'”.')
2068
+ : ('No voices available for this provider.'))
2069
+ + '</div>';
2070
+ return;
2071
+ }
2072
+ // Group by category for a tidy scannable list. Providers that don't
2073
+ // set a category (Kokoro static list, etc.) all land in "Other".
2074
+ var groups = {};
2075
+ for(var i=0;i<filtered.length;i++){
2076
+ var v = filtered[i];
2077
+ var key = v.category || (v.language ? ('Language: '+v.language) : 'Other');
2078
+ if(!groups[key]) groups[key] = [];
2079
+ groups[key].push(v);
2080
+ }
2081
+ var groupNames = Object.keys(groups).sort(function(a,b){
2082
+ if(a==='Other') return 1;
2083
+ if(b==='Other') return -1;
2084
+ return a.localeCompare(b);
2085
+ });
2086
+ var html = '';
2087
+ for(var g=0;g<groupNames.length;g++){
2088
+ var name = groupNames[g];
2089
+ html += '<div class="rp-vp-group"><div class="rp-vp-group-head">'+esc(name)+' · '+groups[name].length+'</div>';
2090
+ for(var j=0;j<groups[name].length;j++){
2091
+ var voice = groups[name][j];
2092
+ var id = voice.id || voice.voice_id || '';
2093
+ if(!id) continue;
2094
+ var meta = [];
2095
+ if(voice.language) meta.push(voice.language);
2096
+ if(voice.gender) meta.push(voice.gender);
2097
+ var sub = meta.length ? ('<span class="rp-vp-meta">'+esc(meta.join(' · '))+'</span>') : '';
2098
+ var desc = voice.description ? ('<div class="rp-vp-desc">'+esc(voice.description)+'</div>') : '';
2099
+ html += '<button type="button" class="rp-vp-item" data-voice-id="'+esc(id)+'">'
2100
+ + '<div class="rp-vp-row1"><span class="rp-vp-name">'+esc(voice.name||id)+'</span>'+sub+'</div>'
2101
+ + '<div class="rp-vp-id">'+esc(id)+'</div>'
2102
+ + desc
2103
+ + '</button>';
2104
+ }
2105
+ html += '</div>';
2106
+ }
2107
+ results.innerHTML = html;
2108
+ }
2109
+ // Click delegation for picker items + the refresh button + the search
2110
+ // input. Bound once on the document so re-opening the modal doesn't
2111
+ // stack listeners.
2112
+ document.addEventListener('click', function(e){
2113
+ var btn = e.target.closest && e.target.closest('.rp-vp-item');
2114
+ if(btn){
2115
+ var vid = btn.getAttribute('data-voice-id');
2116
+ if(vid && ttsPickerState.onPick){ ttsPickerState.onPick(vid); }
2117
+ closeModal();
2118
+ return;
2119
+ }
2120
+ var r = e.target.closest && e.target.closest('#rp-voice-picker-refresh');
2121
+ if(r){
2122
+ // Re-fetch the catalog, ignore cache, then re-render.
2123
+ loadTtsVoiceCatalog(ttsPickerState.provider, {force:true}).then(function(){ renderTtsPicker(); });
2124
+ return;
2125
+ }
2126
+ });
2127
+ document.addEventListener('input', function(e){
2128
+ if(e.target && e.target.id === 'rp-voice-picker-search'){
2129
+ ttsPickerState.filter = e.target.value;
2130
+ renderTtsPicker();
2131
+ }
2132
+ });
2133
+
2134
+ // ── Live Sessions sub-tab ──
2135
+ var rpLiveAuto = true;
2136
+ var rpLiveClockTimer = null;
2137
+ function startLiveAutoRefresh(){
2138
+ if(rpIntervals.live){ clearInterval(rpIntervals.live); rpIntervals.live=null; }
2139
+ if(rpLiveClockTimer){ clearInterval(rpLiveClockTimer); rpLiveClockTimer=null; }
2140
+ if(rpLiveAuto){
2141
+ rpIntervals.live = setInterval(pollLiveSessions, 3000);
2142
+ rpLiveClockTimer = setInterval(updateLiveDurations, 1000);
2143
+ }
2144
+ }
2145
+ function updateLiveDurations(){
2146
+ var tbody = $('rp-live-tbody'); if(!tbody) return;
2147
+ var cells = tbody.querySelectorAll('td[data-started]');
2148
+ for(var i=0;i<cells.length;i++){
2149
+ var t = parseInt(cells[i].getAttribute('data-started'),10);
2150
+ if(!t) continue;
2151
+ cells[i].textContent = rpFmtDuration(Math.floor((Date.now()-t)/1000));
2152
+ }
2153
+ }
2154
+ function pollLiveSessions(){
2155
+ getJson('/robopark/api/sessions?active_only=true').then(function(rows){
2156
+ renderLiveSessions(Array.isArray(rows)?rows:[]);
2157
+ }).catch(function(e){ /* soft fail */ });
2158
+ }
2159
+ function renderLiveSessions(rows){
2160
+ var tbody = $('rp-live-tbody'), empty = $('rp-live-empty'), wrap = $('rp-live-wrap');
2161
+ var searchEl=$('rp-live-search'), robotEl=$('rp-live-robot');
2162
+ var search = searchEl ? String(searchEl.value||'').toLowerCase() : '';
2163
+ var robotF = robotEl ? String(robotEl.value||'') : '';
2164
+ // refresh the robot dropdown whenever the set changes
2165
+ if(robotEl){
2166
+ var known={}; for(var i=0;i<rows.length;i++){ var rid=String(rows[i].robot_id||''); if(rid) known[rid]=1; }
2167
+ var present = robotEl.querySelectorAll('option');
2168
+ var have={};
2169
+ for(var j=1;j<present.length;j++){ have[present[j].value]=1; if(!known[present[j].value]){ robotEl.removeChild(present[j]); } }
2170
+ for(var rid in known){ if(!have[rid]){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; robotEl.appendChild(o); } }
2171
+ }
2172
+ var filtered = rows.filter(function(s){
2173
+ if(robotF && String(s.robot_id||'')!==robotF) return false;
2174
+ if(search){
2175
+ var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();
2176
+ if(blob.indexOf(search)<0) return false;
2177
+ }
2178
+ return true;
2179
+ });
2180
+ if(!filtered.length){ tbody.innerHTML=''; wrap.style.display='none'; empty.style.display='block'; }
2181
+ else { empty.style.display='none'; wrap.style.display='block'; }
2182
+ var html='';
2183
+ for(var i=0;i<filtered.length;i++){
2184
+ var s = filtered[i];
2185
+ var rid = s.robot_id || '';
2186
+ var sid = s.session_id || '';
2187
+ var started = s.started_at ? Date.parse(s.started_at) : null;
2188
+ var elapsed = started ? Math.floor((Date.now()-started)/1000) : null;
2189
+ html += '<tr>'
2190
+ + '<td><b>'+esc(rid)+'</b></td>'
2191
+ + '<td><span class="sid" title="'+esc(sid)+'">'+esc(sid)+'</span></td>'
2192
+ + '<td><span class="t-rel"><span class="live-dot"></span>'+esc(rpFmtRelative(started))+'</span><span class="t-abs">'+esc(rpFmtAbs(started))+'</span></td>'
2193
+ + '<td data-started="'+started+'">'+esc(rpFmtDuration(elapsed))+'</td>'
2194
+ + '<td><span class="rp-end-badge active">active</span></td>'
2195
+ + '<td style="display:flex;gap:0.3rem;">'
2196
+ + '<button class="rp-btn" data-act="open-robot" data-robot="'+esc(rid)+'">Open robot</button>'
2197
+ + '<button class="rp-btn danger" data-act="end-session" data-sid="'+esc(sid)+'" data-robot="'+esc(rid)+'">End</button>'
2198
+ + '</td>'
2199
+ + '</tr>';
2200
+ }
2201
+ tbody.innerHTML = html;
2202
+ // update the tab badge
2203
+ var badge = $('rps-live-badge'); if(badge) badge.textContent = filtered.length;
2204
+ }
2205
+ function endSessionFromTable(sessionId, robotId){
2206
+ if(!confirm('End session '+sessionId+' for '+robotId+'?')) return;
2207
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/end-session?reason=operator').then(function(){
2208
+ showOut('ended session for '+robotId);
2209
+ pollLiveSessions();
2210
+ pollRobopark();
2211
+ pollRuntimeHealth();
2212
+ }).catch(function(e){ showOut('end-session: '+e.message); });
2213
+ }
2214
+
2215
+ // wire live tab interactions
2216
+ (function(){
2217
+ var s=$('rp-live-search'); if(s) s.addEventListener('input', function(){ pollLiveSessions(); });
2218
+ var r=$('rp-live-robot'); if(r) r.addEventListener('change', function(){ pollLiveSessions(); });
2219
+ var a=$('rp-live-autoref'); if(a) a.addEventListener('change', function(){ rpLiveAuto = !!a.checked; startLiveAutoRefresh(); });
2220
+ var rb=$('rp-live-refresh'); if(rb) rb.addEventListener('click', pollLiveSessions);
2221
+ var tb=$('rp-live-tbody'); if(tb) tb.addEventListener('click', function(e){
2222
+ var b = e.target.closest ? e.target.closest('.rp-btn') : null;
2223
+ if(!b) return;
2224
+ var act = b.getAttribute('data-act');
2225
+ if(act==='end-session'){ endSessionFromTable(b.getAttribute('data-sid'), b.getAttribute('data-robot')); }
2226
+ if(act==='open-robot'){ var rid=b.getAttribute('data-robot'); if(rid){ setRpSubTab('fleet'); } }
2227
+ });
2228
+ })();
2229
+
2230
+ // ── History sub-tab ──
2231
+ var rpHistoryFilter = { robot: '', endReason: '', range: '7d', search: '' };
2232
+ var rpHistoryPage = 0;
2233
+ var RP_HISTORY_PAGE_SIZE = 25;
2234
+ function pollHistory(){
2235
+ getJson('/robopark/api/sessions?active_only=false&limit=500').then(function(rows){
2236
+ rpHistoryAll = Array.isArray(rows)?rows:[];
2237
+ renderHistory(rpHistoryAll);
2238
+ }).catch(function(e){
2239
+ var tbody=$('rp-hist-tbody'); if(tbody) tbody.innerHTML='';
2240
+ var empty=$('rp-hist-empty'); if(empty){ empty.style.display='block'; empty.textContent='history unreachable: '+(e&&e.message||''); }
2241
+ });
2242
+ }
2243
+ function rpHistoryWithinRange(s, range){
2244
+ if(range==='all'||!range) return true;
2245
+ if(!s.started_at) return true; // include rows with no timestamp when no filter set
2246
+ var t = Date.parse(s.started_at); if(isNaN(t)) return true;
2247
+ var now = Date.now();
2248
+ if(range==='24h') return (now-t) <= 86400e3;
2249
+ if(range==='7d') return (now-t) <= 7*86400e3;
2250
+ if(range==='30d') return (now-t) <= 30*86400e3;
2251
+ return true;
2252
+ }
2253
+ function renderHistory(rows){
2254
+ rows = rows || rpHistoryAll;
2255
+ var tbody = $('rp-hist-tbody'), empty = $('rp-hist-empty'), wrap = $('rp-hist-wrap');
2256
+ var info = $('rp-hist-info');
2257
+ // refresh robot dropdown from rows
2258
+ var rsel = $('rp-hist-robot');
2259
+ if(rsel){
2260
+ var known={};
2261
+ for(var i=0;i<rows.length;i++){ var r=String(rows[i].robot_id||''); if(r) known[r]=1; }
2262
+ // remove stale (preserve first "all" option)
2263
+ var opts=rsel.querySelectorAll('option');
2264
+ for(var j=opts.length-1;j>=1;j--){ if(!known[opts[j].value]) rsel.removeChild(opts[j]); }
2265
+ for(var rid in known){
2266
+ var present=false; for(var k=1;k<opts.length;k++){ if(opts[k].value===rid){ present=true; break; } }
2267
+ if(!present){ var o=document.createElement('option'); o.value=rid; o.textContent=rid; rsel.appendChild(o); }
2268
+ }
2269
+ }
2270
+ var f = rpHistoryFilter;
2271
+ var filtered = rows.filter(function(s){
2272
+ if(f.robot && String(s.robot_id||'')!==f.robot) return false;
2273
+ if(f.endReason && String(s.end_reason||'').toLowerCase()!==f.endReason) return false;
2274
+ if(f.search){
2275
+ var blob = (String(s.session_id||'')+' '+String(s.robot_id||'')+' '+String(s.room_name||'')).toLowerCase();
2276
+ if(blob.indexOf(f.search)<0) return false;
2277
+ }
2278
+ if(!rpHistoryWithinRange(s, f.range)) return false;
2279
+ return true;
2280
+ });
2281
+ var totalPages = Math.max(1, Math.ceil(filtered.length / RP_HISTORY_PAGE_SIZE));
2282
+ if(rpHistoryPage >= totalPages) rpHistoryPage = totalPages-1;
2283
+ if(rpHistoryPage < 0) rpHistoryPage = 0;
2284
+ var start = rpHistoryPage * RP_HISTORY_PAGE_SIZE;
2285
+ var slice = filtered.slice(start, start+RP_HISTORY_PAGE_SIZE);
2286
+ if(!slice.length){
2287
+ tbody.innerHTML = '';
2288
+ wrap.style.display='none';
2289
+ empty.style.display='block';
2290
+ empty.textContent = filtered.length ? 'no results on this page' : 'no sessions match the current filters';
2291
+ } else {
2292
+ empty.style.display='none';
2293
+ wrap.style.display='block';
2294
+ }
2295
+ var html='';
2296
+ for(var i=0;i<slice.length;i++){
2297
+ var s = slice[i];
2298
+ var sid = s.session_id || '';
2299
+ var rid = s.robot_id || '';
2300
+ var started = s.started_at ? Date.parse(s.started_at) : null;
2301
+ var ended = s.ended_at ? Date.parse(s.ended_at) : null;
2302
+ var dur = s.duration_seconds!=null ? s.duration_seconds : (started&&ended?Math.floor((ended-started)/1000):null);
2303
+ var reason = s.end_reason || '';
2304
+ var reasonCls = rpEndReasonClass(reason);
2305
+ var trig = s.trigger_count!=null ? s.trigger_count : '—';
2306
+ var lat = s.avg_latency_ms!=null ? Math.round(s.avg_latency_ms)+'ms' : '—';
2307
+ html += '<tr data-sid="'+esc(sid)+'">'
2308
+ + '<td><span class="sid" title="'+esc(sid)+'">'+esc(sid)+'</span></td>'
2309
+ + '<td><b>'+esc(rid)+'</b></td>'
2310
+ + '<td><span class="t-rel">'+esc(rpFmtRelative(started))+'</span><span class="t-abs">'+esc(rpFmtAbs(started))+'</span></td>'
2311
+ + '<td><span class="t-rel">'+esc(rpFmtRelative(ended))+'</span><span class="t-abs">'+esc(rpFmtAbs(ended))+'</span></td>'
2312
+ + '<td>'+esc(rpFmtDuration(dur))+'</td>'
2313
+ + '<td><span class="rp-end-badge '+(reasonCls||'')+'">'+esc(reason||'—')+'</span></td>'
2314
+ + '<td>'+esc(trig)+'</td>'
2315
+ + '<td>'+esc(lat)+'</td>'
2316
+ + '<td><button class="rp-btn" data-act="view" data-sid="'+esc(sid)+'">View</button></td>'
2317
+ + '</tr>';
2318
+ }
2319
+ tbody.innerHTML = html;
2320
+ if(info) info.textContent = 'showing '+(slice.length?(start+1):0)+'–'+(start+slice.length)+' of '+filtered.length+' · page '+(rpHistoryPage+1)+' / '+totalPages;
2321
+ var prev=$('rp-hist-prev'), next=$('rp-hist-next');
2322
+ if(prev) prev.disabled = rpHistoryPage<=0;
2323
+ if(next) next.disabled = rpHistoryPage>=totalPages-1;
2324
+ }
2325
+ function openSessionDetailModal(s){
2326
+ if(!s) return;
2327
+ function row(k,v){ return '<div class="kv"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v==null?'—':v)+'</div></div>'; }
2328
+ var html = '';
2329
+ html += row('Session ID', s.session_id);
2330
+ html += row('Robot', s.robot_id);
2331
+ html += row('Room', s.room_name);
2332
+ html += row('Started', rpFmtAbs(s.started_at) + ' ('+rpFmtRelative(s.started_at)+')');
2333
+ html += row('Ended', rpFmtAbs(s.ended_at) + ' ('+rpFmtRelative(s.ended_at)+')');
2334
+ html += row('Duration', rpFmtDuration(s.duration_seconds));
2335
+ html += row('End reason', s.end_reason || '—');
2336
+ html += row('Trigger count', s.trigger_count!=null?s.trigger_count:'—');
2337
+ html += row('Avg latency', s.avg_latency_ms!=null?Math.round(s.avg_latency_ms)+'ms':'—');
2338
+ html += row('Drop rate', s.drop_rate!=null?Math.round(s.drop_rate*100)+'%':'—');
2339
+ html += row('Operator', s.operator_id || s.ended_by || '—');
2340
+ html += row('Character', s.character_id || s.character_name || '—');
2341
+ html += row('Voice stack', s.voice_stack_id || '—');
2342
+ if(s.notes) html += row('Notes', s.notes);
2343
+ if(s.metadata){
2344
+ html += '<div class="kv"><div class="k">Metadata</div><div class="v"><pre>'+esc(typeof s.metadata==='string'?s.metadata:JSON.stringify(s.metadata,null,2))+'</pre></div></div>';
2345
+ }
2346
+ showModal('Session '+(s.session_id||''), html);
2347
+ }
2348
+ (function(){
2349
+ var s=$('rp-hist-search'); if(s) s.addEventListener('input', function(){ rpHistoryFilter.search = String(s.value||'').toLowerCase(); rpHistoryPage=0; renderHistory(); });
2350
+ var r=$('rp-hist-robot'); if(r) r.addEventListener('change', function(){ rpHistoryFilter.robot = r.value||''; rpHistoryPage=0; renderHistory(); });
2351
+ var re=$('rp-hist-reason'); if(re) re.addEventListener('change', function(){ rpHistoryFilter.endReason = re.value||''; rpHistoryPage=0; renderHistory(); });
2352
+ var ra=$('rp-hist-range'); if(ra) ra.addEventListener('change', function(){ rpHistoryFilter.range = ra.value||'7d'; rpHistoryPage=0; renderHistory(); });
2353
+ var rb=$('rp-hist-refresh'); if(rb) rb.addEventListener('click', pollHistory);
2354
+ var prev=$('rp-hist-prev'); if(prev) prev.addEventListener('click', function(){ rpHistoryPage=Math.max(0,rpHistoryPage-1); renderHistory(); });
2355
+ var next=$('rp-hist-next'); if(next) next.addEventListener('click', function(){ rpHistoryPage=rpHistoryPage+1; renderHistory(); });
2356
+ var tb=$('rp-hist-tbody'); if(tb) tb.addEventListener('click', function(e){
2357
+ var b = e.target.closest ? e.target.closest('.rp-btn') : null; if(!b) return;
2358
+ if(b.getAttribute('data-act')==='view'){
2359
+ var sid = b.getAttribute('data-sid');
2360
+ for(var i=0;i<rpHistoryAll.length;i++){ if(String(rpHistoryAll[i].session_id||'')===sid){ openSessionDetailModal(rpHistoryAll[i]); return; } }
2361
+ }
2362
+ });
2363
+ })();
2364
+
2365
+ // ── Audit Log sub-tab ──
2366
+ var rpAuditFilter = { category: '' };
2367
+ var rpAuditLast = [];
2368
+ function pollAuditLog(){
2369
+ var qs = rpAuditFilter.category ? ('?category='+encodeURIComponent(rpAuditFilter.category)+'&limit=200') : '?limit=200';
2370
+ getJson('/robopark/api/history'+qs).then(function(rows){
2371
+ renderAuditLog(Array.isArray(rows)?rows:[]);
2372
+ }).catch(function(){
2373
+ var tbody=$('rp-audit-tbody'); if(tbody) tbody.innerHTML='';
2374
+ var empty=$('rp-audit-empty'); if(empty){ empty.style.display='block'; empty.textContent='audit log unreachable'; }
2375
+ });
2376
+ }
2377
+ function renderAuditLog(rows){
2378
+ var tbody=$('rp-audit-tbody'), empty=$('rp-audit-empty'), wrap=$('rp-audit-wrap');
2379
+ if(!rows.length){
2380
+ tbody.innerHTML='';
2381
+ wrap.style.display='none';
2382
+ empty.style.display='block';
2383
+ return;
2384
+ }
2385
+ empty.style.display='none';
2386
+ wrap.style.display='block';
2387
+ var html='';
2388
+ for(var i=0;i<rows.length;i++){
2389
+ var ev=rows[i];
2390
+ var ts = ev.timestamp;
2391
+ var cat = ev.category || '—';
2392
+ var act = ev.action || '—';
2393
+ var ent = (ev.entity_type||'')+(ev.entity_id?(' · '+ev.entity_id):'');
2394
+ var det = ev.details || '';
2395
+ var src = ev.actor || '—';
2396
+ if(typeof det==='object') det = JSON.stringify(det);
2397
+ var detShort = String(det);
2398
+ if(detShort.length>80) detShort = detShort.slice(0,77)+'…';
2399
+ html += '<tr data-idx="'+i+'">'
2400
+ + '<td><span class="t-rel">'+esc(rpFmtRelative(ts))+'</span><span class="t-abs">'+esc(rpFmtAbs(ts))+'</span></td>'
2401
+ + '<td><span class="rp-end-badge">'+esc(rpCategoryIcon(cat))+' '+esc(cat)+'</span></td>'
2402
+ + '<td><b>'+esc(act)+'</b></td>'
2403
+ + '<td>'+esc(ent||'—')+'</td>'
2404
+ + '<td style="max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="'+esc(String(det).replace(/"/g,'&quot;'))+'">'+esc(detShort)+'</td>'
2405
+ + '<td>'+esc(src)+'</td>'
2406
+ + '</tr>';
2407
+ }
2408
+ tbody.innerHTML = html;
2409
+ // stash for modal
2410
+ rpAuditLast = rows;
2411
+ }
2412
+ function openAuditDetailModal(ev){
2413
+ if(!ev) return;
2414
+ function row(k,v){ return '<div class="kv"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v==null?'—':v)+'</div></div>'; }
2415
+ var html = '';
2416
+ html += row('Time', rpFmtAbs(ev.timestamp) + ' ('+rpFmtRelative(ev.timestamp)+')');
2417
+ html += row('Category', rpCategoryIcon(ev.category)+' '+(ev.category||'—'));
2418
+ html += row('Action', ev.action);
2419
+ html += row('Entity type', ev.entity_type);
2420
+ html += row('Entity ID', ev.entity_id);
2421
+ html += row('Actor / source', ev.actor);
2422
+ if(ev.details){
2423
+ var dStr = typeof ev.details==='string' ? ev.details : JSON.stringify(ev.details,null,2);
2424
+ html += '<div class="kv"><div class="k">Details</div><div class="v"><pre>'+esc(dStr)+'</pre></div></div>';
2425
+ }
2426
+ showModal('Audit · '+(ev.action||'event')+' · '+(ev.entity_id||ev.entity_type||''), html);
2427
+ }
2428
+ (function(){
2429
+ var chips = document.querySelectorAll('.rp-cat-chip');
2430
+ for(var i=0;i<chips.length;i++){
2431
+ (function(c){
2432
+ c.addEventListener('click', function(){
2433
+ for(var j=0;j<chips.length;j++) chips[j].classList.remove('active');
2434
+ c.classList.add('active');
2435
+ rpAuditFilter.category = c.getAttribute('data-cat')||'';
2436
+ pollAuditLog();
2437
+ });
2438
+ })(chips[i]);
2439
+ }
2440
+ var rb=$('rp-audit-refresh'); if(rb) rb.addEventListener('click', pollAuditLog);
2441
+ var tb=$('rp-audit-tbody'); if(tb) tb.addEventListener('click', function(e){
2442
+ var tr = e.target.closest ? e.target.closest('tr') : null; if(!tr) return;
2443
+ var idx = parseInt(tr.getAttribute('data-idx'),10);
2444
+ if(!isNaN(idx) && rpAuditLast[idx]) openAuditDetailModal(rpAuditLast[idx]);
2445
+ });
2446
+ })();
2447
+
2448
+ // ── Fleet sub-tab (uses the existing #rp-grid) ──
2449
+ // The Fleet sub-tab reuses the existing #rp-grid render in pollRobopark().
2450
+ // We add sparklines by overlaying on top of the existing cards.
2451
+ function pollFleet(){
2452
+ pollRobopark();
2453
+ }
2454
+ function drawRobotSparkline(canvas, sessions){
2455
+ if(!canvas || !canvas.getContext) return;
2456
+ var ctx = canvas.getContext('2d');
2457
+ var w = canvas.width = canvas.clientWidth;
2458
+ var h = canvas.height = canvas.clientHeight;
2459
+ ctx.clearRect(0,0,w,h);
2460
+ if(!sessions || sessions.length<2){
2461
+ ctx.fillStyle = 'rgba(158,147,180,0.4)';
2462
+ ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';
2463
+ ctx.fillText('no history', 6, h/2+3);
2464
+ return;
2465
+ }
2466
+ var counts = sessions.map(function(s){
2467
+ 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));
2468
+ });
2469
+ var mx = Math.max.apply(null, counts); if(mx<=0) mx=1;
2470
+ var pad = 2;
2471
+ var stepX = (w - pad*2) / (counts.length - 1);
2472
+ ctx.beginPath();
2473
+ for(var i=0;i<counts.length;i++){
2474
+ var x = pad + i*stepX;
2475
+ var y = h - pad - (counts[i]/mx) * (h - pad*2);
2476
+ if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
2477
+ }
2478
+ // gold gradient line
2479
+ try{
2480
+ var grad = ctx.createLinearGradient(0,0,0,h);
2481
+ grad.addColorStop(0, '#FFD86B');
2482
+ grad.addColorStop(1, '#E6B23C');
2483
+ ctx.strokeStyle = grad;
2484
+ }catch(e){ ctx.strokeStyle = '#E6B23C'; }
2485
+ ctx.lineWidth = 1.4;
2486
+ ctx.lineJoin = 'round';
2487
+ ctx.stroke();
2488
+ // fill underneath
2489
+ ctx.lineTo(pad+(counts.length-1)*stepX, h-pad);
2490
+ ctx.lineTo(pad, h-pad);
2491
+ ctx.closePath();
2492
+ ctx.fillStyle = 'rgba(230,178,60,0.18)';
2493
+ ctx.fill();
2494
+ }
2495
+
2496
+ // ── Overview sub-tab ──
2497
+ function pollOverview(){
2498
+ Promise.all([
2499
+ getJson('/robopark/api/sessions?active_only=false&limit=200').catch(function(){ return []; }),
2500
+ getJson('/robopark/api/sessions/stats').catch(function(){ return {}; }),
2501
+ getJson('/robopark/api/telemetry').catch(function(){ return {robots:[],totals:{}}; }),
2502
+ getJson('/robopark/api/history?limit=20').catch(function(){ return []; })
2503
+ ]).then(function(results){
2504
+ renderOverview({
2505
+ sessions: Array.isArray(results[0])?results[0]:[],
2506
+ stats: results[1]||{},
2507
+ telemetry: results[2]||{robots:[],totals:{}},
2508
+ history: Array.isArray(results[3])?results[3]:[]
2509
+ });
2510
+ }).catch(function(){ /* soft fail */ });
2511
+ }
2512
+ function renderOverview(data){
2513
+ var sessions = data.sessions||[];
2514
+ var stats = data.stats||{};
2515
+ var telemetry = data.telemetry||{};
2516
+ var totals = telemetry.totals || {};
2517
+ var robots = Array.isArray(telemetry.robots)?telemetry.robots:[];
2518
+ // active sessions
2519
+ var active = 0; for(var i=0;i<sessions.length;i++){ if(!sessions[i].ended_at) active++; }
2520
+ var totalTrig = totals.trigger_count!=null?totals.trigger_count:stats.total_sessions;
2521
+ var today = stats.today_sessions!=null?stats.today_sessions:0;
2522
+ var avgLat = (function(){
2523
+ var vals=[];
2524
+ for(var i=0;i<robots.length;i++){ if(robots[i].avg_latency_ms!=null) vals.push(robots[i].avg_latency_ms); }
2525
+ if(!vals.length) return null;
2526
+ return Math.round(vals.reduce(function(a,b){return a+b;},0)/vals.length);
2527
+ })();
2528
+ var dropRate = totals.drop_rate!=null?totals.drop_rate:null;
2529
+ var total = stats.total_sessions!=null?stats.total_sessions:0;
2530
+ // uptime: ratio of robots connected — best effort (we don't have an uptime API,
2531
+ // so we fall back to "robot count online" derived from telemetry robots)
2532
+ // Render the KPI strip
2533
+ function kpi(l,v,cls,sub){
2534
+ 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>';
2535
+ }
2536
+ var html = '';
2537
+ html += kpi('Triggers (total)', totalTrig!=null?totalTrig:'—', 'info', robots.length+' robot'+(robots.length===1?'':'s'));
2538
+ html += kpi('Today', today, today>0?'ok':'', 'sessions today');
2539
+ html += kpi('Active', active, active>0?'ok':'', 'live right now');
2540
+ html += kpi('Avg latency', avgLat!=null?(avgLat+'ms'):'—', avgLat==null?'':avgLat>=500?'bad':avgLat>=200?'warn':'ok', 'across all robots');
2541
+ html += kpi('Drop rate', dropRate!=null?Math.round(dropRate*100)+'%':'—', dropRate==null?'':dropRate>=0.2?'bad':dropRate>=0.05?'warn':'ok', 'failed / total');
2542
+ html += kpi('Total sessions', total, '', stats.avg_duration_seconds!=null?('avg '+rpFmtDuration(stats.avg_duration_seconds)):'');
2543
+ $('rp-overview-kpis').innerHTML = html;
2544
+ // mini chart: bucket sessions by hour for the last 60 minutes from started_at
2545
+ var buckets = new Array(12).fill(0); // 12 buckets × 5min = 60min
2546
+ var now = Date.now();
2547
+ for(var i=0;i<sessions.length;i++){
2548
+ var t = sessions[i].started_at ? Date.parse(sessions[i].started_at) : 0;
2549
+ if(!t) continue;
2550
+ var diff = now - t;
2551
+ if(diff<0 || diff>3600e3) continue;
2552
+ var idx = 11 - Math.floor(diff/(5*60e3));
2553
+ if(idx>=0 && idx<12) buckets[idx]++;
2554
+ }
2555
+ drawOverviewChart($('rp-overview-chart'), buckets);
2556
+ // top 3 robots by session count
2557
+ var ranked = robots.slice().sort(function(a,b){ return (b.sessions_total||0)-(a.sessions_total||0); });
2558
+ var top3 = ranked.slice(0,3);
2559
+ var top = $('rp-overview-top'), topMeta = $('rp-overview-top-meta');
2560
+ if(topMeta) topMeta.textContent = ranked.length?('of '+ranked.length+' robots'):'';
2561
+ if(!top3.length){ top.innerHTML = '<div class="empty" style="grid-column:1/-1;">No robot telemetry yet.</div>'; }
2562
+ else {
2563
+ var thtml='';
2564
+ for(var i=0;i<top3.length;i++){
2565
+ var x = top3[i];
2566
+ var name = x.name || x.id || 'robot';
2567
+ var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||name)});
2568
+ thtml += '<div class="rp-card" style="padding:0.55rem 0.7rem;">'
2569
+ + '<div class="rp-card-top">'
2570
+ + (avatar?'<div class="rp-card-img"><img alt="'+esc(name)+'" src="'+esc(avatar+QS)+'"></div>':'')
2571
+ + '<div style="min-width:0;flex:1;"><h4 style="margin:0;font-size:0.78rem;">'+esc(name)+'</h4>'
2572
+ + '<div class="rp-metrics" style="grid-template-columns:repeat(3,1fr);gap:0.2rem 0.5rem;margin-top:0.3rem;">'
2573
+ + '<div class="rp-m"><span class="v" style="font-size:0.9rem;">'+(x.sessions_total||0)+'</span><span class="l">sessions</span></div>'
2574
+ + '<div class="rp-m"><span class="v" style="font-size:0.9rem;">'+(x.trigger_count||0)+'</span><span class="l">triggers</span></div>'
2575
+ + '<div class="rp-m"><span class="v" style="font-size:0.9rem;">'+(x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—')+'</span><span class="l">latency</span></div>'
2576
+ + '</div></div></div></div>';
2577
+ }
2578
+ top.innerHTML = thtml;
2579
+ }
2580
+ // recent activity stream
2581
+ var stream = $('rp-overview-stream'), smeta = $('rp-overview-stream-meta');
2582
+ if(smeta) smeta.textContent = 'last '+(data.history?data.history.length:0);
2583
+ var hist = data.history || [];
2584
+ if(!hist.length){ stream.innerHTML = '<div class="line"><span class="d" style="color:var(--muted)">no recent activity</span></div>'; }
2585
+ else {
2586
+ var hhtml='';
2587
+ for(var i=0;i<hist.length;i++){
2588
+ var ev = hist[i];
2589
+ var ts = ev.timestamp;
2590
+ var cat = ev.category || '';
2591
+ var act = ev.action || '';
2592
+ var ent = (ev.entity_type||'')+(ev.entity_id?(' · '+ev.entity_id):'');
2593
+ var det = ev.details || '';
2594
+ if(typeof det==='object') det = JSON.stringify(det);
2595
+ hhtml += '<div class="line"><span class="t">'+esc(clock(ts))+'</span>'
2596
+ + '<span class="ic">'+esc(rpCategoryIcon(cat))+'</span>'
2597
+ + '<span class="n" title="'+esc(cat)+'">'+esc(String(cat).slice(0,8))+'</span>'
2598
+ + '<span class="d"><b>'+esc(act)+'</b> '+esc(ent)+(det?(' — '+esc(String(det).slice(0,60))):'')+'</span></div>';
2599
+ }
2600
+ stream.innerHTML = hhtml;
2601
+ }
2602
+ // update overview "now" indicator
2603
+ var nowEl = $('rp-overview-now'); if(nowEl) nowEl.textContent = sessions.length+' recent · '+active+' active';
2604
+ }
2605
+ function drawOverviewChart(canvas, buckets){
2606
+ if(!canvas || !canvas.getContext) return;
2607
+ var ctx = canvas.getContext('2d');
2608
+ var dpr = Math.max(1, Math.min(2, window.devicePixelRatio||1));
2609
+ var w = canvas.clientWidth, h = canvas.clientHeight;
2610
+ canvas.width = w*dpr; canvas.height = h*dpr;
2611
+ ctx.setTransform(dpr,0,0,dpr,0,0);
2612
+ ctx.clearRect(0,0,w,h);
2613
+ var padL=20, padR=8, padT=8, padB=14;
2614
+ var plotW = w - padL - padR, plotH = h - padT - padB;
2615
+ var mx = Math.max(1, Math.max.apply(null, buckets));
2616
+ // y grid (4 lines)
2617
+ ctx.strokeStyle = 'rgba(255,255,255,0.07)';
2618
+ ctx.lineWidth = 1;
2619
+ for(var g=0;g<=4;g++){
2620
+ var y = padT + (plotH * g/4);
2621
+ ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(padL+plotW,y); ctx.stroke();
2622
+ ctx.fillStyle = 'rgba(158,147,180,0.6)';
2623
+ ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';
2624
+ ctx.fillText(Math.round(mx-(mx*g/4)), 2, y+3);
2625
+ }
2626
+ // bars
2627
+ var n = buckets.length, barW = plotW/n;
2628
+ for(var i=0;i<n;i++){
2629
+ var v = buckets[i];
2630
+ var bh = (v/mx)*plotH;
2631
+ var x = padL + i*barW + 1;
2632
+ var y = padT + (plotH - bh);
2633
+ try{
2634
+ var grad = ctx.createLinearGradient(0,y,0,y+bh);
2635
+ grad.addColorStop(0, 'rgba(255,216,107,0.95)');
2636
+ grad.addColorStop(1, 'rgba(230,178,60,0.55)');
2637
+ ctx.fillStyle = grad;
2638
+ }catch(e){ ctx.fillStyle = '#E6B23C'; }
2639
+ ctx.fillRect(x, y, Math.max(1,barW-2), bh);
2640
+ }
2641
+ // x axis labels
2642
+ ctx.fillStyle = 'rgba(158,147,180,0.7)';
2643
+ ctx.font = '9px ui-monospace,Menlo,Consolas,monospace';
2644
+ ctx.fillText('-60m', padL, h-2);
2645
+ var mid = Math.round(n/2);
2646
+ ctx.fillText('-30m', padL + mid*barW, h-2);
2647
+ ctx.fillText('now', padL + (n-1)*barW - 16, h-2);
2648
+ }
2649
+
2650
+ // ── init sub-tabs when the robopark tab is opened ──
2651
+ // We call buildRpSubTabs() the first time the user switches to the
2652
+ // Sessions & Telemetry main tab. From then on, setRpSubTab(name) reuses
2653
+ // it whenever the user picks a different sub-tab.
2654
+ var rpSubTabsInited = false;
2655
+ function initRpSubTabs(){
2656
+ if(rpSubTabsInited) return;
2657
+ rpSubTabsInited = true;
2658
+ buildRpSubTabs();
2659
+ }
2660
+
1241
2661
  // ══════════════════════════════════════════════════════════════════
1242
2662
  // Park — isometric live map of the mesh
1243
2663
  // Real data: /fed/status nodes (placement + load) + /fed/stream frames
@@ -1247,12 +2667,39 @@ button.act:disabled{opacity:0.5;cursor:default;}
1247
2667
  var Park = (function(){
1248
2668
  var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;
1249
2669
  var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
1250
- var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
2670
+ var _lastScan = {};
2671
+ // Latest per-robot scheduler telemetry, keyed by scheduler robot name
2672
+ // (e.g. "jaguar") or id. Updated by pollRobopark() on every refresh
2673
+ // (every 3s) via Park.updateTelemetry(...). The Park node → telemetry
2674
+ // match is done in lookupTelemetry() by name.
2675
+ var _telemetryByName = {};
2676
+ // Remember the most recent in-session and ended timestamps per robot
2677
+ // so the "ended" ring can fade out for ~4s after a session ends,
2678
+ // and the "live/talking" status can persist briefly across polls.
2679
+ var _statusMem = {}; // name -> { lastActiveAt, lastEndedAt, prevReadiness }
2680
+ var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, deviceSelectorsReady=false;
1251
2681
  var previewOn=false, previewTimer=null; // on-demand operator camera preview
1252
2682
  var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
1253
- var lkRoom=null, micAnalyser=null, audioCtx=null;
2683
+ var lkRoom=null, micAnalyser=null, audioCtx=null, lkAudioEls=[];
1254
2684
  var imgs={}; // preloaded robot concept avatars
1255
2685
  var TW=44, TH=22, PARK_W=15, PARK_D=8;
2686
+ // Plaza footprint and parking lot (upper-right corner of the plaza)
2687
+ var P_X0=0.2, P_Y0=-0.10, P_X1=PARK_W-0.2, P_Y1=PARK_D-0.45;
2688
+ // Plaza outline as a polygon — top edge stays straight, bottom-left dips up where the pond
2689
+ // cuts into the plaza, and the right edge curves slightly inward to match the aerial.
2690
+ var PLAZA_POLY=[
2691
+ [0.4, -0.10],
2692
+ [8.6, -0.10],
2693
+ [8.6, 1.7 ],
2694
+ [14.6, 1.7 ],
2695
+ [14.6, 5.5 ],
2696
+ [3.0, 5.5 ],
2697
+ [2.5, 5.0 ],
2698
+ [0.4, 4.5 ]
2699
+ ];
2700
+ var LOT_X0=9.0, LOT_Y0=-0.30, LOT_X1=14.8, LOT_Y1=1.6;
2701
+ // Beach strip at the very bottom edge of the park
2702
+ var BEACH_Y0=PARK_D-0.45, BEACH_Y1=PARK_D+0.3;
1256
2703
  // The real park (from the aerial photo): named robot pads at their ground
1257
2704
  // positions along the long Shenzhen Bay promenade plaza, plus palms, pond,
1258
2705
  // street and stage. A mesh node binds to a pad when its display name matches
@@ -1260,18 +2707,121 @@ button.act:disabled{opacity:0.5;cursor:default;}
1260
2707
  // SLOTS is now rebuilt from scheduler character_presets; keep the same shape.
1261
2708
  var SLOTS = parkSlots.slice();
1262
2709
  var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};
1263
- // Palm positions tuned to the aerial: clusters along the back edge and
1264
- // scattered down the center of the paved strip.
2710
+ // Palm positions: two staggered rows on the plaza, between the robot pad columns.
2711
+ // Robot pads in normalized 0..1: HUB(0.06,0.16), VOLT(0.18,0.26), TESLA(0.38,0.30),
2712
+ // JAGUAR(0.58,0.34), VIXEN(0.40,0.68), PANDA(0.82,0.52)
2713
+ // Back row of palms at y=0.22, between the robots at the top of the plaza.
2714
+ // Front row at y=0.62, between/around the lower robots (VIXEN/PANDA).
2715
+ // Plus 2 grass palms near the upper-left for the tent area.
1265
2716
  var PALMS=[
1266
- [0.10,0.38],[0.14,0.55],[0.18,0.72],[0.22,0.30],[0.26,0.48],[0.30,0.66],
1267
- [0.36,0.24],[0.40,0.42],[0.44,0.60],[0.50,0.26],[0.54,0.46],[0.58,0.64],
1268
- [0.66,0.30],[0.70,0.50],[0.74,0.68],[0.80,0.36],[0.84,0.56],[0.90,0.40],[0.94,0.62],
1269
- [0.62,0.80],[0.76,0.84],[0.35,0.82]
2717
+ [0.30,0.18],[0.50,0.18],[0.70,0.18],[0.90,0.18],
2718
+ [0.18,0.55],[0.28,0.55],[0.50,0.55],[0.65,0.55],
2719
+ [0.05,0.42],[0.05,0.62]
1270
2720
  ];
1271
2721
  // Pond / curved water feature at the lower-left of the plaza.
1272
- var POND={cx:0.28*15, cy:0.96*8, rx:3.4, ry:1.6};
2722
+ var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};
1273
2723
  // Street traffic along the top road (yellow taxis + private cars).
1274
2724
  var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];
2725
+ // ── Walking people — looping sprites that stroll along fixed plaza paths.
2726
+ // Each walker is a small canvas-drawn figure (head + body + two
2727
+ // swinging legs + swinging arms) with a vertical bob and a slight
2728
+ // body lean. A path is a list of [wx, wy] world points; the walker
2729
+ // eases between them at a per-walker speed, and "greets" any robot
2730
+ // pad it passes closely by pausing for ~1.4s in a wave pose while
2731
+ // the robot's pulse fires. Kept simple on purpose: no canvas
2732
+ // pathfinding, no random spawn, no state machine beyond a
2733
+ // pauseUntil timer. ──
2734
+ // Paths are aligned with the plaza chessboard so walkers appear to
2735
+ // step across the tiles. Coordinates are world units; the iso()
2736
+ // projection in drawWalker() handles the perspective.
2737
+ //
2738
+ // Six walkers total, distributed so at any moment at least a few are
2739
+ // visible across the plaza. segDurMs is how long a single segment
2740
+ // should take at this walker's pace — converting to a per-frame
2741
+ // step value happens in tickWalkers() so speeds are frame-rate
2742
+ // independent. ~3-4 seconds per segment reads as a calm stroll;
2743
+ // shorter = brisker.
2744
+ var WALKER_PATHS = [
2745
+ // Top of plaza — slow stroller between the hub and front row of robots. L→R.
2746
+ { segDurMs: 5200, shirt:'#5b9bd5', pants:'#2a3f5c', pauseAt:[[3.5,0.4]] },
2747
+ // Lower half — loops past VIXEN and PANDA pads. R→L.
2748
+ { segDurMs: 4200, shirt:'#c95b6f', pants:'#3a2a3a', pauseAt:[[11.0,5.1]] },
2749
+ // Mid-plaza — wanders past TESLA. L→R.
2750
+ { segDurMs: 4600, shirt:'#7fb069', pants:'#3d4f2c', pauseAt:[[5.6,1.6]] },
2751
+ // Back of plaza — a quiet walker along the upper tiles. R→L.
2752
+ { segDurMs: 6000, shirt:'#d68a3a', pants:'#4a3a26', pauseAt:[[8.0,-0.1]] },
2753
+ // Diagonal — crosses the plaza between JAGUAR and PANDA. L→R.
2754
+ { segDurMs: 5000, shirt:'#a26fb0', pants:'#3a2a4a', pauseAt:[[7.0,3.4]] },
2755
+ // Slow elder at the front edge of the plaza. R→L.
2756
+ { segDurMs: 7000, shirt:'#e8c39a', pants:'#5a4a2a', pauseAt:[[9.5,2.6]] },
2757
+ ];
2758
+ // Per-walker offsets so they don't all start at the same phase of
2759
+ // their loop — makes the plaza feel populated at all times rather
2760
+ // than empty for the first few seconds. Offsets are fractions of
2761
+ // total path length (0..1) so they spread evenly.
2762
+ var WALKER_PHASE_OFFSETS = [0.00, 0.18, 0.42, 0.66, 0.82, 0.30];
2763
+ // Build the world-space path for each walker spec. Path idx chooses
2764
+ // the route; all paths live on the plaza chessboard so walkers look
2765
+ // like they step across the tiles.
2766
+ function buildWalkerPath(spec){
2767
+ if(spec.idx === 0){
2768
+ 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]];
2769
+ }
2770
+ if(spec.idx === 1){
2771
+ 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]];
2772
+ }
2773
+ if(spec.idx === 2){
2774
+ 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]];
2775
+ }
2776
+ if(spec.idx === 3){
2777
+ 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]];
2778
+ }
2779
+ if(spec.idx === 4){
2780
+ 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]];
2781
+ }
2782
+ // idx 5
2783
+ 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]];
2784
+ }
2785
+ // Cumulative arc-length table for each walker's polyline, used to
2786
+ // convert a phase offset (0..1) into a (seg, segT) starting position
2787
+ // and to drive the per-frame step so all walkers cover ground at a
2788
+ // consistent world-space pace regardless of segment length.
2789
+ function buildPathLengths(path){
2790
+ var lens = [0];
2791
+ var total = 0;
2792
+ for(var i=0; i<path.length-1; i++){
2793
+ var dx = path[i+1][0]-path[i][0], dy = path[i+1][1]-path[i][1];
2794
+ total += Math.sqrt(dx*dx + dy*dy);
2795
+ lens.push(total);
2796
+ }
2797
+ return { lens: lens, total: total || 1 };
2798
+ }
2799
+ function pathPosAt(pathInfo, dist){
2800
+ // Returns {seg, t} for the given arc-length distance along the path.
2801
+ var d = ((dist % pathInfo.total) + pathInfo.total) % pathInfo.total;
2802
+ for(var i=0; i<pathInfo.lens.length-1; i++){
2803
+ if(d <= pathInfo.lens[i+1]){
2804
+ var segLen = pathInfo.lens[i+1] - pathInfo.lens[i] || 1;
2805
+ return { seg: i, t: (d - pathInfo.lens[i]) / segLen };
2806
+ }
2807
+ }
2808
+ return { seg: 0, t: 0 };
2809
+ }
2810
+ var walkers = WALKER_PATHS.map(function(spec, idx){
2811
+ var path = buildWalkerPath({idx:idx});
2812
+ var info = buildPathLengths(path);
2813
+ var phase = WALKER_PHASE_OFFSETS[idx % WALKER_PHASE_OFFSETS.length] || 0;
2814
+ var startPos = pathPosAt(info, phase * info.total);
2815
+ return {
2816
+ idx: idx,
2817
+ spec: spec,
2818
+ path: path,
2819
+ info: info,
2820
+ distance: startPos.seg + startPos.t, // current distance (in segments) along the path
2821
+ pauseUntil: 0, // perf.now() ms; walker holds while now < pauseUntil
2822
+ nextGreet: 0, // index into spec.pauseAt, for the next pause to fire
2823
+ };
2824
+ });
1275
2825
 
1276
2826
  function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
1277
2827
  function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }
@@ -1283,60 +2833,147 @@ button.act:disabled{opacity:0.5;cursor:default;}
1283
2833
  if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';
1284
2834
  return 'peer';
1285
2835
  }
1286
- function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
1287
- function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
1288
- function normalizeAvatarPath(path){
1289
- var p=(path==null?'':String(path)).trim();
1290
- if(!p) return '';
1291
- if(/^https?:\/\//i.test(p) || p.indexOf('data:')===0 || p.indexOf('/')===0) return p;
1292
- return '/' + p.replace(/^\.?\//, '');
1293
- }
1294
- function slotAliases(slot){
1295
- var out=[], seen={};
1296
- function add(v){
1297
- var s=(v==null?'':String(v)).trim().toLowerCase();
1298
- if(!s || seen[s]) return;
1299
- seen[s]=1;
1300
- out.push(s);
1301
- }
1302
- add(slot && slot.label);
1303
- add(slot && slot.id);
1304
- add(slot && slot.name);
1305
- add(slot && slot.sub);
1306
- (slot && slot.keys ? slot.keys : []).forEach(add);
1307
- return out;
1308
- }
1309
- function slotForNode(n){
1310
- var text=((n && (n.label || n.name || n.id || ''))+'').toLowerCase();
1311
- if(!text) return null;
1312
- var best=null;
1313
- for(var i=0;i<SLOTS.length;i++){
1314
- var slot=SLOTS[i], aliases=slotAliases(slot);
1315
- for(var j=0;j<aliases.length;j++){
1316
- var key=aliases[j];
1317
- if(!key) continue;
1318
- if(text===key || text.indexOf(key)>-1 || key.indexOf(text)>-1) return slot;
1319
- if(!best && key.length>2 && text.indexOf(key.replace(/[^a-z0-9]/g,''))>-1) best=slot;
1320
- }
1321
- }
1322
- return best;
1323
- }
1324
- function avatarSrcForNode(n){
1325
- var slot=slotForNode(n);
1326
- return normalizeAvatarPath(slot && slot.img);
1327
- }
1328
- function avatarImageForNode(n){
1329
- var slot=slotForNode(n);
1330
- if(!slot) return null;
1331
- var aliases=slotAliases(slot);
1332
- var src=normalizeAvatarPath(slot.img);
1333
- if(src) aliases.unshift(src);
1334
- for(var i=0;i<aliases.length;i++){ var img=imgs[aliases[i]]; if(img) return img; }
1335
- return null;
1336
- }
1337
-
1338
- // Bind mesh nodes onto the fixed park pads; unmatched pads render as
2836
+ function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
2837
+ function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
2838
+ function normalizeAvatarPath(path){
2839
+ var p=(path==null?'':String(path)).trim();
2840
+ if(!p) return '';
2841
+ if(/^https?:\\/\\//i.test(p) || p.indexOf('data:')===0 || p.indexOf('/')===0) return p;
2842
+ return '/' + p.replace(/^\\.?\\//, '');
2843
+ }
2844
+ function slotAliases(slot){
2845
+ var out=[], seen={};
2846
+ function add(v){
2847
+ var s=(v==null?'':String(v)).trim().toLowerCase();
2848
+ if(!s || seen[s]) return;
2849
+ seen[s]=1;
2850
+ out.push(s);
2851
+ }
2852
+ add(slot && slot.label);
2853
+ add(slot && slot.id);
2854
+ add(slot && slot.name);
2855
+ add(slot && slot.sub);
2856
+ (slot && slot.keys ? slot.keys : []).forEach(add);
2857
+ return out;
2858
+ }
2859
+ function slotForNode(n){
2860
+ var text=((n && (n.label || n.name || n.id || ''))+'').toLowerCase();
2861
+ if(!text) return null;
2862
+ var best=null;
2863
+ for(var i=0;i<SLOTS.length;i++){
2864
+ var slot=SLOTS[i], aliases=slotAliases(slot);
2865
+ for(var j=0;j<aliases.length;j++){
2866
+ var key=aliases[j];
2867
+ if(!key) continue;
2868
+ if(text===key || text.indexOf(key)>-1 || key.indexOf(text)>-1) return slot;
2869
+ if(!best && key.length>2 && text.indexOf(key.replace(/[^a-z0-9]/g,''))>-1) best=slot;
2870
+ }
2871
+ }
2872
+ return best;
2873
+ }
2874
+ function avatarSrcForNode(n){
2875
+ var slot=slotForNode(n);
2876
+ return normalizeAvatarPath(slot && slot.img);
2877
+ }
2878
+ function avatarImageForNode(n){
2879
+ var slot=slotForNode(n);
2880
+ if(!slot) return null;
2881
+ var aliases=slotAliases(slot);
2882
+ var src=normalizeAvatarPath(slot.img);
2883
+ if(src) aliases.unshift(src);
2884
+ for(var i=0;i<aliases.length;i++){ var img=imgs[aliases[i]]; if(img) return img; }
2885
+ return null;
2886
+ }
2887
+
2888
+ // Bind mesh nodes onto the fixed park pads; unmatched pads render as
1339
2889
  // offline placeholders so the whole park is always visible.
2890
+ // ── Per-robot scheduler telemetry → status ──
2891
+ // pollRobopark() pushes the latest /robopark/api/telemetry rows into
2892
+ // Park.updateTelemetry(); we key them by robot name. layout() then
2893
+ // looks up each robot node by its slot aliases and attaches a
2894
+ // status + telemetry field used by drawRobot() to drive the
2895
+ // status animations.
2896
+ function updateTelemetry(map){
2897
+ _telemetryByName = map || {};
2898
+ }
2899
+ function lookupTelemetry(node){
2900
+ if(!node) return null;
2901
+ // The hub is never a per-robot telemetry row — only robot slots are.
2902
+ if(node.kind !== 'robot') return null;
2903
+ // Try every alias the slot was built with: slot.label, slot.id,
2904
+ // slot.sub, slot.keys, and the matched mesh node's displayName.
2905
+ var slot = null;
2906
+ for(var i=0;i<SLOTS.length;i++){ if(SLOTS[i].label === node.label){ slot = SLOTS[i]; break; } }
2907
+ var aliases = [];
2908
+ if(slot) aliases = aliases.concat(slotAliases(slot));
2909
+ if(node.name) aliases.push(String(node.name).toLowerCase());
2910
+ if(node.telemKeys) for(var tk=0; tk<node.telemKeys.length; tk++) aliases.push(node.telemKeys[tk]);
2911
+ // Try each alias as a case-insensitive key against the telemetry map.
2912
+ for(var ai=0; ai<aliases.length; ai++){
2913
+ var a = aliases[ai];
2914
+ if(!a) continue;
2915
+ for(var key in _telemetryByName){
2916
+ if(!Object.prototype.hasOwnProperty.call(_telemetryByName, key)) continue;
2917
+ var kl = String(key).toLowerCase();
2918
+ if(kl === a || kl.indexOf(a) >= 0 || a.indexOf(kl) >= 0) return _telemetryByName[key];
2919
+ }
2920
+ }
2921
+ return null;
2922
+ }
2923
+ // Derive a coarse status string + a few extra flags from a
2924
+ // scheduler telemetry row. Status is one of: 'offline' | 'ready'
2925
+ // | 'live' | 'talking' | 'ended'. We also expose the raw readiness
2926
+ // string so the existing label can still read it.
2927
+ function computeStatus(tele, node){
2928
+ if(!node || node.kind !== 'robot') return { status:'idle', readiness:null };
2929
+ if(!node.connected) return { status:'offline', readiness:null };
2930
+ var mem = _statusMem[node.id] || (_statusMem[node.id] = { lastActiveAt:0, lastEndedAt:0, prevReadiness:null });
2931
+ var readiness = tele ? (tele.readiness_code || 'unknown') : null;
2932
+ var hasSession = !!(tele && (tele.current_session_id || tele.active_session || tele.in_session || tele.session_active));
2933
+ var sessionAge = tele ? (tele.session_age_seconds != null ? tele.session_age_seconds : null) : null;
2934
+ var activityAge = tele ? (tele.session_activity_age_seconds != null ? tele.session_activity_age_seconds : null) : null;
2935
+ var now = performance.now();
2936
+ // mark transitions
2937
+ if(readiness === 'in_session' && mem.prevReadiness !== 'in_session'){
2938
+ mem.lastActiveAt = now;
2939
+ }
2940
+ if(mem.prevReadiness === 'in_session' && readiness !== 'in_session'){
2941
+ mem.lastEndedAt = now;
2942
+ }
2943
+ mem.prevReadiness = readiness;
2944
+ // decide status
2945
+ var status = 'ready';
2946
+ if(hasSession){
2947
+ // active session. "talking" if the session has had activity
2948
+ // within the last 2.5s; otherwise "live" (idle but connected).
2949
+ if(activityAge != null && activityAge <= 2.5) status = 'talking';
2950
+ else status = 'live';
2951
+ } else if(readiness === 'in_session'){
2952
+ // server says in_session but we don't have the session id on
2953
+ // this row — treat as live.
2954
+ status = 'live';
2955
+ } else if(readiness && /stuck_session|stale_session/.test(readiness)){
2956
+ status = 'live'; // still a session going on, just unhealthy
2957
+ } else if(readiness === 'ready'){
2958
+ // Show "ended" ring for ~4s after a session ends, then drop back
2959
+ // to the calm "ready" (waiting) state.
2960
+ if(mem.lastEndedAt && (now - mem.lastEndedAt) < 4000) status = 'ended';
2961
+ else status = 'ready';
2962
+ } else if(readiness && /production_off|production_off_robot|device_missing|server_unavailable|heartbeat_stale/.test(readiness)){
2963
+ status = 'ready'; // still ready to talk, just gated
2964
+ }
2965
+ return {
2966
+ status: status,
2967
+ readiness: readiness,
2968
+ readinessMessage: tele ? tele.readiness_message : null,
2969
+ sessionAge: sessionAge,
2970
+ activityAge: activityAge,
2971
+ triggerCount: tele ? tele.trigger_count : null,
2972
+ drops: tele ? tele.drops : null,
2973
+ dropRate: tele ? tele.drop_rate : null,
2974
+ };
2975
+ }
2976
+
1340
2977
  function layout(src){
1341
2978
  var list=(src||[]).slice(), used={}, out=[];
1342
2979
  function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }
@@ -1349,7 +2986,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1349
2986
  }
1350
2987
  var id = match?match.nodeId:('pad:'+slot.label);
1351
2988
  if(match) used[match.nodeId]=1;
1352
- return {
2989
+ var node = {
1353
2990
  id:id, kind:kind, label:slot.label,
1354
2991
  name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),
1355
2992
  sub: match?(slot.sub||''):'not joined',
@@ -1360,6 +2997,19 @@ button.act:disabled{opacity:0.5;cursor:default;}
1360
2997
  telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
1361
2998
  wx:wx, wy:wy, _hit:prevHit(id)
1362
2999
  };
3000
+ // Attach a status derived from the latest scheduler telemetry
3001
+ // (push-poll, so it can lag up to 3s behind /fed/status).
3002
+ if(kind === 'robot'){
3003
+ var tele = lookupTelemetry(node);
3004
+ var st = computeStatus(tele, node);
3005
+ node.telemetry = tele;
3006
+ node.status = st.status;
3007
+ node.readiness = st.readiness;
3008
+ node.readinessMessage = st.readinessMessage;
3009
+ node.sessionAge = st.sessionAge;
3010
+ node.activityAge = st.activityAge;
3011
+ }
3012
+ return node;
1363
3013
  }
1364
3014
  var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));
1365
3015
  SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });
@@ -1462,64 +3112,801 @@ button.act:disabled{opacity:0.5;cursor:default;}
1462
3112
  if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
1463
3113
  }
1464
3114
  function drawGround(){
1465
- // grass base
1466
- var g=X.createLinearGradient(0,0,0,H); g.addColorStop(0,'rgba(38,68,44,0.55)'); g.addColorStop(1,'rgba(22,44,28,0.5)');
1467
- poly([[-2,-2.2],[PARK_W+2,-2.2],[PARK_W+2,PARK_D+2],[-2,PARK_D+2]], g, 'rgba(120,180,120,0.10)',1);
1468
- // street band along the far edge + dashed centerline + cars
1469
- poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.88)', 'rgba(255,255,255,0.08)',1);
1470
- X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);
3115
+ var z=cam.z;
3116
+ // ── grass base — multi-stop radial sun-lit gradient on a slightly larger field ──
3117
+ // Field is now sized to the plaza footprint + a small margin. The previous
3118
+ // version extended from x=-0.5..PARK_W+0.5, which left a wide empty green
3119
+ // strip on either side of the plaza; trimmed here to remove that overhang.
3120
+ var parkCx=PARK_W/2, parkCy=PARK_D/2;
3121
+ var gCent=iso(parkCx,parkCy);
3122
+ var gRad=X.createRadialGradient(gCent.x,gCent.y,12*z,gCent.x,gCent.y,Math.max(W,H)*0.62);
3123
+ gRad.addColorStop(0,'rgba(154,196,108,0.95)');
3124
+ gRad.addColorStop(0.45,'rgba(86,142,80,0.95)');
3125
+ gRad.addColorStop(1,'rgba(44,98,52,0.95)');
3126
+ // grass field — tight margin around the plaza + lot, NOT covering the road or the beach
3127
+ var GF_X0=0.0, GF_X1=PARK_W, GF_Y0=-0.32, GF_Y1=PARK_D-0.55;
3128
+ 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);
3129
+ // ── "mowed stripes" effect — wide diagonal bands of alternating green tones ──
3130
+ // Drawn as full-width rectangles in world space, slightly rotated via a parallelogram skew.
3131
+ X.save();
3132
+ var stripeColors=['rgba(108,162,90,0.45)','rgba(86,140,72,0.35)'];
3133
+ var stripeCount=5;
3134
+ var stripeW=(GF_X1-GF_X0+GF_Y1-GF_Y0+2)/stripeCount;
3135
+ for(var si=0;si<stripeCount;si++){
3136
+ var off=si*stripeW - ((GF_X1-GF_X0)+(GF_Y1-GF_Y0))*0.5;
3137
+ // Build a parallelogram in world coords that runs from left-bottom to right-top
3138
+ // at a slight angle (skewed +X, -Y direction) — looks like a stadium mowed lawn.
3139
+ // Tightly fitted to the grass field bounds.
3140
+ var p1=[GF_X0 + off, GF_Y1];
3141
+ var p2=[GF_X0 + off + 1.0, GF_Y0];
3142
+ var p3=[GF_X0 + off + 1.0 + (GF_X1-GF_X0), GF_Y0];
3143
+ var p4=[GF_X0 + off + (GF_X1-GF_X0), GF_Y1];
3144
+ poly([p1,p2,p3,p4], stripeColors[si%2], null, 0);
3145
+ }
3146
+ X.restore();
3147
+ // ── outer hedge / foliage border just inside the grass field ──
3148
+ // Slightly ragged inner edge: extra points along each side to break the straight line.
3149
+ // The hedge wraps the grass field, which now hugs the plaza footprint (no side overhang).
3150
+ var hedgeW=0.9; // world units thick
3151
+ var pTL=[GF_X0,GF_Y0], pTR=[GF_X1,GF_Y0], pBR=[GF_X1,GF_Y1], pBL=[GF_X0,GF_Y1];
3152
+ // Build a ragged inner-edge polygon: along the inner edge we add jitter points.
3153
+ // We use 4 sides, each with 4 extra points (5 segments per side), jitter ±0.18.
3154
+ function jx(seed){ return ((Math.sin(seed*12.9898)*43758.5453)%1+1)%1; }
3155
+ function hedgeInnerPts(){
3156
+ // Returns an inner ring (hedge width hedgeW from outer), starting at top-left and going CW.
3157
+ var pts=[];
3158
+ // top edge: from pTL toward pTR (jitter along y)
3159
+ var segs=6;
3160
+ for(var i=0;i<=segs;i++){
3161
+ var t=i/segs;
3162
+ var x=pTL[0]+(pTR[0]-pTL[0])*t;
3163
+ var y=pTL[1]+hedgeW + (jx(i*3+1)-0.5)*0.34;
3164
+ pts.push([x,y]);
3165
+ }
3166
+ // right edge: from pTR toward pBR (jitter along x)
3167
+ for(var i2=1;i2<=segs;i2++){
3168
+ var t2=i2/segs;
3169
+ var x2=pTR[0]-hedgeW + (jx(i2*5+7)-0.5)*0.34;
3170
+ var y2=pTR[1]+(pBR[1]-pTR[1])*t2;
3171
+ pts.push([x2,y2]);
3172
+ }
3173
+ // bottom edge: from pBR toward pBL (jitter along y, reversed)
3174
+ for(var i3=1;i3<=segs;i3++){
3175
+ var t3=i3/segs;
3176
+ var x3=pBR[0]+(pBL[0]-pBR[0])*t3;
3177
+ var y3=pBR[1]-hedgeW + (jx(i3*7+13)-0.5)*0.34;
3178
+ pts.push([x3,y3]);
3179
+ }
3180
+ // left edge: from pBL toward pTL (jitter along x, reversed)
3181
+ for(var i4=1;i4<segs;i4++){
3182
+ var t4=i4/segs;
3183
+ var x4=pBL[0]+hedgeW + (jx(i4*11+19)-0.5)*0.34;
3184
+ var y4=pBL[1]+(pTL[1]-pBL[1])*t4;
3185
+ pts.push([x4,y4]);
3186
+ }
3187
+ return pts;
3188
+ }
3189
+ var innerRing=hedgeInnerPts();
3190
+ // full grass outer polygon = outer rect, then inner ring (use even-odd via closePath)
3191
+ X.save();
3192
+ X.beginPath();
3193
+ var outerRect=[[GF_X0,GF_Y0],[GF_X1,GF_Y0],[GF_X1,GF_Y1],[GF_X0,GF_Y1]];
3194
+ 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); }
3195
+ X.closePath();
3196
+ 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); }
3197
+ X.closePath();
3198
+ X.fillStyle='rgba(28,68,38,0.95)'; X.fill('evenodd');
3199
+ X.restore();
3200
+ // thin lighter inner rim ~0.15 inside the inner edge
3201
+ function innerRimPts(){
3202
+ var rim=[];
3203
+ var segs=6;
3204
+ 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]); }
3205
+ 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)]); }
3206
+ 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]); }
3207
+ 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)]); }
3208
+ return rim;
3209
+ }
3210
+ var rimPts=innerRimPts();
3211
+ X.save();
3212
+ X.beginPath();
3213
+ 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); }
3214
+ X.closePath();
3215
+ X.strokeStyle='rgba(96,148,86,0.7)'; X.lineWidth=0.8*z; X.stroke();
3216
+ X.restore();
3217
+ // grass noise — scattered lighter tufts to add texture (warm bright-green tints)
3218
+ X.save();
3219
+ for(var ni=0;ni<60;ni++){
3220
+ var gnx=GF_X0 + ((ni*37)%100)/100*(GF_X1-GF_X0);
3221
+ var gny=GF_Y0 + ((ni*53)%100)/100*(GF_Y1-GF_Y0);
3222
+ var gp=iso(gnx,gny);
3223
+ X.fillStyle=(ni%2)?'rgba(150,200,110,0.20)':'rgba(56,112,62,0.22)';
3224
+ 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();
3225
+ }
3226
+ X.restore();
3227
+ // ── flower spots — deterministic small dots in 3 colors, scattered in the lawn only ──
3228
+ X.save();
3229
+ var flowerColors=['rgba(232,124,164,0.85)','rgba(248,220,96,0.88)','rgba(248,248,240,0.85)'];
3230
+ for(var fi=0;fi<34;fi++){
3231
+ var fx=GF_X0 + jx(fi*17+3)*(GF_X1-GF_X0);
3232
+ var fy=hedgeW + jx(fi*23+11)*(GF_Y1-hedgeW*1.4);
3233
+ // skip plaza area (P_X0..P_X1, P_Y0..P_Y1) and pond area
3234
+ if(fx>=P_X0-0.3 && fx<=P_X1+0.3 && fy>=P_Y0-0.3 && fy<=P_Y1+0.3) continue;
3235
+ var dxp=fx-POND.cx, dyp=fy-POND.cy;
3236
+ if(dxp*dxp/(POND.rx*POND.rx) + dyp*dyp/(POND.ry*POND.ry) < 1.1) continue;
3237
+ var fp=iso(fx,fy);
3238
+ X.fillStyle=flowerColors[fi%3];
3239
+ 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();
3240
+ }
3241
+ X.restore();
3242
+ // beach strip at the very bottom (sandy)
3243
+ poly([[-1,BEACH_Y0],[PARK_W+1,BEACH_Y0],[PARK_W+1,BEACH_Y1],[-1,BEACH_Y1]],
3244
+ 'rgba(214,196,150,0.55)', 'rgba(180,160,120,0.5)', 1);
3245
+ // darker curb along the paved edge (drawn as a thin offset quad)
3246
+ var curbOff=0.12;
3247
+ var curbPoly=PLAZA_POLY.map(function(p){
3248
+ // expand outward a bit so the curb ring is visible just outside the plaza footprint
3249
+ return [p[0] + (p[0]<1?-curbOff:(p[0]>14?curbOff:0)), p[1] + (p[1]<-0.05?-curbOff:(p[1]>5?curbOff:0))];
3250
+ });
3251
+ poly(curbPoly, 'rgba(20,28,22,0.85)', null, 0);
3252
+ // paved plaza base — neutral stone so the tile pattern reads clearly (polygon outline)
3253
+ var pg=X.createLinearGradient(0,0,0,H);
3254
+ pg.addColorStop(0,'rgba(170,162,150,0.92)');
3255
+ pg.addColorStop(1,'rgba(132,124,114,0.92)');
3256
+ poly(PLAZA_POLY, pg, 'rgba(60,52,44,0.55)',1);
3257
+ // chessboard stone tile pattern — iterate a rectangle then clip to the plaza polygon
3258
+ X.save();
3259
+ X.beginPath();
3260
+ for(var pli=0;pli<PLAZA_POLY.length;pli++){
3261
+ var plp=iso(PLAZA_POLY[pli][0],PLAZA_POLY[pli][1]);
3262
+ if(pli===0) X.moveTo(plp.x,plp.y); else X.lineTo(plp.x,plp.y);
3263
+ }
3264
+ X.closePath();
3265
+ X.clip();
3266
+ var tile=1.5;
3267
+ var tx0=Math.floor(P_X0/tile)*tile, ty0=Math.floor(P_Y0/tile)*tile;
3268
+ var lite='rgba(206,200,188,0.92)', dark='rgba(118,108,98,0.92)';
3269
+ var liteBevel='rgba(238,232,218,0.55)', darkShade='rgba(60,52,44,0.45)';
3270
+ for(var gy2=ty0;gy2<P_Y1+1;gy2+=tile){
3271
+ for(var gx2=tx0;gx2<P_X1+1;gx2+=tile){
3272
+ var x0=gx2, y0=gy2, x1=gx2+tile, y1=gy2+tile;
3273
+ if(x1>P_X1) x1=P_X1; if(y1>P_Y1) y1=P_Y1;
3274
+ var isLite=(((Math.round((x0-P_X0)/tile)+Math.round((y0-P_Y0)/tile))%2)+2)%2===0;
3275
+ var top=isLite?lite:dark;
3276
+ // top face
3277
+ poly([[x0,y0],[x1,y0],[x1,y1],[x0,y1]], top, null, 0);
3278
+ // bevel: lighter top-left edge, darker bottom-right edge (use iso projection edge lines)
3279
+ var tlA=iso(x0,y0), tlB=iso(x1,y0), tlC=iso(x1,y1), tlD=iso(x0,y1);
3280
+ // top edge (lit)
3281
+ X.strokeStyle=isLite?liteBevel:'rgba(160,150,140,0.35)'; X.lineWidth=0.9*z;
3282
+ X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlB.x,tlB.y); X.stroke();
3283
+ // left edge (lit)
3284
+ X.strokeStyle=isLite?liteBevel:'rgba(150,140,130,0.30)';
3285
+ X.beginPath(); X.moveTo(tlA.x,tlA.y); X.lineTo(tlD.x,tlD.y); X.stroke();
3286
+ // bottom edge (shaded)
3287
+ X.strokeStyle=darkShade;
3288
+ X.beginPath(); X.moveTo(tlD.x,tlD.y); X.lineTo(tlC.x,tlC.y); X.stroke();
3289
+ // right edge (shaded)
3290
+ X.strokeStyle=darkShade;
3291
+ X.beginPath(); X.moveTo(tlB.x,tlB.y); X.lineTo(tlC.x,tlC.y); X.stroke();
3292
+ }
3293
+ }
3294
+ X.restore();
3295
+ // outer plaza border for a clean curb (use the polygon)
3296
+ poly(PLAZA_POLY, null, 'rgba(50,44,36,0.7)', 1.4*z);
3297
+ // ── sun-shade pavilions on the plaza (4 of them) ──
3298
+ // (pavilions removed — the chessboard plaza reads better without canopies)
3299
+ // parking lot — upper-right corner of the plaza (after the road)
3300
+ // curb ring around the lot
3301
+ 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]],
3302
+ 'rgba(20,20,24,0.95)', null, 0);
3303
+ // asphalt surface
3304
+ poly([[LOT_X0,LOT_Y0],[LOT_X1,LOT_Y0],[LOT_X1,LOT_Y1],[LOT_X0,LOT_Y1]],
3305
+ 'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.9)', 1);
3306
+ // access driveway from lot down into the plaza (thin asphalt strip)
3307
+ poly([[8.5, 1.5],[10.0, 1.5],[10.6, 2.5],[9.1, 2.5]],
3308
+ 'rgba(34,34,40,0.96)', 'rgba(20,20,24,0.85)', 0.8*z);
3309
+ // parking bay lines (white) — 2 rows
3310
+ X.strokeStyle='rgba(238,238,232,0.85)'; X.lineWidth=1.1*z; X.setLineDash([]);
3311
+ var bays=6;
3312
+ var lotH=LOT_Y1-LOT_Y0;
3313
+ var rowYs=[LOT_Y0+lotH*0.32, LOT_Y0+lotH*0.70];
3314
+ for(var ri2=0;ri2<rowYs.length;ri2++){
3315
+ for(var bi=1;bi<bays;bi++){
3316
+ var bx=LOT_X0+bi*((LOT_X1-LOT_X0)/bays);
3317
+ var ba=iso(bx, rowYs[ri2]-0.18), bb=iso(bx, rowYs[ri2]+0.18);
3318
+ X.beginPath(); X.moveTo(ba.x,ba.y); X.lineTo(bb.x,bb.y); X.stroke();
3319
+ }
3320
+ }
3321
+ // front edge line
3322
+ var feA=iso(LOT_X0+0.12,LOT_Y1-0.22), feB=iso(LOT_X1-0.12,LOT_Y1-0.22);
3323
+ X.beginPath(); X.moveTo(feA.x,feA.y); X.lineTo(feB.x,feB.y); X.stroke();
3324
+ // back edge line
3325
+ var beA=iso(LOT_X0+0.12,LOT_Y0+0.22), beB=iso(LOT_X1-0.12,LOT_Y0+0.22);
3326
+ X.beginPath(); X.moveTo(beA.x,beA.y); X.lineTo(beB.x,beB.y); X.stroke();
3327
+ // parked cars in 2 rows
3328
+ var bayW=(LOT_X1-LOT_X0)/bays;
3329
+ var parkedColors=['#d44a4a','#5b8bd6','#e8e6dc','#f2c14e','#7fb4ff','#5fa060','#c87ed4','#e0a23c','#9aa0a8','#ff7e6e'];
3330
+ for(var pri=0;pri<rowYs.length;pri++){
3331
+ for(var pbi=0;pbi<bays-1;pbi++){
3332
+ var pcx=LOT_X0+0.7+pbi*bayW;
3333
+ if(pbi===0 || pbi===bays-2) continue; // skip end bays for some breathing room
3334
+ drawCar(pcx, rowYs[pri], parkedColors[(pri*bays+pbi)%parkedColors.length]);
3335
+ }
3336
+ }
3337
+ // street band along the far edge + dashed centerline + road cars
3338
+ 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);
3339
+ X.strokeStyle='rgba(232,210,120,0.55)'; X.setLineDash([9*z,9*z]);
1471
3340
  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([]);
1472
3341
  CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
1473
- // white event tent / stage on the far left of the plaza (matches aerial)
1474
- drawTent(0.85, 0.18);
1475
- // paved plazalong rectangular promenade matching the aerial footprint
1476
- var px0=0.2,py0=-0.10,px1=PARK_W-0.2,py1=PARK_D-0.45;
1477
- var pg=X.createLinearGradient(0,0,0,H); pg.addColorStop(0,'rgba(74,70,92,0.66)'); pg.addColorStop(1,'rgba(46,42,60,0.62)');
1478
- poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);
1479
- // plaza grid (tile joints)
1480
- X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;
1481
- for(var gx=Math.ceil(px0);gx<px1;gx+=1.5){ var a=iso(gx,py0), b=iso(gx,py1); X.beginPath(); X.moveTo(a.x,a.y); X.lineTo(b.x,b.y); X.stroke(); }
1482
- for(var gy=Math.ceil(py0);gy<py1;gy+=1.5){ var c=iso(px0,gy), d=iso(px1,gy); X.beginPath(); X.moveTo(c.x,c.y); X.lineTo(d.x,d.y); X.stroke(); }
1483
- // curved pond / water feature at bottom-left
3342
+ // white event tent in the left grass strip (west of the plaza)
3343
+ drawTent(-0.15, 0.5);
3344
+ // ── lakewider sandy shore with soft radial fade to grass ──
3345
+ // shore ring: a wider light sand band outside the water with a soft radial fade.
3346
+ X.save();
3347
+ var shoreRings=18;
3348
+ for(var shr=shoreRings; shr>=0; shr--){
3349
+ var t=shr/shoreRings;
3350
+ var rx=POND.rx + 0.5*(1-t);
3351
+ var ry=POND.ry + 0.5*(1-t);
3352
+ var alpha=0.55*(1-t)*(1-t);
3353
+ var col='rgba(228,210,168,'+alpha.toFixed(3)+')';
3354
+ var ringPts=[];
3355
+ for(var sk=0;sk<Math.PI*2;sk+=Math.PI/16){
3356
+ ringPts.push([POND.cx+rx*Math.cos(sk), POND.cy+ry*Math.sin(sk)*0.7]);
3357
+ }
3358
+ poly(ringPts, col, null, 0);
3359
+ }
3360
+ X.restore();
3361
+ // thin outer foam ring (kept for sparkle)
3362
+ var foamPts=[];
3363
+ for(var tf=0;tf<Math.PI*2;tf+=Math.PI/16){
3364
+ foamPts.push([POND.cx+(POND.rx+0.06)*Math.cos(tf), POND.cy+(POND.ry+0.05)*Math.sin(tf)*0.7]);
3365
+ }
3366
+ poly(foamPts, 'rgba(232,244,248,0.45)', 'rgba(255,255,255,0.5)', 1.0*z);
3367
+ // water outline (outer ellipse)
1484
3368
  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]); }
1485
- var wg=X.createLinearGradient(0,0,0,H); wg.addColorStop(0,'rgba(44,116,156,0.72)'); wg.addColorStop(1,'rgba(26,74,116,0.72)');
1486
- poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);
1487
- // winding footpath along the pond
1488
- X.strokeStyle='rgba(210,190,150,0.35)'; X.lineWidth=2.5*cam.z; X.setLineDash([]);
1489
- var pathPts=[[0.15*PARK_W,0.82*PARK_D],[0.22*PARK_W,0.88*PARK_D],[0.32*PARK_W,0.92*PARK_D],[0.45*PARK_W,0.95*PARK_D]];
1490
- X.beginPath();
1491
- for(var pi=0;pi<pathPts.length;pi++){ var pp=iso(pathPts[pi][0],pathPts[pi][1]); if(pi===0)X.moveTo(pp.x,pp.y); else X.lineTo(pp.x,pp.y); }
1492
- X.stroke();
3369
+ // ── lake bed — 3 concentric ellipses with progressively darker teal/blue, slightly offset toward upper-back ──
3370
+ 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; }
3371
+ // Outer: teal
3372
+ 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);
3373
+ // Mid: deeper teal
3374
+ 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);
3375
+ // Inner: deep blue
3376
+ 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);
3377
+ // water surface outline (drawn on top of the depth layers for a clean edge)
3378
+ poly(pts, null, 'rgba(120,200,222,0.6)',1.6*z);
3379
+ // ── sun-glare streak — translucent white diagonal ellipse at upper-right of lake ──
3380
+ var glareCx=POND.cx+0.55, glareCy=POND.cy-0.55;
3381
+ var glareP=iso(glareCx, glareCy);
3382
+ var glareGrad=X.createRadialGradient(glareP.x, glareP.y, 2*z, glareP.x, glareP.y, 28*z);
3383
+ glareGrad.addColorStop(0,'rgba(255,255,255,0.32)');
3384
+ glareGrad.addColorStop(0.6,'rgba(255,255,255,0.10)');
3385
+ glareGrad.addColorStop(1,'rgba(255,255,255,0)');
3386
+ X.save();
3387
+ X.translate(glareP.x, glareP.y);
3388
+ X.rotate(-Math.PI/4);
3389
+ X.fillStyle=glareGrad;
3390
+ X.beginPath(); X.ellipse(0, 0, 26*z, 6*z, 0, 0, Math.PI*2); X.fill();
3391
+ X.restore();
3392
+ // ── animated ripple/highlight — thin white elliptical arcs that drift across the lake ──
3393
+ var pondCent=iso(POND.cx, POND.cy);
3394
+ var ripplePhase=Math.sin(performance.now()/1500);
3395
+ X.save();
3396
+ X.strokeStyle='rgba(232,248,252,0.7)'; X.lineWidth=1.0*z; X.lineCap='round';
3397
+ for(var rk=0;rk<4;rk++){
3398
+ var drift=(rk*0.6 + ripplePhase*0.8) % (POND.rx*1.4) - POND.rx*0.7;
3399
+ var rxr2=Math.abs(ripplePhase)*POND.rx*0.35 + POND.rx*0.18 + rk*0.18;
3400
+ var ryr2=POND.ry*0.18 + rk*0.10;
3401
+ var rCx2=POND.cx + drift*0.4;
3402
+ var rCy2=POND.cy - 0.10 + rk*0.12;
3403
+ var rA2=iso(rCx2-rxr2, rCy2);
3404
+ var rB2=iso(rCx2+rxr2, rCy2);
3405
+ X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(performance.now()/900 + rk));
3406
+ X.beginPath();
3407
+ X.moveTo(rA2.x, rA2.y);
3408
+ X.quadraticCurveTo(pondCent.x, rA2.y - 3*z, rB2.x, rB2.y);
3409
+ X.stroke();
3410
+ }
3411
+ X.globalAlpha=1;
3412
+ X.restore();
3413
+ // ── lily pads — small green ellipse clusters at deterministic positions ──
3414
+ var lilySpots=[
3415
+ {x: POND.cx-1.8, y: POND.cy+0.25, s: 0.95},
3416
+ {x: POND.cx+1.5, y: POND.cy+0.45, s: 1.05},
3417
+ {x: POND.cx-0.3, y: POND.cy-0.55, s: 0.85},
3418
+ {x: POND.cx+2.1, y: POND.cy-0.35, s: 0.9}
3419
+ ];
3420
+ for(var li=0;li<lilySpots.length;li++){
3421
+ var lp=iso(lilySpots[li].x, lilySpots[li].y);
3422
+ var lsz=lilySpots[li].s*z;
3423
+ // lily pad: ellipse with a notch cut from the right side
3424
+ X.save();
3425
+ X.fillStyle='rgba(72,140,72,0.85)';
3426
+ X.beginPath();
3427
+ X.ellipse(lp.x, lp.y, 5*lsz, 3.2*lsz, 0, -0.4, Math.PI*2-0.4);
3428
+ X.lineTo(lp.x, lp.y);
3429
+ X.closePath(); X.fill();
3430
+ // notch (darker wedge) — small triangle cut-out
3431
+ X.fillStyle='rgba(30,90,40,0.7)';
3432
+ X.beginPath();
3433
+ X.moveTo(lp.x, lp.y);
3434
+ X.lineTo(lp.x+5.2*lsz, lp.y-0.6*lsz);
3435
+ X.lineTo(lp.x+5.2*lsz, lp.y+0.6*lsz);
3436
+ X.closePath(); X.fill();
3437
+ // highlight
3438
+ X.fillStyle='rgba(150,210,130,0.45)';
3439
+ 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();
3440
+ // tiny flower on one of them
3441
+ if(li===1){
3442
+ X.fillStyle='rgba(252,220,232,0.95)';
3443
+ X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 1.2*lsz, 0, Math.PI*2); X.fill();
3444
+ X.fillStyle='rgba(248,184,200,0.95)';
3445
+ X.beginPath(); X.arc(lp.x-0.5*lsz, lp.y-0.4*lsz, 0.6*lsz, 0, Math.PI*2); X.fill();
3446
+ }
3447
+ X.restore();
3448
+ }
3449
+ // ── tiny fountain — 3 concentric rings of decreasing opacity around lake centroid ──
3450
+ X.save();
3451
+ for(var fk=0;fk<3;fk++){
3452
+ var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(performance.now()/600 + fk));
3453
+ var falpha=0.55 - fk*0.18;
3454
+ var fcol='rgba(220,240,248,'+falpha.toFixed(3)+')';
3455
+ var fringPts=[];
3456
+ for(var fj=0;fj<Math.PI*2;fj+=Math.PI/14){
3457
+ fringPts.push([POND.cx + fr*Math.cos(fj), POND.cy + fr*Math.sin(fj)*0.7]);
3458
+ }
3459
+ poly(fringPts, null, fcol, 1.0*z);
3460
+ }
3461
+ X.restore();
3462
+ // ── fully styled walking path — from plaza bottom edge curving right through the grass ──
3463
+ X.lineCap='round'; X.lineJoin='round';
3464
+ var pathPts=[
3465
+ [3.5, 5.7],
3466
+ [4.2, 6.2],
3467
+ [5.4, 6.5],
3468
+ [6.8, 6.5],
3469
+ [8.2, 6.2],
3470
+ [9.4, 5.7]
3471
+ ];
3472
+ function strokePath(strokeW, strokeStyle, dashed, dash){
3473
+ X.beginPath();
3474
+ for(var spi=0;spi<pathPts.length;spi++){
3475
+ var spp=iso(pathPts[spi][0], pathPts[spi][1]);
3476
+ if(spi===0) X.moveTo(spp.x, spp.y); else X.lineTo(spp.x, spp.y);
3477
+ }
3478
+ X.lineWidth=strokeW;
3479
+ X.strokeStyle=strokeStyle;
3480
+ if(dashed){ X.setLineDash(dash); } else { X.setLineDash([]); }
3481
+ X.stroke();
3482
+ }
3483
+ // edge stroke (grass border) — drawn first, slightly wider
3484
+ strokePath(4.0*z + 1.0*z, 'rgba(140,200,160,0.55)', false, null);
3485
+ // base stroke — light tan compacted dirt
3486
+ strokePath(4.0*z, 'rgba(212,190,140,0.92)', false, null);
3487
+ // subtle darker speckle along the path for "compacted dirt" texture
3488
+ X.save();
3489
+ X.fillStyle='rgba(168,142,90,0.55)';
3490
+ for(var spt=0;spt<14;spt++){
3491
+ var sSeg=(spt+0.5)/14;
3492
+ var sIdx = Math.min(pathPts.length-2, Math.floor(sSeg*(pathPts.length-1)));
3493
+ var sT = sSeg*(pathPts.length-1) - sIdx;
3494
+ var sAx=pathPts[sIdx][0]*(1-sT)+pathPts[sIdx+1][0]*sT;
3495
+ var sAy=pathPts[sIdx][1]*(1-sT)+pathPts[sIdx+1][1]*sT;
3496
+ var sPx=iso(sAx,sAy);
3497
+ var sAng=Math.sin(spt*23.7)*1.4;
3498
+ 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();
3499
+ }
3500
+ X.restore();
3501
+ // a faint lighter highlight stripe down the middle
3502
+ strokePath(1.0*z, 'rgba(238,222,178,0.5)', false, null);
3503
+ X.setLineDash([]); X.lineCap='butt'; X.lineJoin='miter';
3504
+ // ── path lanterns — vertical post + warm yellow glow at the top, 3 of them ──
3505
+ var lanternSpots=[
3506
+ [pathPts[1][0], pathPts[1][1]-0.2],
3507
+ [pathPts[3][0], pathPts[3][1]],
3508
+ [pathPts[5][0], pathPts[5][1]]
3509
+ ];
3510
+ for(var ln=0;ln<lanternSpots.length;ln++){
3511
+ var lpos=iso(lanternSpots[ln][0], lanternSpots[ln][1]);
3512
+ // post
3513
+ X.strokeStyle='rgba(36,30,28,0.92)'; X.lineWidth=1.6*z;
3514
+ X.beginPath(); X.moveTo(lpos.x, lpos.y); X.lineTo(lpos.x, lpos.y-1.5*z*10); X.stroke();
3515
+ // base plate
3516
+ X.fillStyle='rgba(48,40,36,0.92)';
3517
+ X.beginPath(); X.ellipse(lpos.x, lpos.y, 2.4*z, 0.9*z, 0, 0, Math.PI*2); X.fill();
3518
+ // glow at top (shadowBlur for warm glow)
3519
+ X.save();
3520
+ X.shadowColor='rgba(255,214,112,0.95)'; X.shadowBlur=12*z;
3521
+ X.fillStyle='rgba(255,234,140,0.95)';
3522
+ X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 1.8*z, 0, Math.PI*2); X.fill();
3523
+ X.restore();
3524
+ // bright core
3525
+ X.fillStyle='rgba(255,250,210,1)';
3526
+ X.beginPath(); X.arc(lpos.x, lpos.y-1.5*z*10, 0.8*z, 0, Math.PI*2); X.fill();
3527
+ }
3528
+ // ── playground equipment in the grass area (below the plaza) ──
3529
+ drawPlayground(13.2, 6.4, 'rgba(224,82,52,0.92)');
3530
+ drawPlayground(13.8, 7.0, 'rgba(248,176,52,0.92)');
3531
+ drawBlueCanopy(0.9, 6.6);
1493
3532
  }
1494
3533
  function drawTent(wx,wy){
1495
- var p=iso(wx,wy), z=cam.z;
1496
- // canopy shadow
1497
- X.fillStyle='rgba(0,0,0,0.28)'; X.beginPath(); X.ellipse(p.x,p.y+4*z,22*z,8*z,0,0,7); X.fill();
1498
- // white peaked tent
1499
- X.fillStyle='rgba(235,235,240,0.92)'; X.strokeStyle='rgba(180,180,190,0.6)'; X.lineWidth=1;
1500
- X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x,p.y-26*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();
1501
- X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x-12*z,p.y+16*z); X.lineTo(p.x+12*z,p.y+16*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();
1502
- // support poles
1503
- X.strokeStyle='rgba(160,160,170,0.7)'; X.lineWidth=1.5*z;
1504
- X.beginPath(); X.moveTo(p.x-10*z,p.y+16*z); X.lineTo(p.x-10*z,p.y+5*z); X.stroke();
1505
- X.beginPath(); X.moveTo(p.x+10*z,p.y+16*z); X.lineTo(p.x+10*z,p.y+5*z); X.stroke();
3534
+ // Compact ridge tent: footprint 0.4 long x 0.28 deep (~ half a plaza tile), height 7*z.
3535
+ var z=cam.z, wLong=0.4, wShort=0.28, h=7*z;
3536
+ var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);
3537
+ var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);
3538
+ var rdL={ x:bkL.x, y:bkL.y - h };
3539
+ var rdR={ x:bkR.x, y:bkR.y - h };
3540
+ var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;
3541
+ // soft ground shadow
3542
+ X.fillStyle='rgba(0,0,0,0.30)';
3543
+ X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();
3544
+ // stone apron
3545
+ X.fillStyle='rgba(190,186,176,0.55)';
3546
+ 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();
3547
+ X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;
3548
+ 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();
3549
+ // back roof panel (shaded)
3550
+ X.fillStyle='rgba(196,200,212,0.97)';
3551
+ 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();
3552
+ // front roof panel (lit)
3553
+ X.fillStyle='rgba(248,248,252,0.98)';
3554
+ 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();
3555
+ // ridge beam
3556
+ X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.9*z;
3557
+ X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();
3558
+ // 4 corner poles
3559
+ X.strokeStyle='rgba(80,60,40,0.92)'; X.lineWidth=0.5*z;
3560
+ X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
3561
+ X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
3562
+ X.strokeStyle='rgba(50,40,30,0.98)'; X.lineWidth=0.7*z;
3563
+ X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
3564
+ X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
3565
+ // 2 mid poles on front edge
3566
+ var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };
3567
+ var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };
3568
+ var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };
3569
+ var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };
3570
+ X.strokeStyle='rgba(50,40,30,0.95)'; X.lineWidth=0.5*z;
3571
+ X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();
3572
+ X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();
3573
+ // fabric scallops on the front eave
3574
+ X.strokeStyle='rgba(220,224,232,0.55)'; X.lineWidth=0.4*z;
3575
+ function scallop(x1,y1,x2,y2,sag){
3576
+ var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;
3577
+ X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();
3578
+ }
3579
+ scallop(frL.x,frL.y, midLF.x,midLF.y, 0.8*z);
3580
+ scallop(midLF.x,midLF.y, midRF.x,midRF.y, 0.8*z);
3581
+ scallop(midRF.x,midRF.y, frR.x,frR.y, 0.8*z);
3582
+ // small flag at the right ridge end
3583
+ X.strokeStyle='rgba(80,60,40,0.95)'; X.lineWidth=0.5*z;
3584
+ X.beginPath(); X.moveTo(rdR.x,rdR.y); X.lineTo(rdR.x, rdR.y - 4*z); X.stroke();
3585
+ X.fillStyle='rgba(232,72,72,0.95)';
3586
+ 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();
1506
3587
  }
1507
3588
  function drawCar(wx,wy,col){
1508
3589
  var p=iso(wx,wy), z=cam.z;
1509
- X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(p.x,p.y+3*z,11*z,5*z,0,0,7); X.fill();
1510
- X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();
1511
- X.fillStyle='rgba(255,255,255,0.25)'; X.beginPath(); X.roundRect(p.x-6*z,p.y-6*z,12*z,5*z,2*z); X.fill();
3590
+ // soft drop shadow
3591
+ X.fillStyle='rgba(0,0,0,0.32)';
3592
+ X.beginPath(); X.ellipse(p.x,p.y+4*z,13*z,5.5*z,0,0,7); X.fill();
3593
+ // chassis (lower body)
3594
+ X.fillStyle=col;
3595
+ X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z,26*z,11*z,3*z); X.fill();
3596
+ // chassis bottom shade
3597
+ X.fillStyle='rgba(0,0,0,0.22)';
3598
+ X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z,26*z,4*z,2*z); X.fill();
3599
+ // roof (darker top)
3600
+ X.fillStyle='rgba(0,0,0,0.55)';
3601
+ X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
3602
+ // roof tint with car color
3603
+ X.fillStyle=col;
3604
+ X.globalAlpha=0.6;
3605
+ X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
3606
+ X.globalAlpha=1;
3607
+ // windshield highlight
3608
+ X.fillStyle='rgba(180,220,255,0.65)';
3609
+ X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z,12*z,3*z,1.5*z); X.fill();
3610
+ // windshield shine
3611
+ X.fillStyle='rgba(255,255,255,0.45)';
3612
+ X.beginPath();
3613
+ X.moveTo(p.x-5*z,p.y-9*z); X.lineTo(p.x-2*z,p.y-9*z); X.lineTo(p.x-4*z,p.y-7*z); X.closePath(); X.fill();
3614
+ // wheels
3615
+ X.fillStyle='#0c0c10';
3616
+ X.beginPath(); X.arc(p.x-8*z,p.y+5*z,2.6*z,0,7); X.fill();
3617
+ X.beginPath(); X.arc(p.x+8*z,p.y+5*z,2.6*z,0,7); X.fill();
3618
+ X.beginPath(); X.arc(p.x-8*z,p.y+6*z,2.6*z,0,7); X.fill();
3619
+ X.beginPath(); X.arc(p.x+8*z,p.y+6*z,2.6*z,0,7); X.fill();
3620
+ // wheel rims
3621
+ X.fillStyle='rgba(200,200,210,0.75)';
3622
+ X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z,1*z,0,7); X.fill();
3623
+ X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z,1*z,0,7); X.fill();
3624
+ // headlight glow (front — right side)
3625
+ X.fillStyle='rgba(255,240,180,0.95)';
3626
+ X.beginPath(); X.arc(p.x+12*z,p.y-1*z,1.6*z,0,7); X.fill();
3627
+ X.fillStyle='rgba(255,240,180,0.4)';
3628
+ X.beginPath(); X.arc(p.x+12*z,p.y-1*z,3*z,0,7); X.fill();
3629
+ // tail light (rear — left side)
3630
+ X.fillStyle='rgba(255,80,80,0.85)';
3631
+ X.beginPath(); X.arc(p.x-12*z,p.y-1*z,1.2*z,0,7); X.fill();
3632
+ // body highlight
3633
+ X.fillStyle='rgba(255,255,255,0.18)';
3634
+ X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z,24*z,1.5*z,1*z); X.fill();
3635
+ }
3636
+ // Sun-shade pavilion: 4 thin white poles supporting a flat white square canopy.
3637
+ function drawPavilion(wx,wy){
3638
+ var p=iso(wx,wy), z=cam.z;
3639
+ var canopy=12*z;
3640
+ var poleH=6*z;
3641
+ // soft ground shadow under the canopy
3642
+ X.fillStyle='rgba(0,0,0,0.18)';
3643
+ X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, canopy*1.1, canopy*0.42, 0, 0, 7); X.fill();
3644
+ // four corner poles (slight isometric offset)
3645
+ var poleOff=canopy*0.42;
3646
+ function pole(px,py){
3647
+ X.strokeStyle='rgba(240,240,244,0.95)';
3648
+ X.lineWidth=0.9*z;
3649
+ X.beginPath(); X.moveTo(px, py); X.lineTo(px, py-poleH); X.stroke();
3650
+ // tiny base dot
3651
+ X.fillStyle='rgba(40,40,48,0.6)';
3652
+ X.beginPath(); X.arc(px, py, 0.8*z, 0, 7); X.fill();
3653
+ }
3654
+ pole(p.x-poleOff, p.y+poleOff*0.5);
3655
+ pole(p.x+poleOff, p.y+poleOff*0.5);
3656
+ pole(p.x-poleOff, p.y-poleOff*0.5);
3657
+ pole(p.x+poleOff, p.y-poleOff*0.5);
3658
+ // canopy roof — flat white square (top diamond)
3659
+ X.fillStyle='rgba(248,248,252,0.97)';
3660
+ X.beginPath();
3661
+ X.moveTo(p.x, p.y-poleH-canopy*0.5);
3662
+ X.lineTo(p.x+canopy, p.y-poleH);
3663
+ X.lineTo(p.x, p.y-poleH+canopy*0.5);
3664
+ X.lineTo(p.x-canopy, p.y-poleH);
3665
+ X.closePath(); X.fill();
3666
+ // canopy rim/edge
3667
+ X.strokeStyle='rgba(170,172,180,0.85)';
3668
+ X.lineWidth=0.8*z;
3669
+ X.beginPath();
3670
+ X.moveTo(p.x, p.y-poleH-canopy*0.5);
3671
+ X.lineTo(p.x+canopy, p.y-poleH);
3672
+ X.lineTo(p.x, p.y-poleH+canopy*0.5);
3673
+ X.lineTo(p.x-canopy, p.y-poleH);
3674
+ X.closePath(); X.stroke();
3675
+ // soft highlight band on the top of the canopy
3676
+ X.fillStyle='rgba(255,255,255,0.55)';
3677
+ X.beginPath();
3678
+ X.moveTo(p.x-canopy*0.8, p.y-poleH-canopy*0.05);
3679
+ X.lineTo(p.x+canopy*0.8, p.y-poleH-canopy*0.05);
3680
+ X.lineTo(p.x+canopy*0.6, p.y-poleH+canopy*0.25);
3681
+ X.lineTo(p.x-canopy*0.6, p.y-poleH+canopy*0.25);
3682
+ X.closePath(); X.fill();
3683
+ }
3684
+ // Abstract playground equipment: a small cluster of colored geometric shapes.
3685
+ function drawPlayground(wx,wy,accent){
3686
+ var p=iso(wx,wy), z=cam.z;
3687
+ // ground shadow
3688
+ X.fillStyle='rgba(0,0,0,0.20)';
3689
+ X.beginPath(); X.ellipse(p.x+1*z, p.y+3*z, 9*z, 4*z, 0, 0, 7); X.fill();
3690
+ // square post (red/orange)
3691
+ X.fillStyle=accent || 'rgba(224,82,52,0.95)';
3692
+ X.fillRect(p.x-3*z, p.y-8*z, 6*z, 6*z);
3693
+ X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z;
3694
+ X.strokeRect(p.x-3*z, p.y-8*z, 6*z, 6*z);
3695
+ // yellow circle
3696
+ X.fillStyle='rgba(248,212,72,0.95)';
3697
+ X.beginPath(); X.arc(p.x+5*z, p.y-4*z, 2.4*z, 0, Math.PI*2); X.fill();
3698
+ X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();
3699
+ // blue triangle
3700
+ X.fillStyle='rgba(82,142,224,0.95)';
3701
+ X.beginPath();
3702
+ X.moveTo(p.x-6*z, p.y-2*z);
3703
+ X.lineTo(p.x-2*z, p.y-2*z);
3704
+ X.lineTo(p.x-4*z, p.y-7*z);
3705
+ X.closePath(); X.fill();
3706
+ X.strokeStyle='rgba(0,0,0,0.35)'; X.lineWidth=0.6*z; X.stroke();
3707
+ // tiny base bar
3708
+ X.strokeStyle='rgba(48,48,52,0.85)'; X.lineWidth=1.2*z;
3709
+ X.beginPath(); X.moveTo(p.x-5*z, p.y); X.lineTo(p.x+6*z, p.y); X.stroke();
3710
+ }
3711
+ // Larger blue canopy (event-tent style) for the grass area.
3712
+ function drawBlueCanopy(wx,wy){
3713
+ // Compact ridge tent (slightly bigger than drawTent, blue, no flag).
3714
+ var z=cam.z, wLong=0.55, wShort=0.35, h=10*z;
3715
+ var bkL=iso(wx-wLong, wy-wShort), bkR=iso(wx+wLong, wy-wShort);
3716
+ var frL=iso(wx-wLong, wy+wShort), frR=iso(wx+wLong, wy+wShort);
3717
+ var rdL={ x:bkL.x, y:bkL.y - h };
3718
+ var rdR={ x:bkR.x, y:bkR.y - h };
3719
+ var cx=(bkL.x+frR.x)/2, cy=(bkL.y+frR.y)/2;
3720
+ // ground shadow
3721
+ X.fillStyle='rgba(0,0,0,0.32)';
3722
+ X.beginPath(); X.ellipse(cx, cy + 2*z, wLong*TW*z*0.95, wShort*TH*z*2.2, 0, 0, 7); X.fill();
3723
+ // stone apron
3724
+ X.fillStyle='rgba(190,186,176,0.55)';
3725
+ 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();
3726
+ X.strokeStyle='rgba(150,140,120,0.6)'; X.lineWidth=0.6*z;
3727
+ 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();
3728
+ // back roof panel (shaded blue)
3729
+ X.fillStyle='rgba(58,104,176,0.97)';
3730
+ 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();
3731
+ // front roof panel (lit blue)
3732
+ X.fillStyle='rgba(132,184,240,0.98)';
3733
+ 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();
3734
+ // ridge beam
3735
+ X.strokeStyle='rgba(20,40,76,0.95)'; X.lineWidth=1.0*z;
3736
+ X.beginPath(); X.moveTo(rdL.x,rdL.y); X.lineTo(rdR.x,rdR.y); X.stroke();
3737
+ // 4 corner poles
3738
+ X.strokeStyle='rgba(40,60,90,0.95)'; X.lineWidth=0.5*z;
3739
+ X.beginPath(); X.moveTo(bkL.x,bkL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
3740
+ X.beginPath(); X.moveTo(bkR.x,bkR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
3741
+ X.strokeStyle='rgba(20,36,68,0.98)'; X.lineWidth=0.7*z;
3742
+ X.beginPath(); X.moveTo(frL.x,frL.y); X.lineTo(rdL.x,rdL.y); X.stroke();
3743
+ X.beginPath(); X.moveTo(frR.x,frR.y); X.lineTo(rdR.x,rdR.y); X.stroke();
3744
+ // 2 mid poles on front edge
3745
+ var midLF={ x:frL.x + (frR.x-frL.x)*0.33, y:frL.y + (frR.y-frL.y)*0.33 };
3746
+ var midRF={ x:frL.x + (frR.x-frL.x)*0.66, y:frL.y + (frR.y-frL.y)*0.66 };
3747
+ var midLT={ x:rdL.x + (rdR.x-rdL.x)*0.33, y:rdL.y + (rdR.y-rdL.y)*0.33 };
3748
+ var midRT={ x:rdL.x + (rdR.x-rdL.x)*0.66, y:rdL.y + (rdR.y-rdL.y)*0.66 };
3749
+ X.strokeStyle='rgba(20,36,68,0.95)'; X.lineWidth=0.5*z;
3750
+ X.beginPath(); X.moveTo(midLF.x,midLF.y); X.lineTo(midLT.x,midLT.y); X.stroke();
3751
+ X.beginPath(); X.moveTo(midRF.x,midRF.y); X.lineTo(midRT.x,midRT.y); X.stroke();
3752
+ // fabric scallops on the front eave
3753
+ X.strokeStyle='rgba(220,232,248,0.6)'; X.lineWidth=0.4*z;
3754
+ function scallop(x1,y1,x2,y2,sag){
3755
+ var mx=(x1+x2)/2, my=(y1+y2)/2 + sag;
3756
+ X.beginPath(); X.moveTo(x1,y1); X.quadraticCurveTo(mx,my, x2,y2); X.stroke();
3757
+ }
3758
+ scallop(frL.x,frL.y, midLF.x,midLF.y, 1.0*z);
3759
+ scallop(midLF.x,midLF.y, midRF.x,midRF.y, 1.0*z);
3760
+ scallop(midRF.x,midRF.y, frR.x,frR.y, 1.0*z);
1512
3761
  }
1513
3762
  function drawPalm(wx,wy){
1514
3763
  var p=iso(wx,wy), z=cam.z;
1515
- X.fillStyle='rgba(0,0,0,0.26)'; X.beginPath(); X.ellipse(p.x,p.y,10*z,4.5*z,0,0,7); X.fill();
1516
- X.strokeStyle='#6b4a24'; X.lineWidth=3*z; X.beginPath(); X.moveTo(p.x,p.y); X.quadraticCurveTo(p.x+3*z,p.y-14*z,p.x+1*z,p.y-24*z); X.stroke();
1517
- var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;
1518
- for(var a=0;a<6;a++){ var ang=(-Math.PI/2)+(a-2.5)*0.52; X.beginPath(); X.moveTo(tx,ty); X.quadraticCurveTo(tx+Math.cos(ang)*10*z, ty+Math.sin(ang)*8*z-4*z, tx+Math.cos(ang)*20*z, ty+Math.sin(ang)*11*z+3*z); X.stroke(); }
1519
- X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();
3764
+ // soft ground shadow
3765
+ X.fillStyle='rgba(0,0,0,0.30)';
3766
+ X.beginPath(); X.ellipse(p.x,p.y+3*z,14*z,5.5*z,0,0,7); X.fill();
3767
+ // trunk: tapered curved column drawn as a filled quad, with a darker right side
3768
+ var trunkH=46*z, trunkBaseW=4*z, trunkTopW=2.2*z;
3769
+ // base point and apex (slight curve to the right)
3770
+ var baseX=p.x, baseY=p.y;
3771
+ var cpx=baseX+5*z, cpy=baseY-trunkH*0.5;
3772
+ var apexX=baseX+2*z, apexY=baseY-trunkH;
3773
+ // sample two intermediate points to build the curved silhouette
3774
+ function curveAt(t){
3775
+ // quadratic bezier
3776
+ var u=1-t;
3777
+ return {
3778
+ x:u*u*baseX + 2*u*t*cpx + t*t*apexX,
3779
+ y:u*u*baseY + 2*u*t*cpy + t*t*apexY
3780
+ };
3781
+ }
3782
+ var sA=curveAt(0.0), sB=curveAt(0.33), sC=curveAt(0.66), sD=curveAt(1.0);
3783
+ // right (shaded) side of trunk — offset outward at each sample
3784
+ function off(px,py,t,w){
3785
+ // normal-ish: lean to the right
3786
+ return { x:px+w, y:py };
3787
+ }
3788
+ var rA=off(sA.x,sA.y,0,trunkBaseW), rB=off(sB.x,sB.y,0.33,trunkBaseW*0.78);
3789
+ var rC=off(sC.x,sC.y,0.66,trunkBaseW*0.55), rD=off(sD.x,sD.y,1,trunkTopW);
3790
+ // left (lit) side
3791
+ function offL(px,py,t,w){ return { x:px-w, y:py }; }
3792
+ var lA=offL(sA.x,sA.y,0,trunkBaseW), lB=offL(sB.x,sB.y,0.33,trunkBaseW*0.78);
3793
+ var lC=offL(sC.x,sC.y,0.66,trunkBaseW*0.55), lD=offL(sD.x,sD.y,1,trunkTopW);
3794
+ // shaded right half
3795
+ X.fillStyle='rgba(82,52,28,0.95)';
3796
+ X.beginPath();
3797
+ X.moveTo(rA.x,rA.y);
3798
+ X.quadraticCurveTo(cpx+trunkBaseW, cpy, rB.x,rB.y);
3799
+ X.quadraticCurveTo(cpx+trunkBaseW*0.55, cpy*0.4+apexY*0.6, rC.x,rC.y);
3800
+ X.lineTo(rD.x,rD.y);
3801
+ X.lineTo(sD.x,sD.y);
3802
+ X.lineTo(sC.x,sC.y);
3803
+ X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);
3804
+ X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);
3805
+ X.closePath(); X.fill();
3806
+ // lighter lit left edge of trunk
3807
+ X.fillStyle='rgba(148,102,62,0.95)';
3808
+ X.beginPath();
3809
+ X.moveTo(lA.x,lA.y);
3810
+ X.quadraticCurveTo(cpx-trunkBaseW, cpy, lB.x,lB.y);
3811
+ X.quadraticCurveTo(cpx-trunkBaseW*0.55, cpy*0.4+apexY*0.6, lC.x,lC.y);
3812
+ X.lineTo(sC.x,sC.y);
3813
+ X.lineTo(sB.x,sB.y);
3814
+ X.quadraticCurveTo(cpx, cpy*0.4+apexY*0.6, sB.x,sB.y);
3815
+ X.quadraticCurveTo(cpx, cpy, sA.x,sA.y);
3816
+ X.closePath(); X.fill();
3817
+ // trunk segment rings (texture)
3818
+ X.strokeStyle='rgba(48,28,14,0.55)'; X.lineWidth=0.8*z;
3819
+ for(var ri=1;ri<6;ri++){
3820
+ var tr=ri/6;
3821
+ var tl=curveAt(tr-0.04), trr=curveAt(tr+0.04);
3822
+ var tlW=(trunkBaseW*(1-tr)+trunkTopW*tr);
3823
+ X.beginPath();
3824
+ X.moveTo(tl.x-tlW,tl.y);
3825
+ X.lineTo(tl.x+tlW,tl.y);
3826
+ X.stroke();
3827
+ }
3828
+ // crown center
3829
+ var tx=apexX, ty=apexY;
3830
+ // coconuts near the crown
3831
+ X.fillStyle='rgba(60,38,20,0.95)';
3832
+ X.beginPath(); X.arc(tx-3*z,ty+2*z,2*z,0,7); X.fill();
3833
+ X.beginPath(); X.arc(tx+3.5*z,ty+2.5*z,2.1*z,0,7); X.fill();
3834
+ X.beginPath(); X.arc(tx+0.5*z,ty+4*z,1.8*z,0,7); X.fill();
3835
+ // coconut highlights
3836
+ X.fillStyle='rgba(180,140,90,0.6)';
3837
+ X.beginPath(); X.arc(tx-3.6*z,ty+1.4*z,0.7*z,0,7); X.fill();
3838
+ X.beginPath(); X.arc(tx+2.9*z,ty+1.9*z,0.7*z,0,7); X.fill();
3839
+ // frond layer 1 — drooping down (8 fronds)
3840
+ var frondCount=8;
3841
+ for(var a=0;a<frondCount;a++){
3842
+ var ang=(-Math.PI/2) + (a-(frondCount-1)/2)*(Math.PI*1.05/frondCount);
3843
+ // drooping fronds have a downward bias
3844
+ var droop=0.45;
3845
+ var tipDx=Math.cos(ang)*22*z;
3846
+ var tipDy=Math.sin(ang)*14*z + 6*z; // pull down
3847
+ var ctlDx=Math.cos(ang)*8*z + Math.sin(ang)*4*z;
3848
+ var ctlDy=Math.sin(ang)*4*z + 8*z;
3849
+ // frond gradient from dark base to light tip
3850
+ var fGrad=X.createLinearGradient(tx+ctlDx*0.2, ty+ctlDy*0.2, tx+tipDx, ty+tipDy);
3851
+ fGrad.addColorStop(0,'rgba(34,90,46,0.95)');
3852
+ fGrad.addColorStop(0.6,'rgba(60,140,68,0.92)');
3853
+ fGrad.addColorStop(1,'rgba(130,200,110,0.88)');
3854
+ X.fillStyle=fGrad;
3855
+ X.beginPath();
3856
+ X.moveTo(tx,ty);
3857
+ X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);
3858
+ // tip point then back via a second control point that bows out
3859
+ var backCtlX=tx+tipDx*0.7 - Math.sin(ang)*3*z;
3860
+ var backCtlY=ty+tipDy*0.7 - Math.cos(ang)*2*z + 1*z;
3861
+ X.quadraticCurveTo(backCtlX, backCtlY, tx, ty);
3862
+ X.closePath(); X.fill();
3863
+ // frond center vein
3864
+ X.strokeStyle='rgba(28,70,36,0.6)'; X.lineWidth=0.7*z;
3865
+ X.beginPath();
3866
+ X.moveTo(tx,ty);
3867
+ X.quadraticCurveTo(tx+ctlDx, ty+ctlDy, tx+tipDx, ty+tipDy);
3868
+ X.stroke();
3869
+ }
3870
+ // frond layer 2 — pointing up (7 fronds, offset angles for fullness)
3871
+ var frondCount2=7;
3872
+ for(var a2=0;a2<frondCount2;a2++){
3873
+ var ang2=(-Math.PI/2) + (a2-(frondCount2-1)/2)*(Math.PI*0.85/frondCount2) + 0.2;
3874
+ var tipDx2=Math.cos(ang2)*20*z;
3875
+ var tipDy2=Math.sin(ang2)*16*z - 6*z; // push up
3876
+ var ctlDx2=Math.cos(ang2)*7*z - Math.sin(ang2)*3*z;
3877
+ var ctlDy2=Math.sin(ang2)*6*z - 2*z;
3878
+ var fGrad2=X.createLinearGradient(tx+ctlDx2*0.2, ty+ctlDy2*0.2, tx+tipDx2, ty+tipDy2);
3879
+ fGrad2.addColorStop(0,'rgba(40,100,52,0.95)');
3880
+ fGrad2.addColorStop(0.6,'rgba(70,150,76,0.92)');
3881
+ fGrad2.addColorStop(1,'rgba(150,210,120,0.88)');
3882
+ X.fillStyle=fGrad2;
3883
+ X.beginPath();
3884
+ X.moveTo(tx,ty);
3885
+ X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);
3886
+ var backCtlX2=tx+tipDx2*0.7 + Math.sin(ang2)*3*z;
3887
+ var backCtlY2=ty+tipDy2*0.7 + Math.cos(ang2)*1*z - 1*z;
3888
+ X.quadraticCurveTo(backCtlX2, backCtlY2, tx, ty);
3889
+ X.closePath(); X.fill();
3890
+ // vein
3891
+ X.strokeStyle='rgba(30,76,40,0.55)'; X.lineWidth=0.6*z;
3892
+ X.beginPath();
3893
+ X.moveTo(tx,ty);
3894
+ X.quadraticCurveTo(tx+ctlDx2, ty+ctlDy2, tx+tipDx2, ty+tipDy2);
3895
+ X.stroke();
3896
+ }
3897
+ // crown core highlight
3898
+ X.fillStyle='rgba(200,220,160,0.45)';
3899
+ X.beginPath(); X.arc(tx,ty,2.5*z,0,7); X.fill();
1520
3900
  }
1521
3901
  function ring(cx,cy,r,frac,color){
1522
- X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.12)'; X.lineWidth=3*cam.z; X.stroke();
3902
+ // No more raised stone base plate — the plaza chessboard tile shows
3903
+ // through underneath. Just the soft gold glow + load arc.
3904
+ X.save();
3905
+ X.shadowColor=color; X.shadowBlur=14*cam.z;
3906
+ X.fillStyle='rgba(232,200,120,0.18)';
3907
+ X.beginPath(); X.ellipse(cx,cy, r+3*cam.z, (r+3*cam.z)*0.5, 0, 0, 7); X.fill();
3908
+ X.restore();
3909
+ 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();
1523
3910
  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();
1524
3911
  }
1525
3912
  function label(cx,cy,txt,col,sub){
@@ -1527,44 +3914,505 @@ button.act:disabled{opacity:0.5;cursor:default;}
1527
3914
  X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
1528
3915
  X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
1529
3916
  }
1530
- function padMark(bx,by,z){ X.strokeStyle='rgba(255,255,255,0.10)'; X.lineWidth=1; X.beginPath(); X.ellipse(bx,by,15*z,7*z,0,0,7); X.stroke(); }
3917
+ function padMark(bx,by,z){
3918
+ // "Not joined" marker — soft crosshair on the bare plaza tile.
3919
+ // No more raised stone platform; the chessboard shows through.
3920
+ X.strokeStyle='rgba(232,200,120,0.6)'; X.lineWidth=1.4*z;
3921
+ X.beginPath();
3922
+ X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);
3923
+ X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);
3924
+ X.stroke();
3925
+ // soft outer glow
3926
+ X.strokeStyle='rgba(255,210,140,0.4)'; X.lineWidth=3*z;
3927
+ X.beginPath();
3928
+ X.moveTo(bx-7*z,by-3*z); X.lineTo(bx+7*z,by+3*z);
3929
+ X.moveTo(bx+7*z,by-3*z); X.lineTo(bx-7*z,by+3*z);
3930
+ X.stroke();
3931
+ }
3932
+ // Render a 3D-looking raised tile (top face + right side + front face) centered on (cx,cy)
3933
+ function drawTileQuad(cx,cy,w,h,fillTop,fillSide){
3934
+ var halfW=w*0.5, halfH=h*0.35;
3935
+ var lift=3*cam.z;
3936
+ // top face (diamond) — lit
3937
+ X.fillStyle=fillTop;
3938
+ X.beginPath();
3939
+ X.moveTo(cx,cy-lift-halfH);
3940
+ X.lineTo(cx+halfW,cy-lift);
3941
+ X.lineTo(cx,cy-lift+halfH);
3942
+ X.lineTo(cx-halfW,cy-lift);
3943
+ X.closePath(); X.fill();
3944
+ // top face bevel highlight (upper-left edges)
3945
+ X.strokeStyle='rgba(255,255,255,0.18)'; X.lineWidth=0.9*cam.z;
3946
+ X.beginPath();
3947
+ X.moveTo(cx,cy-lift-halfH);
3948
+ X.lineTo(cx-halfW,cy-lift);
3949
+ X.stroke();
3950
+ X.beginPath();
3951
+ X.moveTo(cx-halfW,cy-lift);
3952
+ X.lineTo(cx,cy-lift+halfH);
3953
+ X.stroke();
3954
+ // right side face (darker)
3955
+ X.fillStyle=fillSide;
3956
+ X.beginPath();
3957
+ X.moveTo(cx,cy-lift+halfH);
3958
+ X.lineTo(cx+halfW,cy-lift);
3959
+ X.lineTo(cx+halfW,cy);
3960
+ X.lineTo(cx,cy+halfH);
3961
+ X.closePath(); X.fill();
3962
+ // front face (darker)
3963
+ X.fillStyle=fillSide;
3964
+ X.beginPath();
3965
+ X.moveTo(cx-halfW,cy-lift);
3966
+ X.lineTo(cx,cy-lift+halfH);
3967
+ X.lineTo(cx,cy+halfH);
3968
+ X.lineTo(cx-halfW,cy);
3969
+ X.closePath(); X.fill();
3970
+ // outline
3971
+ X.strokeStyle='rgba(0,0,0,0.45)'; X.lineWidth=0.8*cam.z;
3972
+ X.beginPath();
3973
+ X.moveTo(cx,cy-lift-halfH); X.lineTo(cx+halfW,cy-lift);
3974
+ X.lineTo(cx+halfW,cy); X.lineTo(cx,cy+halfH);
3975
+ X.lineTo(cx-halfW,cy); X.lineTo(cx-halfW,cy-lift);
3976
+ X.closePath(); X.stroke();
3977
+ }
1531
3978
  function drawHub(n){
1532
- var p=iso(n.wx,n.wy), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-6*z-bob;
1533
- X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,16*z,7*z,0,0,7); X.fill();
1534
- X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
1535
- X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();
1536
- for(var i=0;i<4;i++){ X.fillStyle=i%2?getVar('--ok'):getVar('--gold-bright'); X.globalAlpha=0.4+0.6*Math.abs(Math.sin((performance.now()-t0)/300+i)); X.fillRect(bx-11*z,by-34*z+i*9*z,22*z,4*z); X.globalAlpha=1; }
1537
- X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();
1538
- X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();
1539
- label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
1540
- n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};
3979
+ // Desktop PC / server workstation: a tower on the left, a monitor on the right,
3980
+ // a keyboard in front and a mouse to the right. Centered on (n.wx, n.wy) on the iso ground.
3981
+ var z=cam.z;
3982
+ var bob=Math.sin((performance.now()-t0)/700)*1.2*z;
3983
+ // base point on the iso ground (the workstation sits centered here, slightly raised by bob)
3984
+ var p=iso(n.wx, n.wy);
3985
+ var gx=p.x, gy=p.y - bob; // ground reference
3986
+ // soft floor shadow under the whole workstation
3987
+ X.fillStyle='rgba(0,0,0,0.32)';
3988
+ X.beginPath(); X.ellipse(gx, gy+3*z, 32*z, 12*z, 0, 0, 7); X.fill();
3989
+ // (desk-mat removed — the bare plaza tile now shows under the workstation)
3990
+
3991
+ // ── PC TOWER (left of center, vertical box) ──
3992
+ // tower footprint ~14*z wide x 10*z deep on the desk, height 26*z
3993
+ var tx=gx-18*z, ty=gy-11*z; // tower base x, y on desk top
3994
+ var tw=14*z, td=10*z, th=26*z;
3995
+ // right side face (darker)
3996
+ X.fillStyle='rgba(36,32,48,0.97)';
3997
+ X.beginPath();
3998
+ X.moveTo(tx+tw, ty);
3999
+ X.lineTo(tx+tw, ty-th);
4000
+ X.lineTo(tx+tw+td*0.45, ty-th+td*0.35);
4001
+ X.lineTo(tx+tw+td*0.45, ty+td*0.35);
4002
+ X.closePath(); X.fill();
4003
+ // front face (lit)
4004
+ X.fillStyle='rgba(56,50,72,0.97)';
4005
+ X.beginPath();
4006
+ X.moveTo(tx, ty);
4007
+ X.lineTo(tx, ty-th);
4008
+ X.lineTo(tx+tw, ty-th);
4009
+ X.lineTo(tx+tw, ty);
4010
+ X.closePath(); X.fill();
4011
+ // top face
4012
+ X.fillStyle='rgba(78,70,96,0.97)';
4013
+ X.beginPath();
4014
+ X.moveTo(tx, ty);
4015
+ X.lineTo(tx+tw, ty);
4016
+ X.lineTo(tx+tw+td*0.45, ty+td*0.35);
4017
+ X.lineTo(tx+td*0.45, ty+td*0.35);
4018
+ X.closePath(); X.fill();
4019
+ // tower outline
4020
+ X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;
4021
+ X.beginPath();
4022
+ X.moveTo(tx, ty); X.lineTo(tx, ty-th); X.lineTo(tx+tw, ty-th); X.lineTo(tx+tw, ty); X.closePath(); X.stroke();
4023
+ X.beginPath();
4024
+ 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();
4025
+ X.beginPath();
4026
+ X.moveTo(tx, ty-th); X.lineTo(tx+td*0.45, ty-th+td*0.35); X.stroke();
4027
+ X.beginPath();
4028
+ X.moveTo(tx+tw, ty-th); X.lineTo(tx+tw+td*0.45, ty-th+td*0.35); X.stroke();
4029
+ // front-face details: power button + drive bay slots + LED
4030
+ X.fillStyle='rgba(255,214,112,0.95)';
4031
+ X.beginPath(); X.arc(tx+3*z, ty-4*z, 1.4*z, 0, 7); X.fill(); // power LED
4032
+ // drive bay slots
4033
+ X.fillStyle='rgba(20,16,28,0.95)';
4034
+ X.fillRect(tx+2*z, ty-9*z, 10*z, 2*z);
4035
+ X.fillRect(tx+2*z, ty-13*z, 10*z, 2*z);
4036
+ X.fillRect(tx+2*z, ty-17*z, 10*z, 2*z);
4037
+ // blinking activity LED (green)
4038
+ var blink=Math.abs(Math.sin((performance.now()-t0)/300));
4039
+ X.fillStyle='rgba(110,224,166,'+(0.5+0.5*blink).toFixed(3)+')';
4040
+ X.beginPath(); X.arc(tx+11*z, ty-4*z, 0.9*z, 0, 7); X.fill();
4041
+ // mesh vent lines on right side (darker grooves)
4042
+ X.strokeStyle='rgba(12,8,18,0.7)'; X.lineWidth=0.5*z;
4043
+ 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(); }
4044
+
4045
+ // ── MONITOR (right of center) ──
4046
+ // stand base, neck, screen
4047
+ var mx=gx+4*z, my=gy-11*z; // monitor base on desk
4048
+ var mw=18*z, mh=14*z; // screen width / height
4049
+ // monitor neck
4050
+ X.fillStyle='rgba(50,44,68,0.95)';
4051
+ 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();
4052
+ // monitor stand base (small ellipse)
4053
+ X.fillStyle='rgba(40,34,52,0.95)';
4054
+ X.beginPath(); X.ellipse(mx+9*z, my+0.5*z, 6*z, 1.4*z, 0, 0, 7); X.fill();
4055
+ // monitor screen (lit, animated content)
4056
+ var scrX=mx-2*z, scrY=my-3*z - mh;
4057
+ // screen bezel
4058
+ X.fillStyle='rgba(20,16,28,0.98)';
4059
+ X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.fill();
4060
+ // screen surface — animated dashboard gradient
4061
+ var scrGrad=X.createLinearGradient(scrX, scrY, scrX, scrY+mh);
4062
+ scrGrad.addColorStop(0, n.connected?'rgba(36,76,108,0.95)':'rgba(48,30,30,0.95)');
4063
+ scrGrad.addColorStop(1, n.connected?'rgba(20,40,64,0.95)':'rgba(28,18,18,0.95)');
4064
+ X.fillStyle=scrGrad;
4065
+ X.beginPath(); X.roundRect(scrX+1*z, scrY+1*z, mw-2*z, mh-3*z, 1*z); X.fill();
4066
+ // screen content — animated bars (looks like a live dashboard)
4067
+ X.fillStyle='rgba(110,224,166,0.85)';
4068
+ for(var bi=0;bi<5;bi++){
4069
+ var bw=2.5*z, bh=(3 + 4*Math.abs(Math.sin((performance.now()-t0)/400 + bi*0.7)))*z;
4070
+ X.fillRect(scrX+2.5*z + bi*3*z, scrY+mh-3*z-bh, bw, bh);
4071
+ }
4072
+ // header bar (gold)
4073
+ X.fillStyle='rgba(255,214,112,0.7)';
4074
+ X.fillRect(scrX+1.5*z, scrY+2*z, mw-3*z, 1*z);
4075
+ // tiny "REC" dot
4076
+ X.fillStyle='rgba(255,90,90,0.95)';
4077
+ X.beginPath(); X.arc(scrX+3*z, scrY+4*z, 0.7*z, 0, 7); X.fill();
4078
+ // monitor bezel outline
4079
+ X.strokeStyle='rgba(12,8,18,0.95)'; X.lineWidth=0.7*z;
4080
+ X.beginPath(); X.roundRect(scrX, scrY, mw, mh, 1.5*z); X.stroke();
4081
+ // brand bar at bottom of monitor
4082
+ X.fillStyle='rgba(255,214,112,0.85)';
4083
+ X.fillRect(scrX+mw*0.35, scrY+mh-1.4*z, mw*0.3, 0.6*z);
4084
+
4085
+ // ── KEYBOARD (in front of the monitor) ──
4086
+ var kbX=mx-1*z, kbY=gy-2*z;
4087
+ var kbW=18*z, kbH=4.5*z;
4088
+ X.fillStyle='rgba(28,24,36,0.95)';
4089
+ X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.fill();
4090
+ X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;
4091
+ X.beginPath(); X.roundRect(kbX, kbY, kbW, kbH, 1*z); X.stroke();
4092
+ // key rows
4093
+ X.fillStyle='rgba(70,62,86,0.95)';
4094
+ for(var kr=0;kr<3;kr++){
4095
+ for(var kc=0;kc<10;kc++){
4096
+ X.fillRect(kbX+1*z + kc*1.7*z, kbY+0.5*z + kr*1.3*z, 1.2*z, 1*z);
4097
+ }
4098
+ }
4099
+ // spacebar
4100
+ X.fillRect(kbX+5*z, kbY+0.5*z+3*1.3*z, 6*z, 1*z);
4101
+
4102
+ // ── MOUSE (right of keyboard) ──
4103
+ var msX=kbX+kbW+1.5*z, msY=kbY+0.5*z;
4104
+ X.fillStyle='rgba(28,24,36,0.95)';
4105
+ X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.fill();
4106
+ X.strokeStyle='rgba(12,8,18,0.9)'; X.lineWidth=0.5*z;
4107
+ X.beginPath(); X.ellipse(msX+1.5*z, msY+1.5*z, 2*z, 2.6*z, 0, 0, 7); X.stroke();
4108
+ // scroll wheel
4109
+ X.fillStyle='rgba(255,214,112,0.85)';
4110
+ X.fillRect(msX+1.4*z, msY+0.5*z, 0.3*z, 0.8*z);
4111
+
4112
+ // ── glowing signal lines coming from the monitor (radio waves) ──
4113
+ for(var ri=0;ri<3;ri++){
4114
+ var rphase=((performance.now()-t0)/1100 + ri*0.33) % 1;
4115
+ var rr=10*z + rphase*22*z;
4116
+ var ralpha=(1-rphase)*0.5;
4117
+ X.strokeStyle='rgba(255,214,112,'+ralpha.toFixed(3)+')';
4118
+ X.lineWidth=0.8*z;
4119
+ X.beginPath();
4120
+ X.arc(scrX+mw*0.5, scrY+mh*0.5, rr, Math.PI*0.85, Math.PI*1.15);
4121
+ X.stroke();
4122
+ }
4123
+
4124
+ // ── label below the workstation ──
4125
+ var labelX=gx;
4126
+ var labelY=gy+10*z;
4127
+ label(labelX, labelY, n.name, getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
4128
+ // hit-box covers the whole workstation footprint
4129
+ n._hit={x:gx-32*z, y:scrY-3*z, w:64*z, h:(labelY+8*z)-(scrY-3*z)};
1541
4130
  }
1542
4131
  function drawRobot(n){
1543
- var p=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;
1544
- if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);
1545
- X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();
1546
- var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';
1547
- X.globalAlpha=n.connected?1:0.7;
1548
- var img = avatarImageForNode(n);
1549
- if(img && img.complete && img.naturalWidth>0){
1550
- var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;
1551
- X.save();
1552
- X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();
1553
- X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);
4132
+ var p=iso(n.wx,n.wy), z=cam.z, now=performance.now();
4133
+ // Slight bob varies by status: live/talking robots get a livelier
4134
+ // bob, offline/ended ones stay still.
4135
+ var bobAmp = (n.status==='live'||n.status==='talking') ? 2.6*z
4136
+ : (n.status==='ended') ? 0.4*z
4137
+ : (n.connected ? 1.6*z : 0);
4138
+ var bob = bobAmp ? Math.sin(now/500 + (n.wx||0)) * bobAmp : 0;
4139
+ var bx=p.x, by=p.y-4*z-bob;
4140
+ var status = n.status || (n.connected ? 'ready' : 'offline');
4141
+
4142
+ // ── status-driven ground effect (drawn under the avatar) ──
4143
+ // Each status has a distinct footprint so you can read what's
4144
+ // happening from across the plaza: a green pulse for live, an
4145
+ // expanding sound ring for talking, a slow blue breathing
4146
+ // halo for ready/waiting, a fading grey ring for ended, and
4147
+ // a dim grey halo for offline.
4148
+ drawStatusGround(n, status, bx, p.y+3*z, z, now);
4149
+
4150
+ // Shadow under the avatar (kept subtle so the new ground effect
4151
+ // is the visual focus).
4152
+ X.fillStyle='rgba(0,0,0,0.28)';
4153
+ X.beginPath(); X.ellipse(bx, p.y+3*z, 13*z, 5.5*z, 0, 0, Math.PI*2); X.fill();
4154
+
4155
+ // Status-colored rim under the avatar (thin ring, same hue as
4156
+ // the status ground effect — binds the avatar to its footprint).
4157
+ var rimColor = statusRimColor(status);
4158
+ X.strokeStyle=rimColor; X.lineWidth=1.4*z;
4159
+ X.beginPath(); X.ellipse(bx, p.y+3*z, 18*z, 8*z, 0, 0, Math.PI*2); X.stroke();
4160
+
4161
+ // Load arc (still useful — shows CPU pressure even on a live
4162
+ // robot). Drawn at a slightly larger radius than the status rim
4163
+ // so the two don't fight visually.
4164
+ if(n.connected) ring(bx, p.y+3*z, 18*z, n.load/100, loadColor(n.load));
4165
+ else padMark(bx, p.y+3*z, z);
4166
+
4167
+ var col = n.connected ? rimColor : 'rgba(150,143,120,0.5)';
4168
+ X.globalAlpha = n.connected ? 1 : 0.7;
4169
+ var img = avatarImageForNode(n);
4170
+ if(img && img.complete && img.naturalWidth>0){
4171
+ var iw=40*z, ih=40*z*img.naturalHeight/img.naturalWidth;
4172
+ X.save();
4173
+ X.beginPath(); X.roundRect(bx-20*z, by-54*z, iw, ih+12*z, 8*z); X.clip();
4174
+ X.drawImage(img, bx-20*z, by-60*z, iw, ih+18*z);
1554
4175
  X.restore();
1555
- X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();
1556
4176
  } else {
1557
- X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;
1558
- X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();
1559
- X.beginPath(); X.roundRect(bx-8*z,by-37*z,16*z,14*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();
1560
- X.fillStyle=n.connected?getVar('--ok'):'#655'; X.beginPath(); X.arc(bx-3.5*z,by-30*z,2*z,0,7); X.arc(bx+3.5*z,by-30*z,2*z,0,7); X.fill();
1561
- X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();
1562
- X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();
4177
+ X.fillStyle='rgba(24,20,38,0.92)';
4178
+ X.beginPath(); X.roundRect(bx-17*z, by-36*z, 34*z, 36*z, 7*z); X.fill();
4179
+ X.fillStyle='rgba(30,24,44,0.95)';
4180
+ X.beginPath(); X.roundRect(bx-12*z, by-54*z, 24*z, 20*z, 5*z); X.fill();
4181
+ // Mouth/eyes default to the live color; "talking" status will
4182
+ // animate the mouth in drawStatusOverlays() below.
4183
+ X.fillStyle = n.connected ? getVar('--ok') : '#655';
4184
+ 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();
4185
+ // mouth dot — gets animated when status==='talking'
4186
+ X.fillStyle = (status==='talking') ? '#FF5B4A' : ((status==='ended') ? 'rgba(200,180,160,0.5)' : '#FF5B4A');
4187
+ 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();
1563
4188
  }
1564
4189
  X.globalAlpha=1;
1565
- var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' · ':'')+Math.round(n.load)+'%'):'not joined';
1566
- label(bx,p.y+17*z,n.label,col,sub);
1567
- n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};
4190
+
4191
+ // status overlays: speech rings (talking), thought bubble
4192
+ // (waiting/ready), a "👋 bye" mark (ended). Drawn ABOVE the
4193
+ // avatar so they read at a glance from the rest of the plaza.
4194
+ drawStatusOverlays(n, status, bx, by, z, now);
4195
+
4196
+ // sub-text under the avatar — show readiness + a short status word
4197
+ var subBits = [];
4198
+ if(n.sub && n.sub!==n.name) subBits.push(n.sub);
4199
+ if(n.connected){
4200
+ if(status==='talking') subBits.push('talking');
4201
+ else if(status==='live') subBits.push('live');
4202
+ else if(status==='ended') subBits.push('session ended');
4203
+ else if(status==='ready') subBits.push('waiting');
4204
+ subBits.push(Math.round(n.load||0)+'%');
4205
+ } else {
4206
+ subBits.push('not joined');
4207
+ }
4208
+ label(bx, p.y+17*z, n.label, col, subBits.join(' · '));
4209
+ // ── holographic name callout pill above the avatar (status-colored border) ──
4210
+ if(n.connected){
4211
+ var pillW=Math.max(70*z, (n.label.length*7 + 18) * z);
4212
+ var pillH=16*z;
4213
+ var pillR=8*z;
4214
+ var pillCx=bx;
4215
+ var pillCy=by - 8*z - 54*z - pillH*0.5;
4216
+ X.save();
4217
+ X.fillStyle='rgba(20,16,32,0.85)';
4218
+ X.beginPath();
4219
+ if(X.roundRect){
4220
+ X.roundRect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH, pillR);
4221
+ } else {
4222
+ X.rect(pillCx - pillW*0.5, pillCy - pillH*0.5, pillW, pillH);
4223
+ }
4224
+ X.fill();
4225
+ // pill border uses the same status hue as the ground effect
4226
+ X.strokeStyle = rimColor; X.lineWidth=1.0*z;
4227
+ X.stroke();
4228
+ X.fillStyle='rgba(255,232,160,0.95)';
4229
+ X.font='700 '+(10*z)+'px '+mono();
4230
+ X.textAlign='center'; X.textBaseline='middle';
4231
+ X.fillText(n.label, pillCx, pillCy);
4232
+ X.restore();
4233
+ // ── pulsing status dot to the right of the pill (faster pulse for live, fastest for talking) ──
4234
+ var dotCx=pillCx + pillW*0.5 + 4*z;
4235
+ var dotCy=pillCy;
4236
+ var dotPeriod = status==='talking' ? 110 : (status==='live' ? 230 : 400);
4237
+ var dotAlpha=0.55+0.45*Math.abs(Math.sin(now/dotPeriod + (n.wx||0)));
4238
+ X.save();
4239
+ X.fillStyle=hexWithAlpha(rimColor, dotAlpha.toFixed(3));
4240
+ X.shadowColor=rimColor; X.shadowBlur=6*z;
4241
+ X.beginPath(); X.arc(dotCx, dotCy, 3*z, 0, Math.PI*2); X.fill();
4242
+ X.restore();
4243
+ }
4244
+ n._hit={x:bx-25*z,y:by-67*z,w:50*z,h:80*z};
4245
+ }
4246
+
4247
+ // Map status → rgb base color (used for ground effect, rim, dot).
4248
+ // Kept in a small helper so all the status-driven effects stay in
4249
+ // sync visually.
4250
+ function statusRimColor(status){
4251
+ switch(status){
4252
+ case 'talking': return 'rgb(110,224,166)'; // bright green — voice is active
4253
+ case 'live': return 'rgb(255,214,112)'; // gold — session open, idle
4254
+ case 'ended': return 'rgb(180,170,160)'; // grey — session just ended, fading
4255
+ case 'ready': return 'rgb(110,180,255)'; // soft blue — waiting/ready
4256
+ case 'offline': return 'rgba(150,143,120,0.5)';
4257
+ default: return 'rgb(255,214,112)';
4258
+ }
4259
+ }
4260
+ // Build an rgba() string from an rgb() base + an alpha. Used so
4261
+ // shadowColor / strokeStyle can blend nicely against the gold grid.
4262
+ function hexWithAlpha(rgb, a){
4263
+ // Accepts "rgb(r,g,b)" or "rgba(r,g,b,x)"; returns rgba(r,g,b,a).
4264
+ // This file is embedded in a TypeScript template string, so regex
4265
+ // backslashes must be doubled or the browser receives an invalid regex.
4266
+ var m = /rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/.exec(rgb);
4267
+ if(!m) return rgb;
4268
+ return 'rgba('+m[1]+','+m[2]+','+m[3]+','+a+')';
4269
+ }
4270
+
4271
+ // ── status-driven ground effect under the avatar ──
4272
+ // Drawn before the avatar so the rim + ring sit on top. Each
4273
+ // status reads as a distinct footprint color + animation so the
4274
+ // plaza instantly tells you which robots are quiet, waiting,
4275
+ // listening, talking, or just stopped.
4276
+ function drawStatusGround(n, status, cx, cy, z, now){
4277
+ if(status === 'offline'){
4278
+ // dim grey aura (kept from the original)
4279
+ var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, 22*z);
4280
+ g.addColorStop(0,'rgba(140,140,160,0.16)');
4281
+ g.addColorStop(1,'rgba(140,140,160,0)');
4282
+ X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, 22*z, 11*z, 0, 0, 7); X.fill();
4283
+ return;
4284
+ }
4285
+ if(status === 'live'){
4286
+ // breathing green pulse — small inner glow that throbs every
4287
+ // ~0.7s, plus the load arc's gold ring already drawn above.
4288
+ var beat = 0.5 + 0.5*Math.sin(now/350);
4289
+ var g = X.createRadialGradient(cx, cy, 2*z, cx, cy, (18 + 6*beat)*z);
4290
+ g.addColorStop(0, 'rgba(110,224,166,'+(0.45 + 0.25*beat).toFixed(3)+')');
4291
+ g.addColorStop(0.6, 'rgba(110,224,166,0.12)');
4292
+ g.addColorStop(1, 'rgba(110,224,166,0)');
4293
+ X.fillStyle=g; X.beginPath(); X.ellipse(cx, cy, (18+6*beat)*z, (9+3*beat)*z, 0, 0, 7); X.fill();
4294
+ return;
4295
+ }
4296
+ if(status === 'talking'){
4297
+ // 2-3 expanding sound rings (like ⌒) — color bright green, very
4298
+ // distinct from the gold "live" pulse so listeners know voice
4299
+ // is actually flowing right now.
4300
+ var ringCount = 2;
4301
+ for(var ti=0; ti<ringCount; ti++){
4302
+ var tphase = ((now/420) + ti*0.5) % 1;
4303
+ var tr = (10 + tphase*22)*z;
4304
+ var ta = (1 - tphase) * 0.7;
4305
+ X.strokeStyle='rgba(110,224,166,'+ta.toFixed(3)+')';
4306
+ X.lineWidth=1.6*z;
4307
+ X.beginPath(); X.ellipse(cx, cy, tr, tr*0.5, 0, 0, Math.PI*2); X.stroke();
4308
+ }
4309
+ // solid inner glow so the "voice" footprint reads even when
4310
+ // rings are between expansions.
4311
+ var tg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 14*z);
4312
+ tg.addColorStop(0, 'rgba(110,224,166,0.45)');
4313
+ tg.addColorStop(1, 'rgba(110,224,166,0)');
4314
+ X.fillStyle=tg; X.beginPath(); X.ellipse(cx, cy, 14*z, 7*z, 0, 0, 7); X.fill();
4315
+ return;
4316
+ }
4317
+ if(status === 'ready'){
4318
+ // soft blue "waiting" halo — slow breath, calmer than live.
4319
+ var breath = 0.5 + 0.5*Math.sin(now/900);
4320
+ var rg = X.createRadialGradient(cx, cy, 2*z, cx, cy, 20*z);
4321
+ rg.addColorStop(0, 'rgba(110,180,255,'+(0.18 + 0.10*breath).toFixed(3)+')');
4322
+ rg.addColorStop(1, 'rgba(110,180,255,0)');
4323
+ X.fillStyle=rg; X.beginPath(); X.ellipse(cx, cy, 20*z, 10*z, 0, 0, 7); X.fill();
4324
+ return;
4325
+ }
4326
+ if(status === 'ended'){
4327
+ // session just ended — fading grey ring expanding outward,
4328
+ // like a sound ring dying down. Alpha drops to 0 over 4s.
4329
+ var mem = _statusMem[n.id] || {};
4330
+ var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;
4331
+ if(since > 4000) return;
4332
+ var ft = Math.min(1, since / 4000);
4333
+ var fr = (16 + ft*30)*z;
4334
+ var fa = (1 - ft) * 0.55;
4335
+ X.strokeStyle='rgba(180,170,160,'+fa.toFixed(3)+')';
4336
+ X.lineWidth=1.4*z;
4337
+ X.beginPath(); X.ellipse(cx, cy, fr, fr*0.5, 0, 0, Math.PI*2); X.stroke();
4338
+ return;
4339
+ }
4340
+ }
4341
+
4342
+ // ── status-driven overlays above the avatar ──
4343
+ // Speech rings (talking), thought-bubble "?" (ready/waiting),
4344
+ // fading "👋" (ended). Drawn on top of the avatar so they read
4345
+ // at a glance.
4346
+ function drawStatusOverlays(n, status, bx, by, z, now){
4347
+ if(status === 'talking'){
4348
+ // Speech ring: 3 expanding arcs above the avatar, like
4349
+ // someone is mid-sentence. Color bright green to match the
4350
+ // ground pulse.
4351
+ for(var si=0; si<3; si++){
4352
+ var sphase = ((now/260) + si*0.34) % 1;
4353
+ var sa = (1 - sphase) * 0.85;
4354
+ var sr = (4 + sphase*9)*z;
4355
+ X.strokeStyle='rgba(110,224,166,'+sa.toFixed(3)+')';
4356
+ X.lineWidth=1.4*z;
4357
+ X.beginPath();
4358
+ X.arc(bx, by-58*z - sphase*4*z, sr, Math.PI*1.15, Math.PI*1.85);
4359
+ X.stroke();
4360
+ }
4361
+ return;
4362
+ }
4363
+ if(status === 'ready'){
4364
+ // Thought bubble: small cloud (3 circles) with a "?" inside.
4365
+ // Bobs gently so it reads as a thought, not a sticker.
4366
+ var bob = Math.sin(now/700) * 1.4*z;
4367
+ var bubbleX = bx + 20*z;
4368
+ var bubbleY = by - 64*z + bob;
4369
+ // tail circles
4370
+ X.fillStyle='rgba(220,234,255,0.95)';
4371
+ X.beginPath(); X.arc(bubbleX-7*z, bubbleY+8*z, 1.6*z, 0, Math.PI*2); X.fill();
4372
+ X.beginPath(); X.arc(bubbleX-4*z, bubbleY+4*z, 2.2*z, 0, Math.PI*2); X.fill();
4373
+ // main bubble
4374
+ X.beginPath(); X.arc(bubbleX, bubbleY, 6*z, 0, Math.PI*2); X.fill();
4375
+ X.strokeStyle='rgba(110,180,255,0.85)'; X.lineWidth=1.0*z; X.stroke();
4376
+ // "?" text
4377
+ X.fillStyle='rgba(60,90,140,0.95)';
4378
+ X.font='700 '+(7*z)+'px '+mono();
4379
+ X.textAlign='center'; X.textBaseline='middle';
4380
+ X.fillText('?', bubbleX, bubbleY+0.5*z);
4381
+ return;
4382
+ }
4383
+ if(status === 'ended'){
4384
+ // Fading "bye" wave — a small waving-hand glyph that rises and
4385
+ // fades out over 4s.
4386
+ var mem = _statusMem[n.id] || {};
4387
+ var since = mem.lastEndedAt ? (now - mem.lastEndedAt) : 0;
4388
+ if(since > 4000) return;
4389
+ var ft = since / 4000;
4390
+ var a = (1 - ft) * 0.95;
4391
+ var lift = ft * 14*z;
4392
+ X.save();
4393
+ X.globalAlpha = a;
4394
+ // small bubble background for legibility
4395
+ X.fillStyle='rgba(20,16,32,0.7)';
4396
+ X.beginPath(); X.arc(bx, by - 60*z - lift, 8*z, 0, Math.PI*2); X.fill();
4397
+ X.strokeStyle='rgba(180,170,160,0.9)'; X.lineWidth=0.8*z;
4398
+ X.stroke();
4399
+ // hand glyph (canvas-drawn, simpler than emoji so it always renders)
4400
+ var hx = bx, hy = by - 60*z - lift;
4401
+ X.fillStyle='rgba(255,214,112,0.95)';
4402
+ // palm
4403
+ X.beginPath(); X.arc(hx, hy + 0.5*z, 3.0*z, 0, Math.PI*2); X.fill();
4404
+ // 4 fingers
4405
+ for(var fi=-2; fi<=1; fi++){
4406
+ X.fillRect(hx + fi*1.2*z - 0.4*z, hy - 3.6*z, 0.8*z, 2.6*z);
4407
+ }
4408
+ // thumb
4409
+ X.beginPath(); X.arc(hx - 2.4*z, hy + 0.5*z, 0.9*z, 0, Math.PI*2); X.fill();
4410
+ // tiny side-to-side wave wobble
4411
+ var wobble = Math.sin(now/110) * 1.4*z;
4412
+ X.translate(wobble, 0);
4413
+ X.restore();
4414
+ return;
4415
+ }
1568
4416
  }
1569
4417
  function drawPeer(n){
1570
4418
  var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;
@@ -1577,16 +4425,206 @@ button.act:disabled{opacity:0.5;cursor:default;}
1577
4425
  n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
1578
4426
  }
1579
4427
 
4428
+ // ── Walker tick + draw ──
4429
+ // Advances each walker along its path at a steady world-space speed
4430
+ // (one segment in segDurMs ms). Frame-rate independent: every
4431
+ // frame we move the walker by the elapsed time, not a per-tick
4432
+ // constant. When a walker is within greet-distance of its next
4433
+ // pauseAt waypoint, it pauses for 1.4s in a wave pose; the nearest
4434
+ // connected robot pad fires a 👋 pulse while it pauses.
4435
+ var _lastTickMs = 0;
4436
+ function tickWalkers(now){
4437
+ if(!_lastTickMs) _lastTickMs = now;
4438
+ var dt = Math.min(64, now - _lastTickMs); // clamp to avoid huge jumps on tab-switch
4439
+ _lastTickMs = now;
4440
+ for(var wi=0; wi<walkers.length; wi++){
4441
+ var w = walkers[wi];
4442
+ if(now < w.pauseUntil) continue;
4443
+ // Distance per frame for this walker = total path length / (segDurMs / dt) segments.
4444
+ // Convert to world units: distance = w.info.total * (dt / (w.path.length-1) / w.spec.segDurMs)
4445
+ // Simplified: dDistance = w.info.total * dt / ((w.path.length-1) * segDurMs)
4446
+ var stepDist = w.info.total * dt / ((w.path.length - 1) * w.spec.segDurMs);
4447
+ w.distance += stepDist;
4448
+ // Check whether we've crossed a pauseAt point during this tick.
4449
+ // Sample the current and previous position and see if either
4450
+ // got close to the next pending pauseAt waypoint. This is
4451
+ // cheaper than walking the whole polyline and handles fast
4452
+ // frame deltas.
4453
+ for(var pi=0; pi<w.spec.pauseAt.length; pi++){
4454
+ if(pi !== w.nextGreet % w.spec.pauseAt.length) continue;
4455
+ var pp = w.spec.pauseAt[pi];
4456
+ // current and previous world positions
4457
+ var cur = pathPosAt(w.info, w.distance);
4458
+ var prev = pathPosAt(w.info, w.distance - stepDist);
4459
+ var ca = w.path[cur.seg], cb = w.path[cur.seg+1] || w.path[0];
4460
+ var pa = w.path[prev.seg], pb = w.path[prev.seg+1] || w.path[0];
4461
+ var cwx = ca[0] + (cb[0]-ca[0])*cur.t, cwy = ca[1] + (cb[1]-ca[1])*cur.t;
4462
+ var pwx = pa[0] + (pb[0]-pa[0])*prev.t, pwy = pa[1] + (pb[1]-pa[1])*prev.t;
4463
+ // Closest distance from the line segment prev→cur to the pause point.
4464
+ var dx = cwx - pwx, dy = cwy - pwy;
4465
+ var segLen2 = dx*dx + dy*dy;
4466
+ var t = 0;
4467
+ if(segLen2 > 1e-6){
4468
+ t = ((pp[0]-pwx)*dx + (pp[1]-pwy)*dy) / segLen2;
4469
+ if(t < 0) t = 0; else if(t > 1) t = 1;
4470
+ }
4471
+ var qx = pwx + dx*t, qy = pwy + dy*t;
4472
+ var ddx = qx - pp[0], ddy = qy - pp[1];
4473
+ if(ddx*ddx + ddy*ddy < 0.35*0.35){
4474
+ w.pauseUntil = now + 1400;
4475
+ w.nextGreet++;
4476
+ // pulse the nearest connected robot pad so the interaction is visible
4477
+ var best = null, bd = 4.0;
4478
+ for(var ni=0; ni<nodes.length; ni++){
4479
+ var nd = nodes[ni];
4480
+ if(nd.kind !== 'robot' || !nd.connected) continue;
4481
+ var nx = nd.wx - pp[0], ny = nd.wy - pp[1];
4482
+ var dd = nx*nx + ny*ny;
4483
+ if(dd < bd){ bd = dd; best = nd; }
4484
+ }
4485
+ if(best) pulse(best, '👋');
4486
+ break;
4487
+ }
4488
+ }
4489
+ }
4490
+ }
4491
+ function walkerPos(w){
4492
+ // Decode w.distance (in units of "segments") into a (seg, t) so
4493
+ // callers can project a real world point without re-walking the
4494
+ // path each time. The 'seg' returned is a continuous float, so a
4495
+ // walker mid-stride is at seg=1.6, for example — used by the
4496
+ // depth sort to get a smooth iso-depth from the world point.
4497
+ var path = w.path, info = w.info;
4498
+ var d = ((w.distance % (path.length-1)) + (path.length-1)) % (path.length-1);
4499
+ var segI = Math.floor(d);
4500
+ var segT = d - segI;
4501
+ var a = path[segI], b = path[segI+1] || path[0];
4502
+ return [ a[0] + (b[0]-a[0])*segT, a[1] + (b[1]-a[1])*segT ];
4503
+ }
4504
+ function drawWalker(w, now){
4505
+ var pos = walkerPos(w);
4506
+ var p = iso(pos[0], pos[1]);
4507
+ var z = cam.z;
4508
+ // Direction along the path: average the next 0.5s of motion so the
4509
+ // figure doesn't snap on segment turns.
4510
+ var lookAhead = walkerPos({path:w.path, info:w.info, distance: w.distance + 0.5});
4511
+ var lh = iso(lookAhead[0], lookAhead[1]);
4512
+ var facingRight = (lh.x - p.x) >= 0;
4513
+ // Gait cycle: tied to distance travelled (not wall clock) so the
4514
+ // animation is smooth at any speed. ~1 step per 0.4 world units.
4515
+ var phase = w.distance * 2.4;
4516
+ // Use a cosine for the step so foot-down poses linger briefly at
4517
+ // each extreme — reads as a real walk rather than a sine wobble.
4518
+ var stepC = Math.cos(phase);
4519
+ var stepS = Math.sin(phase);
4520
+ // Bob: only on the up-phase of the step (plant foot + rise).
4521
+ var bob = Math.max(0, stepC) * 1.0 * z;
4522
+ // Body lean: very slight forward lean into the direction of travel.
4523
+ var lean = 0.6 * z;
4524
+ var greeting = now < w.pauseUntil;
4525
+ // Anchor on the ground (shadow), body sits above.
4526
+ var gx = p.x, gy = p.y + 2*z;
4527
+ X.save();
4528
+ if(!facingRight){ X.translate(gx, 0); X.scale(-1, 1); X.translate(-gx, 0); }
4529
+ // shadow ellipse — softens and shrinks while in the air
4530
+ X.fillStyle='rgba(0,0,0,0.28)';
4531
+ X.beginPath(); X.ellipse(gx, gy, (4.6 - bob*0.5)*z, 1.8*z, 0, 0, Math.PI*2); X.fill();
4532
+ // legs (two angled lines from the hip, swinging opposite with cosine)
4533
+ var hipX = gx + lean, hipY = gy - 9*z + bob;
4534
+ var legSpread = 2.6*z;
4535
+ var legSwing = stepC * 3.0*z;
4536
+ X.strokeStyle = w.spec.pants; X.lineWidth = 1.9*z; X.lineCap = 'round';
4537
+ // Back leg (planted)
4538
+ X.beginPath();
4539
+ X.moveTo(hipX - legSpread*0.5, hipY);
4540
+ X.lineTo(hipX - legSpread*0.5 - legSwing, gy);
4541
+ X.stroke();
4542
+ // Front leg (swinging forward)
4543
+ X.beginPath();
4544
+ X.moveTo(hipX + legSpread*0.5, hipY);
4545
+ X.lineTo(hipX + legSpread*0.5 + legSwing, gy);
4546
+ X.stroke();
4547
+ // shoes — small dark caps at the foot so the step reads
4548
+ X.fillStyle = '#1a1410';
4549
+ X.beginPath(); X.ellipse(hipX - legSpread*0.5 - legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();
4550
+ X.beginPath(); X.ellipse(hipX + legSpread*0.5 + legSwing, gy, 1.1*z, 0.7*z, 0, 0, Math.PI*2); X.fill();
4551
+ // torso (shirt) — slightly tilted into the lean
4552
+ X.save();
4553
+ X.translate(hipX, hipY - 4*z);
4554
+ X.rotate(lean / 10);
4555
+ X.fillStyle = w.spec.shirt;
4556
+ X.beginPath(); X.roundRect(-3.2*z, -3.6*z, 6.4*z, 7.2*z, 1.6*z); X.fill();
4557
+ // belt
4558
+ X.fillStyle = 'rgba(0,0,0,0.25)';
4559
+ X.fillRect(-3.2*z, 2.4*z, 6.4*z, 0.9*z);
4560
+ X.restore();
4561
+ // arms (opposite-phase to the legs for a natural gait)
4562
+ X.strokeStyle = w.spec.shirt; X.lineWidth = 1.6*z; X.lineCap = 'round';
4563
+ var armSwing = stepC * 1.6*z;
4564
+ if(greeting){
4565
+ // waving arm: raised and gently flicking
4566
+ var flick = Math.sin(now/85) * 1.2*z;
4567
+ X.beginPath();
4568
+ X.moveTo(hipX - 2.8*z, hipY - 6*z);
4569
+ X.lineTo(hipX - 5*z + flick, hipY - 12*z);
4570
+ X.stroke();
4571
+ // resting arm — small bend so it doesn't look stiff
4572
+ X.beginPath();
4573
+ X.moveTo(hipX + 2.8*z, hipY - 6*z);
4574
+ X.lineTo(hipX + 4.4*z, hipY - 1.4*z);
4575
+ X.stroke();
4576
+ } else {
4577
+ X.beginPath();
4578
+ X.moveTo(hipX - 2.8*z, hipY - 6*z);
4579
+ X.lineTo(hipX - 4.2*z - armSwing, hipY - 0.6*z);
4580
+ X.moveTo(hipX + 2.8*z, hipY - 6*z);
4581
+ X.lineTo(hipX + 4.2*z + armSwing, hipY - 0.6*z);
4582
+ X.stroke();
4583
+ }
4584
+ // head (skin tone circle) — slight up/down nod with the step
4585
+ var headY = hipY - 11*z + bob*0.4;
4586
+ X.fillStyle = '#e8c39a';
4587
+ X.beginPath(); X.arc(hipX + lean*0.3, headY, 2.2*z, 0, Math.PI*2); X.fill();
4588
+ // hair cap (slightly different for each walker for visual variety)
4589
+ X.fillStyle = w.idx % 3 === 0 ? '#2a2018' : (w.idx % 3 === 1 ? '#4a2a18' : '#1a1810');
4590
+ 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();
4591
+ // hat / accessory variant for walker 4 & 5 so they read as distinct characters
4592
+ if(w.idx === 4 || w.idx === 5){
4593
+ X.fillStyle = w.idx === 4 ? '#3a4a6a' : '#7a5a2a';
4594
+ 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();
4595
+ }
4596
+ X.restore();
4597
+ }
4598
+
1580
4599
  function frame(){
1581
4600
  if(!running) return;
4601
+ var now = performance.now();
1582
4602
  X.clearRect(0,0,W,H);
1583
4603
  drawGround();
1584
- // depth-sort palms + nodes so nearer objects overlap farther ones
4604
+ // step the looping walkers along their fixed paths
4605
+ tickWalkers(now);
4606
+ // Re-derive status every frame so the "ended" countdown (a wall-
4607
+ // clock timer) decrements smoothly between telemetry polls (3s).
4608
+ for(var sni=0; sni<nodes.length; sni++){
4609
+ var sn = nodes[sni];
4610
+ if(sn.kind === 'robot'){
4611
+ var st = computeStatus(sn.telemetry, sn);
4612
+ sn.status = st.status;
4613
+ sn.readiness = st.readiness;
4614
+ sn.readinessMessage = st.readinessMessage;
4615
+ }
4616
+ }
4617
+ // depth-sort palms + nodes + walkers so nearer objects overlap farther ones
1585
4618
  var objs=[];
1586
4619
  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}); }
1587
4620
  for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }
4621
+ for(var wii=0; wii<walkers.length; wii++){ var wpos=walkerPos(walkers[wii]); objs.push({wx:wpos[0],wy:wpos[1],walker:walkers[wii]}); }
1588
4622
  objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });
1589
- objs.forEach(function(o){ if(o.palm) drawPalm(o.wx,o.wy); else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); } });
4623
+ objs.forEach(function(o){
4624
+ if(o.palm) drawPalm(o.wx,o.wy);
4625
+ else if(o.walker) drawWalker(o.walker, now);
4626
+ else { var n=o.n; if(n.kind==='hub')drawHub(n); else if(n.kind==='robot')drawRobot(n); else drawPeer(n); }
4627
+ });
1590
4628
  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;}
1591
4629
  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;
1592
4630
  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;
@@ -1627,22 +4665,22 @@ button.act:disabled{opacity:0.5;cursor:default;}
1627
4665
  };
1628
4666
  document.head.appendChild(s);
1629
4667
  }
1630
- function loadImages(){
1631
- imgs={};
1632
- var seen={};
1633
- SLOTS.forEach(function(s){
1634
- var src=normalizeAvatarPath(s && s.img);
1635
- if(!src || seen[src]) return;
1636
- seen[src]=1;
1637
- var i=new Image();
1638
- i.decoding='async';
1639
- var aliases=slotAliases(s);
1640
- aliases.push(src);
1641
- i.onload=function(){ aliases.forEach(function(k){ imgs[k]=i; }); };
1642
- i.onerror=function(){ aliases.forEach(function(k){ if(!(k in imgs)) imgs[k]=null; }); };
1643
- i.src=src+QS;
1644
- });
1645
- }
4668
+ function loadImages(){
4669
+ imgs={};
4670
+ var seen={};
4671
+ SLOTS.forEach(function(s){
4672
+ var src=normalizeAvatarPath(s && s.img);
4673
+ if(!src || seen[src]) return;
4674
+ seen[src]=1;
4675
+ var i=new Image();
4676
+ i.decoding='async';
4677
+ var aliases=slotAliases(s);
4678
+ aliases.push(src);
4679
+ i.onload=function(){ aliases.forEach(function(k){ imgs[k]=i; }); };
4680
+ i.onerror=function(){ aliases.forEach(function(k){ if(!(k in imgs)) imgs[k]=null; }); };
4681
+ i.src=src+QS;
4682
+ });
4683
+ }
1646
4684
  function updateSlots(newSlots){
1647
4685
  SLOTS = newSlots.slice();
1648
4686
  loadImages();
@@ -1661,20 +4699,20 @@ button.act:disabled{opacity:0.5;cursor:default;}
1661
4699
 
1662
4700
  // ── robot drawer ──
1663
4701
  function sect(title, inner){ return '<div class="pk-sect"><h4>'+title+'</h4>'+inner+'</div>'; }
1664
- function openDrawer(id){
1665
- var n=byId[id]; if(!n) return; drawerId=id;
1666
- $('pk-drawer').classList.add('open');
1667
- var av=$('pk-av'), avSrc=avatarSrcForNode(n), avImg=avatarImageForNode(n);
1668
- if(av){
1669
- if(avSrc){
1670
- av.classList.add('hasimg');
1671
- av.innerHTML = '<img alt="'+esc(n.label||n.name||'robot')+'" src="'+esc(avImg && avImg.src ? avImg.src : avSrc+QS)+'">';
1672
- } else {
1673
- av.classList.remove('hasimg');
1674
- av.textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
1675
- }
1676
- }
1677
- $('pk-name').textContent=n.label||n.name;
4702
+ function openDrawer(id){
4703
+ var n=byId[id]; if(!n) return; drawerId=id; deviceSelectorsReady=false;
4704
+ $('pk-drawer').classList.add('open');
4705
+ var av=$('pk-av'), avSrc=avatarSrcForNode(n), avImg=avatarImageForNode(n);
4706
+ if(av){
4707
+ if(avSrc){
4708
+ av.classList.add('hasimg');
4709
+ av.innerHTML = '<img alt="'+esc(n.label||n.name||'robot')+'" src="'+esc(avImg && avImg.src ? avImg.src : avSrc+QS)+'">';
4710
+ } else {
4711
+ av.classList.remove('hasimg');
4712
+ av.textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
4713
+ }
4714
+ }
4715
+ $('pk-name').textContent=n.label||n.name;
1678
4716
  $('pk-sub').textContent = n.connected ? (n.name+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'')) : 'pad reserved · not joined';
1679
4717
  var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');
1680
4718
  buildDrawer(n);
@@ -1687,11 +4725,33 @@ button.act:disabled{opacity:0.5;cursor:default;}
1687
4725
  // enrolled device's id is a generated dev_xxxx, distinct from its name).
1688
4726
  // Falls back to n.name if telemetry hasn't matched a row yet.
1689
4727
  function schedRow(n){
1690
- var row=roboparkByName[n.name];
1691
- if(!row){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }
1692
- return row;
4728
+ var row=roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase()]);
4729
+ var seen={}, candidates=[];
4730
+ for(var rk in roboparkByName){
4731
+ var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);
4732
+ if(!candidate||!cid||seen[cid]) continue;
4733
+ seen[cid]=true;
4734
+ var label=String(candidate.name||'').toLowerCase(), idlabel=String(cid).toLowerCase(), matches=false;
4735
+ 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; } }
4736
+ if(matches) candidates.push(candidate);
4737
+ }
4738
+ function readyScore(candidate){
4739
+ var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
4740
+ if(candidate.production_mode===true||device.production_mode===true) score+=1000;
4741
+ if(status==='online'||status==='running'||status==='detecting') score+=500;
4742
+ if(candidate.readiness_code==='ready') score+=250;
4743
+ if(candidate===exact) score+=10;
4744
+ return score;
4745
+ }
4746
+ candidates.sort(function(a,b){ return readyScore(b)-readyScore(a); });
4747
+ return candidates[0]||row;
4748
+ }
4749
+ function schedId(n){
4750
+ var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
4751
+ if(n.schedulerRobotId) return n.schedulerRobotId;
4752
+ var byName=schedulerDevicesByName[n.name]; if(byName&&byName.id) return byName.id;
4753
+ return n.name;
1693
4754
  }
1694
- function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }
1695
4755
  function closeDrawer(){
1696
4756
  // tell the scheduler to end any live preview so the robot stops its cam
1697
4757
  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){} } }
@@ -1753,6 +4813,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
1753
4813
  }).catch(function(e){ showOut('set config ('+robotId+'): '+e.message); });
1754
4814
  }
1755
4815
 
4816
+ // Per-robot production mode: PATCHes the device row, then immediately
4817
+ // arms/disarms this robot's continuous motion-triggered loop + always-on
4818
+ // camera feed rather than waiting for the next poll cycle to notice.
4819
+ function setProductionMode(n, on){
4820
+ var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
4821
+ rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),{production_mode:!!on}).then(function(){
4822
+ showOut('production mode '+(on?'enabled':'disabled')+' for '+(device.name||id));
4823
+ if(device) device.production_mode=!!on;
4824
+ if(on && !previewOn && !lkRoom) startCam(n);
4825
+ if(!on && previewOn) stopPreview(n);
4826
+ }).catch(function(e){ var pm=$('pk-prodmode'); if(pm) pm.checked=!on; showOut('production mode ('+id+'): '+e.message); });
4827
+ }
1756
4828
  // ── on-demand operator preview: open the robot's cam without a scene-detection session ──
1757
4829
  function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'■ Stop live':'▶ Go live'; b.classList.toggle('danger', previewOn); } }
1758
4830
  function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }
@@ -1766,12 +4838,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
1766
4838
  .then(function(info){
1767
4839
  if(!previewOn) return; // stopped while awaiting
1768
4840
  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; }
1769
- if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview · robot opening camera…'; }
4841
+ if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); tryVisionOverlay(n); if(note) note.textContent='live preview · robot opening camera…'; }
1770
4842
  else { idleCam('no-sdk'); }
1771
4843
  if(previewTimer) clearInterval(previewTimer);
1772
4844
  previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);
1773
4845
  })
1774
- .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });
4846
+ .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(); });
1775
4847
  }
1776
4848
  function stopPreview(n){
1777
4849
  previewOn=false; updateGoLive();
@@ -1784,22 +4856,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
1784
4856
  // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),
1785
4857
  // so the browser hits the robot's LAN IP straight on, no scheduler proxy.
1786
4858
  // The scheduler learns that IP from the device's own heartbeats.
1787
- function toggleVision(n){
1788
- var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');
1789
- if(visionOn){
1790
- visionOn=false; if(img){ img.src=''; img.style.display='none'; }
1791
- if(btn) btn.textContent='🔍 Show overlay';
1792
- if(note) note.textContent="object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)";
1793
- return;
4859
+ // LiveKit is the authoritative camera feed. The standalone OpenCV MJPEG
4860
+ // server is opt-in because running it beside preview_agent would open the
4861
+ // same Windows camera twice and make both feeds unreliable.
4862
+ function tryVisionOverlay(n){
4863
+ var img=$('pk-vision-img'); if(!img) return;
4864
+ if(String(window.localStorage.getItem('robopark.visionOverlay')||'').toLowerCase()!=='true'){
4865
+ visionOn=false; img.src=''; img.style.display='none'; return;
1794
4866
  }
1795
- var row=schedRow(n);
1796
- var ip=row&&row.lan_ip;
1797
- if(!ip){ if(note) note.textContent='no known IP for this robot yet — waiting for a heartbeat with lan_ip'; return; }
4867
+ var row=schedRow(n), ip=row&&row.lan_ip;
4868
+ if(!ip){ visionOn=false; img.src=''; img.style.display='none'; return; }
1798
4869
  var port=(row&&row.vision_port)||5000;
1799
4870
  visionOn=true;
1800
- if(btn) btn.textContent=' Hide overlay';
1801
- if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';
1802
- if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }
4871
+ img.onerror=function(){ visionOn=false; img.style.display='none'; img.src=''; };
4872
+ img.onload=function(){ img.style.display='block'; };
4873
+ img.src='http://'+ip+':'+port+'/video_feed';
1803
4874
  }
1804
4875
  function stopVision(){
1805
4876
  visionOn=false;
@@ -1812,10 +4883,93 @@ button.act:disabled{opacity:0.5;cursor:default;}
1812
4883
  refreshDrawer();
1813
4884
  }).catch(function(){});
1814
4885
  }
4886
+ function schedulerDevice(n){
4887
+ var row=schedRow(n), id=row&&(row.robot_id||row.id);
4888
+ return (id&&schedulerDevicesById[id]) || schedulerDevicesById[n.schedulerRobotId] || schedulerDevicesByName[n.name] || schedulerDevicesById[n.name] || null;
4889
+ }
4890
+ function deviceList(inv, key, fallback){
4891
+ var list=inv&&Array.isArray(inv[key])?inv[key]:[];
4892
+ return list.length?list:fallback.map(function(id){ return {id:id,name:id}; });
4893
+ }
4894
+ 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))); }
4895
+ function refreshDeviceSelectors(n){
4896
+ var device=schedulerDevice(n)||{}, inv=device.device_inventory||{};
4897
+ if(hasRealInventory(inv)) deviceSelectorsReady=true;
4898
+ var search=$('pk-device-search'), query=(search&&search.value||'').trim().toLowerCase();
4899
+ var video=deviceList(inv,'video',['auto','none','picamera','/dev/video0','/dev/video1','/dev/video2']);
4900
+ var input=deviceList(inv,'audio_input',['default','none']);
4901
+ var output=deviceList(inv,'audio_output',['default','none']);
4902
+ function fill(id, list, selected){
4903
+ var el=$(id); if(!el) return;
4904
+ el.innerHTML='';
4905
+ 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){
4906
+ var o=document.createElement('option'); o.value=String(x.id); o.textContent=String(x.name||x.id); if(String(x.id)===String(selected||'')) o.selected=true; el.appendChild(o);
4907
+ });
4908
+ if(!el.value && selected){ var o=document.createElement('option'); o.value=String(selected); o.textContent=String(selected)+' (saved)'; o.selected=true; el.appendChild(o); }
4909
+ }
4910
+ fill('pk-video-device', video, device.video_device||'auto');
4911
+ fill('pk-audio-input', input, device.audio_device||'default');
4912
+ fill('pk-audio-output', output, device.audio_output_device||'default');
4913
+ var greetings=$('pk-greeting-phrases');
4914
+ if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));
4915
+ var note=$('pk-device-note'); if(note) note.textContent=(device.id?'inventory from '+(device.name||device.id):'waiting for robot heartbeat inventory');
4916
+ }
4917
+ function saveDeviceSelectors(n){
4918
+ var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
4919
+ var body={video_device:$('pk-video-device').value, audio_device:$('pk-audio-input').value, audio_output_device:$('pk-audio-output').value,
4920
+ greeting_phrases:($('pk-greeting-phrases').value||'').split(String.fromCharCode(10)).map(function(x){return x.trim();}).filter(Boolean)};
4921
+ rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),body).then(function(){
4922
+ showOut('camera and microphone selection saved for '+(device.name||id));
4923
+ }).catch(function(e){ showOut('device selection: '+e.message); });
4924
+ }
4925
+ function fmtUptime(secs){
4926
+ if(secs==null) return '';
4927
+ secs=Math.round(secs);
4928
+ if(secs<60) return secs+'s';
4929
+ if(secs<3600) return Math.round(secs/60)+'m';
4930
+ return Math.round(secs/3600)+'h';
4931
+ }
4932
+ function restartService(n, svcName, btn){
4933
+ var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
4934
+ if(btn){ btn.disabled=true; btn.textContent='queuing…'; }
4935
+ rpAction('POST','/robopark/api/devices/'+encodeURIComponent(id)+'/services/'+encodeURIComponent(svcName)+'/restart',{}).then(function(){
4936
+ showOut('restart queued for '+svcName+' — picked up on the next robot check-in, up to about 10s');
4937
+ if(btn){ btn.textContent='queued'; }
4938
+ }).catch(function(e){
4939
+ showOut('restart ('+svcName+'): '+e.message);
4940
+ if(btn){ btn.disabled=false; btn.textContent='restart'; }
4941
+ });
4942
+ }
4943
+ function refreshSupervisorPanel(n){
4944
+ var list=$('pk-svc-list'), age=$('pk-svc-age'); if(!list) return;
4945
+ var device=schedulerDevice(n);
4946
+ var services=device&&device.supervisor_status;
4947
+ var reportedAt=device&&device.supervisor_status_at;
4948
+ if(!services || !services.length){
4949
+ list.innerHTML='<div class="pk-note">no supervisor report yet — either robot_supervisor.py is not running on this robot, or it has not enrolled (no device_token) yet</div>';
4950
+ if(age) age.textContent='';
4951
+ return;
4952
+ }
4953
+ list.innerHTML=services.map(function(s){
4954
+ var cls=!s.enabled?'disabled':(s.running?'up':'down');
4955
+ var meta=!s.enabled?'disabled'
4956
+ : s.running?('pid '+s.pid+' · up '+fmtUptime(s.uptime_seconds)+(s.failure_count?(' · '+s.failure_count+' restart'+(s.failure_count===1?'':'s')+' so far'):''))
4957
+ : ('down'+(s.last_exit_code!=null?(' · exit '+s.last_exit_code):'')+(s.failure_count?(' · retry #'+s.failure_count):''));
4958
+ var btn=s.enabled?('<button class="rp-btn pk-svc-restart-btn" data-svc="'+esc(s.name)+'">restart</button>'):'';
4959
+ 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>';
4960
+ }).join('');
4961
+ if(age){
4962
+ if(reportedAt){
4963
+ var ageSec=(Date.now()-new Date(reportedAt+'Z').getTime())/1000;
4964
+ age.textContent = ageSec>30 ? ('⚠ last report '+fmtUptime(ageSec)+' ago — supervisor may be down') : ('last report '+fmtUptime(ageSec)+' ago');
4965
+ age.style.color = ageSec>30 ? 'var(--bad)' : '';
4966
+ } else { age.textContent=''; }
4967
+ }
4968
+ }
1815
4969
  function buildDrawer(n){
1816
4970
  var b=$('pk-body');
1817
4971
  if(n.kind==='robot' && !n.connected){
1818
- var defaultName=(n.label||n.id||'robot').toString().toLowerCase().replace(/\s+/g,'');
4972
+ var defaultName=(n.label||n.id||'robot').toString().toLowerCase().replace(/\\s+/g,'');
1819
4973
  var presetOptions='', voiceOptions='';
1820
4974
  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>'; }
1821
4975
  for(var v=0;v<voiceStacks.length;v++){ var s=voiceStacks[v]; voiceOptions += '<option value="'+esc(s.id||'')+'">'+esc(s.name||s.id||'')+'</option>'; }
@@ -1841,7 +4995,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1841
4995
  + '<div class="rp-field"><label>Voice stack override</label><select id="pk-setup-voice"><option value="">— from character preset —</option>'+voiceOptions+'</select></div>'
1842
4996
  + '</div></details>'
1843
4997
  + '</div>'
1844
- + '<div class="pk-sect" id="pk-setup-result" style="display:none;"><h4>Enrollment token (copy once)</h4><input id="pk-setup-token" class="rp-char" readonly style="margin-bottom:0.4rem;"><button class="rp-btn" id="pk-setup-copy">Copy</button></div>');
4998
+ + '<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>');
1845
4999
  var setupBtn=$('pk-setup-btn'); if(setupBtn) setupBtn.addEventListener('click', function(){ submitPadSetup(n); });
1846
5000
  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'); } });
1847
5001
  function refreshPadCliCommand(){
@@ -1858,26 +5012,38 @@ button.act:disabled{opacity:0.5;cursor:default;}
1858
5012
  var effPresetOptions='', effVoiceOptions='';
1859
5013
  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>'; }
1860
5014
  for(var vi=0;vi<voiceStacks.length;vi++){ var vs=voiceStacks[vi]; effVoiceOptions += '<option value="'+esc(vs.id||'')+'">'+esc(vs.name||vs.id||'')+'</option>'; }
5015
+ var devForPm=schedulerDevice(n)||{};
1861
5016
  b.innerHTML =
1862
5017
  sect('👁 camera feed',
1863
5018
  '<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
5019
+ +'<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"/>'
1864
5020
  +'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
1865
- +'<div class="pk-note" id="pk-camnote">seeking LiveKit track…</div>'
1866
- +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>')
1867
- + sect('🔍 vision overlay',
1868
- '<div class="pk-cam" id="pk-vision-wrap"><img id="pk-vision-img" style="width:100%;display:none;border-radius:inherit" alt="vision feed"/></div>'
1869
- +'<div class="pk-note" id="pk-vision-note">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'
1870
- +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-vision-toggle">🔍 Show overlay</button></div>')
5021
+ +'<div class="pk-note" id="pk-camnote">seeking live feed…</div>'
5022
+ +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
5023
+ +'<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>')
1871
5024
  + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
1872
5025
  + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">awaiting robot audio plugin</div>')
1873
5026
  + sect('📊 hardware','<div class="pk-hwgrid"><div><div class="l">load</div><div class="track"><div class="fill" id="pk-hw-load"></div></div></div><div><div class="l">status</div><div class="track"><div class="fill" id="pk-hw-conn" style="background:'+(n.connected?'var(--ok)':'var(--muted)')+'"></div></div></div></div><div class="pk-note">remote node — full CPU/mem telemetry shows on the node itself</div>')
1874
5027
  + sect('🗓 session telemetry','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-trig">—</div><div class="l">triggers</div></div><div class="pk-kv"><div class="v" id="pk-tv-sess">—</div><div class="l">sessions</div></div><div class="pk-kv"><div class="v" id="pk-tv-lat">—</div><div class="l">avg latency</div></div><div class="pk-kv"><div class="v" id="pk-tv-drop">—</div><div class="l">drop rate</div></div></div>')
1875
5028
  + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn danger" id="pk-end" data-robot="'+esc(n.name)+'">End session</button><button class="rp-btn" id="pk-follow">Follow run</button></div><div class="pk-note">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>')
5029
+ + sect('🩺 robot services','<div id="pk-svc-list" class="pk-svc-list"><div class="pk-note">waiting for supervisor report…</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;">📋 Copy command</button>')
1876
5030
  + sect('🎭 character / voice','<div class="pk-note" id="pk-eff-note" style="margin-bottom:0.45rem;">loading effective config…</div><div class="rp-field"><label>Character preset</label><select class="rp-char" id="pk-eff-char" data-robot="'+esc(schedId(n))+'">'+effPresetOptions+'</select></div><div class="rp-field"><label>Voice stack</label><select class="rp-voice" id="pk-eff-voice" data-robot="'+esc(schedId(n))+'">'+effVoiceOptions+'</select></div><button class="rp-btn" id="pk-eff-save" data-robot="'+esc(schedId(n))+'" style="margin-top:0.35rem;">Set up on this robot</button>');
1877
- startCam(n); startMeters(n); loadEffectiveConfig(schedId(n));
5031
+ b.innerHTML += sect('Camera + audio devices','<div class="rp-field"><label>Search devices</label><input id="pk-device-search" placeholder="camera, USB, microphone, HDMI..." autocomplete="off"></div><div class="rp-field"><label>Camera input</label><select id="pk-video-device"></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input"></select></div><div class="rp-field"><label>Speaker / output inventory</label><select id="pk-audio-output"></select></div><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 device selection + greetings</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
5032
+ refreshDeviceSelectors(n);
5033
+ 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>';
5034
+ var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
5035
+ var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
5036
+ startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
1878
5037
  var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
1879
- var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });
5038
+ 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); });
5039
+ 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 — run it on the robot itself'); });
5040
+ var svcList=$('pk-svc-list'); if(svcList) svcList.addEventListener('click', function(ev){
5041
+ var btn=ev.target.closest('.pk-svc-restart-btn'); if(!btn) return;
5042
+ if(rpReadOnly){ showOut('read-only node'); return; }
5043
+ restartService(n, btn.dataset.svc, btn);
5044
+ });
1880
5045
  var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
5046
+ var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });
1881
5047
  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); });
1882
5048
  } else {
1883
5049
  b.innerHTML =
@@ -1890,16 +5056,31 @@ button.act:disabled{opacity:0.5;cursor:default;}
1890
5056
  }
1891
5057
  function refreshDrawer(){
1892
5058
  var n=drawerId?byId[drawerId]:null; if(!n) return;
5059
+ if(n.kind==='robot' && !deviceSelectorsReady && $('pk-video-device')){
5060
+ var devCheck=schedulerDevice(n);
5061
+ if(devCheck && hasRealInventory(devCheck.device_inventory)) refreshDeviceSelectors(n);
5062
+ }
5063
+ if(n.kind==='robot' && $('pk-cam')){
5064
+ var camDev=schedulerDevice(n);
5065
+ // Self-heal: a production-mode robot's feed should never sit idle for
5066
+ // long — if it's not currently live (no session, no preview running),
5067
+ // re-arm it. Also keep retrying the opencv vision overlay in case the
5068
+ // robot's lan_ip only became known after this panel was first drawn.
5069
+ if(camDev && camDev.production_mode && !previewOn && !lkRoom) startCam(n);
5070
+ if(!visionOn && lkRoom) tryVisionOverlay(n);
5071
+ }
5072
+ if(n.kind==='robot' && $('pk-svc-list')) refreshSupervisorPanel(n);
1893
5073
  setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);
1894
5074
  var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
1895
5075
  var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
1896
5076
  var row=schedRow(n);
5077
+ 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)'; }
1897
5078
  if(row){
1898
5079
  set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
1899
5080
  set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
1900
5081
  set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'—');
1901
5082
  var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'—'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }
1902
- var active=row.active_session||row.in_session||row.session_active;
5083
+ var active=row.active_session||row.in_session||row.session_active||row.current_session_id||row.status==='connecting'||row.status==='running';
1903
5084
  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')); }
1904
5085
  }
1905
5086
  }
@@ -1916,9 +5097,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
1916
5097
  fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
1917
5098
  .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
1918
5099
  .then(function(info){
1919
- if(info && info.active===false){ idleCam(info.reason||'idle'); return; }
5100
+ if(info && info.active===false){
5101
+ // No real session running. If this robot is in production mode,
5102
+ // keep its camera live via the same on-demand preview path "Go
5103
+ // live" uses, instead of showing an idle placeholder — production
5104
+ // mode means the feed should always be visible.
5105
+ // Production mode leaves the idle camera with the robot's motion
5106
+ // detector. Operator preview is started explicitly with Go live.
5107
+ idleCam(info.reason||'idle');
5108
+ return;
5109
+ }
1920
5110
  if(info && info.url && info.token){
1921
- if(window.LivekitClient){ attachLiveKit(info, n); }
5111
+ if(window.LivekitClient){ attachLiveKit(info, n); tryVisionOverlay(n); }
1922
5112
  else { idleCam('no-sdk'); }
1923
5113
  } else { idleCam('idle'); }
1924
5114
  })
@@ -1939,11 +5129,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
1939
5129
  var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
1940
5130
  var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
1941
5131
  if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
1942
- } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }
5132
+ } else if(track.kind==='audio'){
5133
+ hookMicAnalyser(track);
5134
+ // Muted by default: this is the camera-preview panel, running on
5135
+ // the SAME physical device as the robot in local testing. Auto-
5136
+ // playing the subscribed track (robot mic, or later the agent's
5137
+ // TTS) through the browser's speakers next to the robot's own
5138
+ // mic closes an acoustic feedback loop — heard as a nonstop echo
5139
+ // that drowns out the actual conversation. The VU meter above
5140
+ // (hookMicAnalyser) taps the raw track directly and works fine
5141
+ // muted.
5142
+ var audio=track.attach(); audio.autoplay=true; audio.controls=false; audio.muted=true; audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);
5143
+ audio.play().catch(function(){});
5144
+ }
1943
5145
  });
1944
5146
  lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='—'; });
1945
- lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
1946
- .catch(function(){ idleCam('unreachable'); });
5147
+ var livekitUrl=info.url;
5148
+ 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');
5149
+ lkRoom.connect(livekitUrl, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
5150
+ .catch(function(e){ idleCam('unreachable'); if(note) note.textContent='LiveKit connection failed'; if(window.console) console.error('RoboPark LiveKit connection failed',e); });
1947
5151
  }catch(e){ idleCam('no-sdk'); }
1948
5152
  }
1949
5153
  // Real mic level: tap the robot's subscribed 'mic' audio track with a Web
@@ -1951,7 +5155,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1951
5155
  function hookMicAnalyser(track){
1952
5156
  try{
1953
5157
  var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
1954
- audioCtx=audioCtx||new AC();
5158
+ audioCtx=audioCtx||new AC(); if(audioCtx.state==='suspended') audioCtx.resume().catch(function(){});
1955
5159
  var ms=track.mediaStreamTrack; if(!ms) return;
1956
5160
  var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
1957
5161
  var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
@@ -2004,13 +5208,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
2004
5208
  }
2005
5209
 
2006
5210
  $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);
2007
- return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots };
5211
+ return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot, updateSlots:updateSlots, updateTelemetry:updateTelemetry };
2008
5212
  })();
2009
5213
 
2010
5214
  poll();
2011
5215
  setInterval(poll, 2000);
2012
5216
  setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);
2013
5217
  setInterval(loadParkSlots, 7000);
5218
+ // Pre-load character presets and voice stacks on startup so the Park
5219
+ // robot-detail drawer has populated dropdowns even if the user never visits the robopark tab.
5220
+ loadCharacterPresets();
5221
+ loadVoiceStacks();
5222
+ setInterval(function(){ loadCharacterPresets(); loadVoiceStacks(); }, 12000);
2014
5223
  initTab();
2015
5224
  })();
2016
5225
  </script>