infinicode 2.8.97 → 2.8.99

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.
@@ -362,18 +362,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
362
362
  .pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
363
363
  .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
364
364
  .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
365
- .pk-pipeline{display:grid;gap:0.32rem;margin-top:0.6rem;}
366
- .pk-pipe-step{display:grid;grid-template-columns:0.55rem 8.2rem 1fr;gap:0.45rem;align-items:start;padding:0.32rem 0.4rem;border-radius:7px;background:rgba(255,255,255,0.025);font-family:var(--mono);font-size:0.56rem;color:var(--muted);}
367
- .pk-pipe-step i{width:0.48rem;height:0.48rem;border-radius:50%;margin-top:0.08rem;background:var(--muted);box-shadow:0 0 0 2px rgba(255,255,255,0.04);}
368
- .pk-pipe-step.running i{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1s infinite;}
369
- .pk-pipe-step.ok i{background:var(--ok);box-shadow:0 0 7px rgba(70,220,150,.45);}
370
- .pk-pipe-step.failed i,.pk-pipe-step.blocked i{background:var(--bad);box-shadow:0 0 7px rgba(255,90,90,.45);}
371
- .pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.04em;}
372
- .pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;}
373
- .pk-pipeline-summary{padding:0.5rem 0.6rem;border:1px solid var(--glass-border);border-radius:8px;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
365
+ .pk-pipeline{display:grid;gap:0.42rem;margin-top:0.7rem;position:relative;}
366
+ .pk-pipe-step{--phase:var(--gold);display:grid;grid-template-columns:2rem minmax(7.2rem,.72fr) minmax(9rem,1.35fr);gap:0.55rem;align-items:center;padding:0.55rem 0.62rem;border-radius:11px;border:1px solid rgba(255,255,255,.06);background:linear-gradient(110deg,color-mix(in srgb,var(--phase) 7%,transparent),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.58rem;color:var(--muted);transition:transform .18s,border-color .18s,background .18s;position:relative;overflow:hidden;}
367
+ .pk-pipe-step.phase-hardware{--phase:#63d5c6}.pk-pipe-step.phase-trigger{--phase:#efb94c}.pk-pipe-step.phase-session{--phase:#66a8ff}.pk-pipe-step.phase-media{--phase:#68d48f}.pk-pipe-step.phase-intelligence{--phase:#e38bff}.pk-pipe-step.phase-playback{--phase:#ff9b62}.pk-pipe-step.phase-close{--phase:#a8b0bd}
368
+ .pk-pipe-step .step-index{display:grid;place-items:center;width:1.65rem;height:1.65rem;border-radius:8px;border:1px solid color-mix(in srgb,var(--phase) 35%,transparent);color:var(--phase);background:color-mix(in srgb,var(--phase) 10%,transparent);font-weight:700;}
369
+ .pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.055em;font-weight:650;display:block;}.pk-pipe-step .phase{display:block;color:var(--phase);font-size:.49rem;letter-spacing:.08em;margin-top:.14rem;}
370
+ .pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;line-height:1.35;}.pk-pipe-step .time{display:block;color:color-mix(in srgb,var(--muted) 75%,transparent);font-size:.5rem;margin-top:.12rem;}
371
+ .pk-pipe-step.ok{border-color:color-mix(in srgb,var(--ok) 22%,transparent)}
372
+ .pk-pipe-step.failed,.pk-pipe-step.blocked{border-color:rgba(255,90,90,.48);background:linear-gradient(110deg,rgba(255,70,70,.14),rgba(255,255,255,.02));}
373
+ .pk-pipe-step.current{transform:translateX(4px);border-color:var(--phase);background:linear-gradient(105deg,color-mix(in srgb,var(--phase) 19%,transparent),rgba(255,255,255,.035));box-shadow:0 0 0 1px color-mix(in srgb,var(--phase) 18%,transparent),0 8px 28px rgba(0,0,0,.22);}
374
+ .pk-pipe-step.current:after{content:'ACTIVE';position:absolute;right:-1.7rem;top:.45rem;transform:rotate(38deg);padding:.12rem 2rem;background:var(--phase);color:#080808;font-size:.42rem;font-weight:800;letter-spacing:.08em;}
375
+ .pk-pipe-step.current .step-index{box-shadow:0 0 14px color-mix(in srgb,var(--phase) 55%,transparent);animation:pulse 1.15s infinite;}
376
+ .pk-pipeline-summary{padding:0.7rem 0.75rem;border:1px solid var(--glass-border);border-radius:11px;background:linear-gradient(120deg,rgba(212,170,72,.08),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.62rem;color:var(--muted);line-height:1.45;}
374
377
  .pk-pipeline-summary.ready{color:var(--ok);border-color:rgba(70,220,150,.35);}
375
378
  .pk-pipeline-summary.running{color:var(--warn);border-color:rgba(255,196,70,.35);}
376
379
  .pk-pipeline-summary.blocked{color:var(--bad);border-color:rgba(255,90,90,.4);}
380
+ .pk-pipe-history{margin-top:.75rem;border-top:1px solid var(--glass-border);padding-top:.7rem}.pk-pipe-history-head{display:flex;align-items:center;justify-content:space-between;gap:.6rem;margin-bottom:.5rem}.pk-pipe-history-head b{font-family:var(--mono);font-size:.65rem;color:var(--ink);text-transform:uppercase;letter-spacing:.06em}.pk-pipe-history-list{display:grid;gap:.45rem}.pk-run-card{border:1px solid var(--glass-border);border-radius:10px;background:var(--panel-2);padding:.55rem .62rem;cursor:pointer}.pk-run-card:hover{border-color:var(--gold)}.pk-run-top{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:start}.pk-run-title{font-family:var(--mono);font-size:.61rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pk-run-meta{font-family:var(--mono);font-size:.53rem;color:var(--muted);margin-top:.17rem}.pk-run-state{font-family:var(--mono);font-size:.5rem;text-transform:uppercase;border:1px solid var(--glass-border);border-radius:999px;padding:.18rem .4rem}.pk-run-state.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}.pk-run-state.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-run-state.running{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}.pk-run-stages{display:flex;gap:3px;margin-top:.45rem}.pk-run-stages i{height:4px;flex:1;border-radius:3px;background:rgba(255,255,255,.07)}.pk-run-stages i.ok{background:var(--ok)}.pk-run-stages i.failed,.pk-run-stages i.blocked{background:var(--bad)}.pk-history-pager{display:flex;align-items:center;gap:.35rem}.pk-history-pager span{font-family:var(--mono);font-size:.54rem;color:var(--muted)}
381
+ .pk-mic-suite{margin-top:.6rem;display:grid;gap:.55rem}.pk-mic-health{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem}.pk-mic-check{padding:.48rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-mic-check .k{font-family:var(--mono);font-size:.49rem;color:var(--muted);text-transform:uppercase}.pk-mic-check .v{font-family:var(--mono);font-size:.62rem;color:var(--ink);margin-top:.16rem;overflow-wrap:anywhere}.pk-mic-check.ok{border-color:color-mix(in srgb,var(--ok) 30%,transparent)}.pk-mic-check.bad{border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-mic-result{border:1px solid var(--glass-border);border-radius:10px;padding:.58rem;background:var(--panel-2);font-family:var(--mono);font-size:.58rem}.pk-mic-result.ok{border-color:var(--ok);background:rgba(60,180,110,.08)}.pk-mic-result.bad{border-color:var(--bad);background:rgba(220,80,80,.08)}.pk-mic-readings{display:grid;grid-template-columns:repeat(4,1fr);gap:.35rem;margin-top:.45rem}.pk-mic-reading{padding:.35rem;background:rgba(0,0,0,.16);border-radius:7px}.pk-mic-reading span{display:block;color:var(--muted);font-size:.48rem;text-transform:uppercase}.pk-mic-reading b{display:block;color:var(--ink);font-size:.67rem;margin-top:.1rem}
382
+ .pk-motor-registry{display:grid;gap:.4rem}.pk-motor-row{display:grid;grid-template-columns:1fr 4.5rem 5.5rem auto;gap:.35rem;align-items:end;padding:.45rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-motor-canvas{display:flex;align-items:stretch;gap:.55rem;overflow-x:auto;padding:.65rem .2rem;min-height:130px}.pk-motor-node{min-width:165px;padding:.55rem;border:1px solid rgba(255,205,93,.35);border-radius:12px;background:linear-gradient(145deg,rgba(201,150,46,.13),rgba(255,255,255,.025));position:relative}.pk-motor-node:not(:last-child):after{content:'';position:absolute;right:-.56rem;top:50%;width:.56rem;border-top:2px solid var(--gold)}.pk-motor-node .node-num{font-family:var(--mono);font-size:.48rem;color:var(--gold);text-transform:uppercase;margin-bottom:.35rem}.pk-motor-node .rp-field{margin-bottom:.3rem}.pk-motor-empty{display:grid;place-items:center;width:100%;min-height:100px;border:1px dashed var(--glass-border);border-radius:10px;color:var(--muted);font-family:var(--mono);font-size:.58rem}.pk-motor-status{font-family:var(--mono);font-size:.58rem;padding:.5rem;border-radius:8px;background:var(--panel-2);border:1px solid var(--glass-border)}.pk-motor-status.ok{color:var(--ok);border-color:var(--ok)}.pk-motor-status.bad{color:var(--bad);border-color:var(--bad)}
383
+ @media(max-width:720px){.pk-pipe-step{grid-template-columns:1.8rem 1fr}.pk-pipe-step .detail{grid-column:2;text-align:left}.pk-mic-health{grid-template-columns:1fr}.pk-mic-readings{grid-template-columns:1fr 1fr}.pk-motor-row{grid-template-columns:1fr 4.5rem}}
377
384
  .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;}
378
385
  .pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}
379
386
  .pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}
@@ -4070,8 +4077,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
4070
4077
  ];
4071
4078
  // Pond / curved water feature at the lower-left of the plaza.
4072
4079
  var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};
4073
- // Street traffic along the top road (yellow taxis + private cars).
4074
- 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']];
4080
+ // Street traffic along the top road. Each car has x, y (world), color,
4081
+ // a per-car speed (world units / second), a direction (+1 = right, -1 =
4082
+ // left). y is one of two lane bands (≈ -0.65 for outbound, ≈ -1.18 for
4083
+ // inbound) so cars don't overlap. Speeds are deliberately varied and
4084
+ // fast enough to read as movement at 60fps — the road is short, so
4085
+ // a 4-7s pass across the screen feels natural.
4086
+ var ROAD_Y0=-1.5, ROAD_Y1=-0.35;
4087
+ var CARS=[
4088
+ {x:1.5, y:-0.65, color:'#f2c14e', speed:3.2, dir:+1},
4089
+ {x:4.0, y:-1.18, color:'#7fb4ff', speed:3.6, dir:-1},
4090
+ {x:6.5, y:-0.65, color:'#e0a23c', speed:2.8, dir:+1},
4091
+ {x:9.0, y:-1.18, color:'#ff7e6e', speed:4.0, dir:-1},
4092
+ {x:11.5, y:-0.65, color:'#d8d8e0', speed:3.0, dir:+1},
4093
+ {x:0.5, y:-1.18, color:'#5b8bd6', speed:3.4, dir:-1},
4094
+ {x:13.0, y:-0.65, color:'#c87ed4', speed:2.6, dir:+1},
4095
+ {x:7.0, y:-1.18, color:'#f2c14e', speed:3.8, dir:-1},
4096
+ ];
4097
+ // Per-car phase offsets so headlights / taillights / wheels don't all
4098
+ // blink in lockstep — gives a natural "different cars, different
4099
+ // speeds" feel.
4100
+ var _carPhase = CARS.map(function(_, i){ return i * 0.7; });
4075
4101
  // ── Walking people — looping sprites that stroll along fixed plaza paths.
4076
4102
  // Each walker is a small canvas-drawn figure (head + body + two
4077
4103
  // swinging legs + swinging arms) with a vertical bob and a slight
@@ -4463,39 +4489,31 @@ button.act:disabled{opacity:0.5;cursor:default;}
4463
4489
  if(fill){ X.fillStyle=fill; X.fill(); }
4464
4490
  if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
4465
4491
  }
4466
- function drawGround(){
4492
+ function drawGround(now){
4493
+ // now is optional — only the road cars and water effects need a
4494
+ // wall-clock. We pass performance.now() in from frame() so every
4495
+ // animation reads the same time.
4496
+ if(typeof now !== 'number') now = performance.now();
4497
+ var _now = now;
4467
4498
  var z=cam.z;
4468
- // ── grass base — multi-stop radial sun-lit gradient on a slightly larger field ──
4469
- // Field is now sized to the plaza footprint + a small margin. The previous
4470
- // version extended from x=-0.5..PARK_W+0.5, which left a wide empty green
4471
- // strip on either side of the plaza; trimmed here to remove that overhang.
4499
+ // ── grass base — single uniform mid-green, no radial brightening ──
4500
+ // Park sits on a floating island now, so we want a flat, calm
4501
+ // grass tone across the whole field. The previous radial gradient
4502
+ // pushed a lighter green toward the center which read as a "spot"
4503
+ // — replaced with a single soft fill so the island feels like a
4504
+ // single piece of turf.
4472
4505
  var parkCx=PARK_W/2, parkCy=PARK_D/2;
4473
4506
  var gCent=iso(parkCx,parkCy);
4474
4507
  var gRad=X.createRadialGradient(gCent.x,gCent.y,12*z,gCent.x,gCent.y,Math.max(W,H)*0.62);
4475
- gRad.addColorStop(0,'rgba(154,196,108,0.95)');
4476
- gRad.addColorStop(0.45,'rgba(86,142,80,0.95)');
4477
- gRad.addColorStop(1,'rgba(44,98,52,0.95)');
4508
+ gRad.addColorStop(0,'rgba(94,150,84,0.95)');
4509
+ gRad.addColorStop(0.5,'rgba(86,140,76,0.95)');
4510
+ gRad.addColorStop(1,'rgba(78,128,68,0.95)');
4478
4511
  // grass field — tight margin around the plaza + lot, NOT covering the road or the beach
4479
4512
  var GF_X0=0.0, GF_X1=PARK_W, GF_Y0=-0.32, GF_Y1=PARK_D-0.55;
4480
4513
  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);
4481
- // ── "mowed stripes" effect — wide diagonal bands of alternating green tones ──
4482
- // Drawn as full-width rectangles in world space, slightly rotated via a parallelogram skew.
4483
- X.save();
4484
- var stripeColors=['rgba(108,162,90,0.45)','rgba(86,140,72,0.35)'];
4485
- var stripeCount=5;
4486
- var stripeW=(GF_X1-GF_X0+GF_Y1-GF_Y0+2)/stripeCount;
4487
- for(var si=0;si<stripeCount;si++){
4488
- var off=si*stripeW - ((GF_X1-GF_X0)+(GF_Y1-GF_Y0))*0.5;
4489
- // Build a parallelogram in world coords that runs from left-bottom to right-top
4490
- // at a slight angle (skewed +X, -Y direction) — looks like a stadium mowed lawn.
4491
- // Tightly fitted to the grass field bounds.
4492
- var p1=[GF_X0 + off, GF_Y1];
4493
- var p2=[GF_X0 + off + 1.0, GF_Y0];
4494
- var p3=[GF_X0 + off + 1.0 + (GF_X1-GF_X0), GF_Y0];
4495
- var p4=[GF_X0 + off + (GF_X1-GF_X0), GF_Y1];
4496
- poly([p1,p2,p3,p4], stripeColors[si%2], null, 0);
4497
- }
4498
- X.restore();
4514
+ // (mowed-stripe effect removed was the lighter green alternating
4515
+ // bands on the sides of the park. Keeping the lawn as a single
4516
+ // uniform tone so the park reads as a clean floating island.)
4499
4517
  // ── outer hedge / foliage border just inside the grass field ──
4500
4518
  // Slightly ragged inner edge: extra points along each side to break the straight line.
4501
4519
  // The hedge wraps the grass field, which now hugs the plaza footprint (no side overhang).
@@ -4566,16 +4584,197 @@ button.act:disabled{opacity:0.5;cursor:default;}
4566
4584
  X.closePath();
4567
4585
  X.strokeStyle='rgba(96,148,86,0.7)'; X.lineWidth=0.8*z; X.stroke();
4568
4586
  X.restore();
4569
- // grass noise — scattered lighter tufts to add texture (warm bright-green tints)
4587
+ // grass noise — scattered darker tufts for texture (no lighter
4588
+ // tufts, so the field stays a single uniform mid-green)
4570
4589
  X.save();
4571
- for(var ni=0;ni<60;ni++){
4590
+ for(var ni=0;ni<40;ni++){
4572
4591
  var gnx=GF_X0 + ((ni*37)%100)/100*(GF_X1-GF_X0);
4573
4592
  var gny=GF_Y0 + ((ni*53)%100)/100*(GF_Y1-GF_Y0);
4574
4593
  var gp=iso(gnx,gny);
4575
- X.fillStyle=(ni%2)?'rgba(150,200,110,0.20)':'rgba(56,112,62,0.22)';
4594
+ X.fillStyle='rgba(56,112,62,0.22)';
4576
4595
  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();
4577
4596
  }
4578
4597
  X.restore();
4598
+ // ── GOLD BORDER + FLOATING ISLAND DEPTH ──
4599
+ // The whole park now reads as a single floating slab of turf in
4600
+ // the sky. We render (in world space, around the grass field):
4601
+ // 1. a soft drop-shadow ellipse on the sky below the island
4602
+ // 2. a tapered rocky underside (darker, hanging from the edge)
4603
+ // 3. a thick gold rim tracing the grass perimeter
4604
+ // 4. a thin warm-glow outer ring (sells the gold as "lit by
4605
+ // the warm sun")
4606
+ // The underside and drop shadow give the park real depth so it
4607
+ // doesn't look like a flat sticker on the sky.
4608
+ // Build the grass perimeter polygon once (offset slightly outward
4609
+ // so the gold rim sits just outside the turf edge).
4610
+ var GRASS_RIM = 0.18; // world units of gold rim thickness
4611
+ function grassPerim(offset){
4612
+ // Use the same world corners as the grass field, offset outward.
4613
+ var o = offset;
4614
+ return [
4615
+ [GF_X0-o, GF_Y0-o],
4616
+ [GF_X1+o, GF_Y0-o],
4617
+ [GF_X1+o, GF_Y1+o],
4618
+ [GF_X0-o, GF_Y1+o]
4619
+ ];
4620
+ }
4621
+ // 1. drop shadow on the sky — a wide soft ellipse below the
4622
+ // island's bottom edge (in screen space, large + soft).
4623
+ var botL = iso(GF_X0, GF_Y1);
4624
+ var botR = iso(GF_X1, GF_Y1);
4625
+ var botM = { x:(botL.x+botR.x)/2, y:(botL.y+botR.y)/2 };
4626
+ var islandWidth = Math.abs(botR.x - botL.x);
4627
+ var shadowCx = botM.x;
4628
+ var shadowCy = botM.y + 28*z;
4629
+ var shadowRX = islandWidth * 0.55;
4630
+ var shadowRY = 22 * z;
4631
+ var dropShadow = X.createRadialGradient(shadowCx, shadowCy, 0, shadowCx, shadowCy, shadowRX);
4632
+ dropShadow.addColorStop(0, 'rgba(20, 24, 36, 0.55)');
4633
+ dropShadow.addColorStop(0.5, 'rgba(20, 24, 36, 0.25)');
4634
+ dropShadow.addColorStop(1, 'rgba(20, 24, 36, 0)');
4635
+ X.fillStyle = dropShadow;
4636
+ X.beginPath();
4637
+ X.ellipse(shadowCx, shadowCy, shadowRX, shadowRY, 0, 0, Math.PI*2);
4638
+ X.fill();
4639
+ // 2. rocky underside — draw the grass field polygon again, but
4640
+ // offset down by ~14*z and slightly darker, then close it into
4641
+ // a wedge. We do this with two filled paths:
4642
+ // - the bottom face: same perimeter polygon shifted down
4643
+ // - the side rim: a band that traces from the top of the
4644
+ // bottom face up to the top of the island (so the side
4645
+ // of the slab reads as dirt/rock)
4646
+ var UNDERSIDE_LIFT = 16 * z;
4647
+ var topPerim = grassPerim(0);
4648
+ var botPerim = topPerim.map(function(p){ return [p[0], p[1] + UNDERSIDE_LIFT / (TH*z) * (1/22)]; });
4649
+ // Convert botPerim into screen-space points (slightly down from top).
4650
+ var topScreen = topPerim.map(function(p){ return iso(p[0], p[1]); });
4651
+ var botScreen = topPerim.map(function(p){ return iso(p[0], p[1] + 1.0); });
4652
+ // right side face
4653
+ X.save();
4654
+ X.fillStyle = 'rgba(38, 50, 36, 0.92)';
4655
+ X.beginPath();
4656
+ X.moveTo(topScreen[1].x, topScreen[1].y);
4657
+ X.lineTo(topScreen[2].x, topScreen[2].y);
4658
+ X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);
4659
+ X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);
4660
+ X.closePath();
4661
+ X.fill();
4662
+ // bottom-right edge highlight (slight bevel)
4663
+ X.strokeStyle = 'rgba(80, 96, 60, 0.55)';
4664
+ X.lineWidth = 1.0*z;
4665
+ X.beginPath();
4666
+ X.moveTo(topScreen[2].x, topScreen[2].y);
4667
+ X.lineTo(botScreen[2].x, botScreen[2].y + UNDERSIDE_LIFT);
4668
+ X.stroke();
4669
+ // left side face
4670
+ X.fillStyle = 'rgba(28, 38, 26, 0.95)';
4671
+ X.beginPath();
4672
+ X.moveTo(topScreen[0].x, topScreen[0].y);
4673
+ X.lineTo(topScreen[1].x, topScreen[1].y);
4674
+ X.lineTo(botScreen[1].x, botScreen[1].y + UNDERSIDE_LIFT);
4675
+ X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);
4676
+ X.closePath();
4677
+ X.fill();
4678
+ // bottom-left edge highlight
4679
+ X.strokeStyle = 'rgba(70, 84, 52, 0.45)';
4680
+ X.beginPath();
4681
+ X.moveTo(topScreen[0].x, topScreen[0].y);
4682
+ X.lineTo(botScreen[0].x, botScreen[0].y + UNDERSIDE_LIFT);
4683
+ X.stroke();
4684
+ // bottom face — slightly tapered into a rough point so the slab
4685
+ // doesn't look like a flat box. We taper the bottom by ~25%.
4686
+ var tapered = topScreen.map(function(s, i){
4687
+ if(i === 0) return { x: s.x + 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };
4688
+ if(i === 1) return { x: s.x - 40*z, y: s.y + UNDERSIDE_LIFT*0.95 };
4689
+ if(i === 2) return { x: s.x - 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };
4690
+ if(i === 3) return { x: s.x + 60*z, y: s.y + UNDERSIDE_LIFT*0.85 };
4691
+ return { x: s.x, y: s.y + UNDERSIDE_LIFT };
4692
+ });
4693
+ X.fillStyle = 'rgba(20, 28, 20, 0.92)';
4694
+ X.beginPath();
4695
+ X.moveTo(topScreen[0].x, topScreen[0].y);
4696
+ X.lineTo(topScreen[1].x, topScreen[1].y);
4697
+ X.lineTo(tapered[1].x, tapered[1].y);
4698
+ X.lineTo(tapered[2].x, tapered[2].y);
4699
+ X.lineTo(tapered[3].x, tapered[3].y);
4700
+ X.lineTo(tapered[0].x, tapered[0].y);
4701
+ X.closePath();
4702
+ X.fill();
4703
+ // a few rocky stalactite bumps hanging from the underside so it
4704
+ // doesn't look like a perfect box either
4705
+ var bumpSpots = [
4706
+ { px: 0.18, len: 0.65 },
4707
+ { px: 0.34, len: 0.85 },
4708
+ { px: 0.52, len: 0.55 },
4709
+ { px: 0.68, len: 0.95 },
4710
+ { px: 0.86, len: 0.70 }
4711
+ ];
4712
+ for(var bpi=0; bpi<bumpSpots.length; bpi++){
4713
+ var bx = GF_X0 + bumpSpots[bpi].px * (GF_X1-GF_X0);
4714
+ var by = GF_Y1;
4715
+ var bp = iso(bx, by);
4716
+ var bpLen = bumpSpots[bpi].len * UNDERSIDE_LIFT;
4717
+ X.fillStyle = 'rgba(24, 32, 22, 0.88)';
4718
+ X.beginPath();
4719
+ X.moveTo(bp.x - 14*z, bp.y + 2*z);
4720
+ // 2-sided stalactite that tapers to a point
4721
+ X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x, bp.y + bpLen);
4722
+ X.quadraticCurveTo(bp.x, bp.y + bpLen*0.5, bp.x + 14*z, bp.y + 2*z);
4723
+ X.closePath();
4724
+ X.fill();
4725
+ }
4726
+ X.restore();
4727
+ // 3. GOLD RIM — thick gold band tracing the grass perimeter.
4728
+ // Drawn AFTER the underside so the top edge of the gold
4729
+ // sits flush with the top of the grass. Two strokes: a wide
4730
+ // gold core + a thin lighter inner highlight on the top edge.
4731
+ var rimOuter = grassPerim(GRASS_RIM);
4732
+ var rimInner = grassPerim(0);
4733
+ var rimOuterScreen = rimOuter.map(function(p){ return iso(p[0], p[1]); });
4734
+ var rimInnerScreen = rimInner.map(function(p){ return iso(p[0], p[1]); });
4735
+ X.save();
4736
+ // outer glow
4737
+ var glowGrad = X.createLinearGradient(0, rimOuterScreen[0].y - 30, 0, rimOuterScreen[3].y + 30);
4738
+ X.fillStyle = 'rgba(255, 200, 110, 0.0)';
4739
+ // fill the rim band as a quad
4740
+ X.beginPath();
4741
+ X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
4742
+ X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
4743
+ X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
4744
+ X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
4745
+ X.closePath();
4746
+ X.fill();
4747
+ // gold band via two strokes: thick base + thin highlight
4748
+ X.strokeStyle = 'rgba(255, 196, 92, 0.95)'; // base gold
4749
+ X.lineWidth = 5.0*z;
4750
+ X.beginPath();
4751
+ X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4752
+ X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
4753
+ X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
4754
+ X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4755
+ X.closePath();
4756
+ X.stroke();
4757
+ // inner highlight (thinner, brighter)
4758
+ X.strokeStyle = 'rgba(255, 232, 158, 0.95)';
4759
+ X.lineWidth = 1.4*z;
4760
+ X.beginPath();
4761
+ X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4762
+ X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
4763
+ X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
4764
+ X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4765
+ X.closePath();
4766
+ X.stroke();
4767
+ // outer deep-gold edge (slightly darker outer stroke for definition)
4768
+ X.strokeStyle = 'rgba(190, 134, 48, 0.85)';
4769
+ X.lineWidth = 1.2*z;
4770
+ X.beginPath();
4771
+ X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
4772
+ X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
4773
+ X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
4774
+ X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
4775
+ X.closePath();
4776
+ X.stroke();
4777
+ X.restore();
4579
4778
  // ── flower spots — deterministic small dots in 3 colors, scattered in the lawn only ──
4580
4779
  X.save();
4581
4780
  var flowerColors=['rgba(232,124,164,0.85)','rgba(248,220,96,0.88)','rgba(248,248,240,0.85)'];
@@ -4690,7 +4889,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
4690
4889
  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);
4691
4890
  X.strokeStyle='rgba(232,210,120,0.55)'; X.setLineDash([9*z,9*z]);
4692
4891
  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([]);
4693
- CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
4892
+ // animated road cars — positions updated each frame by tickCars()
4893
+ // draw order: outbound lane first (front), then inbound (back) for natural overlap
4894
+ for(var cai=0; cai<CARS.length; cai++){
4895
+ var car = CARS[cai];
4896
+ drawCar(car.x, car.y, car.color, car.dir, _carPhase[cai]||0, _now);
4897
+ }
4694
4898
  // white event tent in the left grass strip (west of the plaza)
4695
4899
  drawTent(-0.15, 0.5);
4696
4900
  // ── lake — wider sandy shore with soft radial fade to grass ──
@@ -4743,7 +4947,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
4743
4947
  X.restore();
4744
4948
  // ── animated ripple/highlight — thin white elliptical arcs that drift across the lake ──
4745
4949
  var pondCent=iso(POND.cx, POND.cy);
4746
- var ripplePhase=Math.sin(performance.now()/1500);
4950
+ var ripplePhase=Math.sin(_now/1500);
4747
4951
  X.save();
4748
4952
  X.strokeStyle='rgba(232,248,252,0.7)'; X.lineWidth=1.0*z; X.lineCap='round';
4749
4953
  for(var rk=0;rk<4;rk++){
@@ -4754,7 +4958,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
4754
4958
  var rCy2=POND.cy - 0.10 + rk*0.12;
4755
4959
  var rA2=iso(rCx2-rxr2, rCy2);
4756
4960
  var rB2=iso(rCx2+rxr2, rCy2);
4757
- X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(performance.now()/900 + rk));
4961
+ X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(_now/900 + rk));
4758
4962
  X.beginPath();
4759
4963
  X.moveTo(rA2.x, rA2.y);
4760
4964
  X.quadraticCurveTo(pondCent.x, rA2.y - 3*z, rB2.x, rB2.y);
@@ -4801,7 +5005,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
4801
5005
  // ── tiny fountain — 3 concentric rings of decreasing opacity around lake centroid ──
4802
5006
  X.save();
4803
5007
  for(var fk=0;fk<3;fk++){
4804
- var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(performance.now()/600 + fk));
5008
+ var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(_now/600 + fk));
4805
5009
  var falpha=0.55 - fk*0.18;
4806
5010
  var fcol='rgba(220,240,248,'+falpha.toFixed(3)+')';
4807
5011
  var fringPts=[];
@@ -4937,54 +5141,271 @@ button.act:disabled{opacity:0.5;cursor:default;}
4937
5141
  X.fillStyle='rgba(232,72,72,0.95)';
4938
5142
  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();
4939
5143
  }
4940
- function drawCar(wx,wy,col){
5144
+ function drawCar(wx,wy,col,dir,phase,now){
4941
5145
  var p=iso(wx,wy), z=cam.z;
4942
- // soft drop shadow
5146
+ // Optional animation params: dir (+1 right / -1 left), phase (per-car
5147
+ // offset in seconds), now (performance.now in ms). Falls back to the
5148
+ // static parked-car look when these are undefined.
5149
+ var moving = (dir===1 || dir===-1) && typeof now === 'number';
5150
+ // gentle vertical bob so a moving car looks like it has suspension
5151
+ var bob = moving ? Math.sin((now/180 + (phase||0)*1000)) * 0.5*z : 0;
5152
+ // soft drop shadow — slight motion blur for moving cars
4943
5153
  X.fillStyle='rgba(0,0,0,0.32)';
4944
5154
  X.beginPath(); X.ellipse(p.x,p.y+4*z,13*z,5.5*z,0,0,7); X.fill();
5155
+ if(moving){
5156
+ // ground shadow streak behind the car (opposite to direction of travel)
5157
+ var sx = p.x - dir*8*z;
5158
+ X.fillStyle='rgba(0,0,0,0.10)';
5159
+ X.beginPath(); X.ellipse(sx, p.y+4.5*z, 9*z, 4*z, 0, 0, 7); X.fill();
5160
+ // soft headlight beam projected on the road in front of the car
5161
+ var beamFrontX = p.x + dir*16*z;
5162
+ var beam = X.createRadialGradient(beamFrontX, p.y+1*z, 1*z, beamFrontX, p.y+1*z, 14*z);
5163
+ beam.addColorStop(0, 'rgba(255,242,200,0.32)');
5164
+ beam.addColorStop(0.5, 'rgba(255,242,200,0.10)');
5165
+ beam.addColorStop(1, 'rgba(255,242,200,0)');
5166
+ X.save();
5167
+ X.fillStyle = beam;
5168
+ X.beginPath(); X.ellipse(beamFrontX, p.y+1*z, 14*z, 6*z, 0, 0, Math.PI*2); X.fill();
5169
+ X.restore();
5170
+ }
4945
5171
  // chassis (lower body)
4946
5172
  X.fillStyle=col;
4947
- X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z,26*z,11*z,3*z); X.fill();
5173
+ X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z+bob,26*z,11*z,3*z); X.fill();
4948
5174
  // chassis bottom shade
4949
5175
  X.fillStyle='rgba(0,0,0,0.22)';
4950
- X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z,26*z,4*z,2*z); X.fill();
5176
+ X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z+bob,26*z,4*z,2*z); X.fill();
4951
5177
  // roof (darker top)
4952
5178
  X.fillStyle='rgba(0,0,0,0.55)';
4953
- X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
5179
+ X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();
4954
5180
  // roof tint with car color
4955
5181
  X.fillStyle=col;
4956
5182
  X.globalAlpha=0.6;
4957
- X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
5183
+ X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();
4958
5184
  X.globalAlpha=1;
4959
5185
  // windshield highlight
4960
5186
  X.fillStyle='rgba(180,220,255,0.65)';
4961
- X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z,12*z,3*z,1.5*z); X.fill();
5187
+ X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z+bob,12*z,3*z,1.5*z); X.fill();
4962
5188
  // windshield shine
4963
5189
  X.fillStyle='rgba(255,255,255,0.45)';
4964
5190
  X.beginPath();
4965
- 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();
4966
- // wheels
5191
+ X.moveTo(p.x-5*z,p.y-9*z+bob); X.lineTo(p.x-2*z,p.y-9*z+bob); X.lineTo(p.x-4*z,p.y-7*z+bob); X.closePath(); X.fill();
5192
+ // wheels — animated spokes when the car is moving (rotation = distance traveled)
4967
5193
  X.fillStyle='#0c0c10';
4968
- X.beginPath(); X.arc(p.x-8*z,p.y+5*z,2.6*z,0,7); X.fill();
4969
- X.beginPath(); X.arc(p.x+8*z,p.y+5*z,2.6*z,0,7); X.fill();
4970
- X.beginPath(); X.arc(p.x-8*z,p.y+6*z,2.6*z,0,7); X.fill();
4971
- X.beginPath(); X.arc(p.x+8*z,p.y+6*z,2.6*z,0,7); X.fill();
4972
- // wheel rims
5194
+ X.beginPath(); X.arc(p.x-8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();
5195
+ X.beginPath(); X.arc(p.x+8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();
5196
+ X.beginPath(); X.arc(p.x-8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();
5197
+ X.beginPath(); X.arc(p.x+8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();
5198
+ // wheel rims with a moving spoke mark so wheels visibly spin
4973
5199
  X.fillStyle='rgba(200,200,210,0.75)';
4974
- X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z,1*z,0,7); X.fill();
4975
- X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z,1*z,0,7); X.fill();
4976
- // headlight glow (frontright side)
5200
+ X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();
5201
+ X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();
5202
+ // wheel spoke (rotates with speed) gives the impression of motion
5203
+ if(moving){
5204
+ var spokeAng = (now/40 + (phase||0)*1000) % (Math.PI*2);
5205
+ var spokeDx = Math.cos(spokeAng) * 1.6*z;
5206
+ var spokeDy = Math.sin(spokeAng) * 1.6*z;
5207
+ X.strokeStyle='rgba(220,220,230,0.85)'; X.lineWidth=0.5*z;
5208
+ X.beginPath(); X.moveTo(p.x-8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x-8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();
5209
+ X.beginPath(); X.moveTo(p.x+8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x+8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();
5210
+ }
5211
+ // headlight (front — the side the car is driving toward)
5212
+ var frontX = p.x + (dir||1)*12*z;
4977
5213
  X.fillStyle='rgba(255,240,180,0.95)';
4978
- X.beginPath(); X.arc(p.x+12*z,p.y-1*z,1.6*z,0,7); X.fill();
5214
+ X.beginPath(); X.arc(frontX, p.y-1*z+bob, 1.6*z, 0, 7); X.fill();
4979
5215
  X.fillStyle='rgba(255,240,180,0.4)';
4980
- X.beginPath(); X.arc(p.x+12*z,p.y-1*z,3*z,0,7); X.fill();
4981
- // tail light (rear — left side)
5216
+ X.beginPath(); X.arc(frontX, p.y-1*z+bob, 3*z, 0, 7); X.fill();
5217
+ // taillight (rear — opposite side, slightly redder)
5218
+ var rearX = p.x - (dir||1)*12*z;
4982
5219
  X.fillStyle='rgba(255,80,80,0.85)';
4983
- X.beginPath(); X.arc(p.x-12*z,p.y-1*z,1.2*z,0,7); X.fill();
5220
+ X.beginPath(); X.arc(rearX, p.y-1*z+bob, 1.2*z, 0, 7); X.fill();
5221
+ if(moving){
5222
+ // a small red glow on the road behind the car
5223
+ X.fillStyle='rgba(255,80,80,0.25)';
5224
+ X.beginPath(); X.arc(rearX - dir*3*z, p.y+1*z+bob, 2.6*z, 0, 7); X.fill();
5225
+ }
4984
5226
  // body highlight
4985
5227
  X.fillStyle='rgba(255,255,255,0.18)';
4986
- X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z,24*z,1.5*z,1*z); X.fill();
5228
+ X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z+bob,24*z,1.5*z,1*z); X.fill();
4987
5229
  }
5230
+
5231
+ // ── Atmosphere: sky, sun, distant mountains, clouds, haze ──
5232
+ // Painted on top of the cleared canvas and below the rest of the map
5233
+ // so the map looks like it sits in an actual world instead of on a
5234
+ // blank panel. Cheap to render (a handful of full-canvas fills + a
5235
+ // few silhouette paths + a small cloud set) and turns the whole
5236
+ // scene from "miniature on a black velvet" into "park at golden hour".
5237
+ //
5238
+ // Everything is drawn in screen space, not world space, because the
5239
+ // sky doesn't follow the iso projection — it's a backdrop, not a
5240
+ // surface. Distant mountains live just above the road horizon line,
5241
+ // the sun is fixed near the upper-right (a warm focal point), and a
5242
+ // handful of soft clouds drift slowly across the sky.
5243
+ function drawAtmosphere(now){
5244
+ if(!W || !H) return;
5245
+ var z = cam.z;
5246
+
5247
+ // ── sky gradient — warm late-afternoon palette (top to bottom) ──
5248
+ // Top: deep cobalt → mid: soft sky → low: warm gold haze. Anchored
5249
+ // to a virtual horizon line ~38% from the top of the stage so the
5250
+ // map always sits on the same horizon regardless of zoom.
5251
+ var horizon = H * 0.42;
5252
+ var sky = X.createLinearGradient(0, 0, 0, H);
5253
+ sky.addColorStop(0, 'rgba(46, 78, 132, 0.95)'); // deep sky
5254
+ sky.addColorStop(0.30, 'rgba(108, 152, 196, 0.85)'); // mid sky
5255
+ sky.addColorStop(0.46, 'rgba(214, 184, 138, 0.78)'); // warm horizon
5256
+ sky.addColorStop(0.55, 'rgba(244, 222, 168, 0.62)'); // soft golden glow
5257
+ sky.addColorStop(0.75, 'rgba(248, 232, 200, 0.20)'); // fades into the ground area
5258
+ sky.addColorStop(1, 'rgba(248, 232, 200, 0)');
5259
+ X.fillStyle = sky;
5260
+ X.fillRect(0, 0, W, H);
5261
+
5262
+ // ── sun — a soft warm disc with a wide glow halo ──
5263
+ // Anchored at ~78% across, 22% down so it reads as the off-screen
5264
+ // light source warming the whole scene. The glow pulses very
5265
+ // gently so it doesn't feel like a static sticker.
5266
+ var sunX = W * 0.78;
5267
+ var sunY = H * 0.22;
5268
+ var sunR = 28 * (0.95 + 0.05 * Math.sin(now/2400));
5269
+ // outer warm glow
5270
+ var glow = X.createRadialGradient(sunX, sunY, sunR*0.6, sunX, sunY, sunR*7);
5271
+ glow.addColorStop(0, 'rgba(255, 222, 140, 0.55)');
5272
+ glow.addColorStop(0.25,'rgba(255, 210, 130, 0.30)');
5273
+ glow.addColorStop(0.6, 'rgba(255, 200, 120, 0.08)');
5274
+ glow.addColorStop(1, 'rgba(255, 200, 120, 0)');
5275
+ X.fillStyle = glow;
5276
+ X.fillRect(0, 0, W, H);
5277
+ // sun disc
5278
+ var disc = X.createRadialGradient(sunX, sunY, sunR*0.2, sunX, sunY, sunR);
5279
+ disc.addColorStop(0, 'rgba(255, 250, 230, 1)');
5280
+ disc.addColorStop(0.6, 'rgba(255, 232, 170, 0.95)');
5281
+ disc.addColorStop(1, 'rgba(255, 210, 130, 0)');
5282
+ X.fillStyle = disc;
5283
+ X.beginPath(); X.arc(sunX, sunY, sunR, 0, Math.PI*2); X.fill();
5284
+
5285
+ // ── distant mountain silhouette (3 receding layers) ──
5286
+ // Each layer uses a slightly lighter color so the eye reads depth.
5287
+ // Anchored just below the horizon so the iso map sits in front of
5288
+ // them. Heights/shapes are deterministic so the silhouette doesn't
5289
+ // shimmer between frames.
5290
+ function mountainLayer(baseY, amp, segments, color, seedOff){
5291
+ X.fillStyle = color;
5292
+ X.beginPath();
5293
+ X.moveTo(0, H);
5294
+ // sample the silhouette using a sum of low-frequency sines
5295
+ for(var mx=0; mx<=W; mx+=8){
5296
+ var t = mx / W;
5297
+ var h = (
5298
+ Math.sin(t*6.3 + seedOff) * 0.5 +
5299
+ Math.sin(t*12.5 + seedOff*2.3) * 0.28 +
5300
+ Math.sin(t*22.1 + seedOff*0.7) * 0.14
5301
+ ) * amp;
5302
+ X.lineTo(mx, baseY - h);
5303
+ }
5304
+ X.lineTo(W, H);
5305
+ X.closePath();
5306
+ X.fill();
5307
+ }
5308
+ // farther layer (lighter haze, lower)
5309
+ mountainLayer(H*0.46, 28, 18, 'rgba(120, 132, 158, 0.45)', 1.7);
5310
+ // middle layer
5311
+ mountainLayer(H*0.48, 36, 16, 'rgba(86, 100, 124, 0.55)', 3.1);
5312
+ // closer ridge (darkest, in front of the plaza)
5313
+ mountainLayer(H*0.50, 22, 14, 'rgba(54, 68, 86, 0.60)', 0.4);
5314
+
5315
+ // ── soft cloud band — 5 elongated ellipses drifting right ──
5316
+ // Drawn as flat white ellipses with low alpha so they look like
5317
+ // wisps, not puffs. Each cloud has its own period so they don't
5318
+ // march in lockstep.
5319
+ var cloudColors = [
5320
+ 'rgba(255,255,255,0.55)',
5321
+ 'rgba(255,250,240,0.45)',
5322
+ 'rgba(248,242,228,0.40)',
5323
+ 'rgba(255,250,235,0.35)',
5324
+ 'rgba(252,246,232,0.50)',
5325
+ ];
5326
+ for(var ci=0; ci<cloudColors.length; ci++){
5327
+ var period = 90 + ci*22; // seconds for a full pass
5328
+ var drift = ((now/1000) / period) % 1; // 0..1
5329
+ var cx = (drift * (W + 240)) - 120; // wrap from -120 to W+120
5330
+ var cy = H*0.10 + ci*16;
5331
+ var rx = 60 + ci*8;
5332
+ var ry = 10 + (ci%3)*3;
5333
+ X.fillStyle = cloudColors[ci];
5334
+ // fluffy edge: 3 overlapping ellipses
5335
+ X.beginPath(); X.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2); X.fill();
5336
+ X.beginPath(); X.ellipse(cx - rx*0.5, cy + ry*0.4, rx*0.7, ry*0.7, 0, 0, Math.PI*2); X.fill();
5337
+ X.beginPath(); X.ellipse(cx + rx*0.5, cy + ry*0.3, rx*0.7, ry*0.6, 0, 0, Math.PI*2); X.fill();
5338
+ }
5339
+
5340
+ // ── soft warm light wash on the upper third of the map ──
5341
+ // Sits in screen space too: a wide radial glow over the entire
5342
+ // map area that makes the grass + plaza read as if lit from the
5343
+ // direction of the sun (upper-right). Without this the map looks
5344
+ // like it was cut out and pasted in.
5345
+ var wash = X.createRadialGradient(sunX, sunY*0.7, 40, sunX, sunY*0.7, Math.max(W, H)*0.85);
5346
+ wash.addColorStop(0, 'rgba(255, 220, 150, 0.16)');
5347
+ wash.addColorStop(0.35, 'rgba(255, 200, 130, 0.06)');
5348
+ wash.addColorStop(1, 'rgba(255, 200, 130, 0)');
5349
+ X.fillStyle = wash;
5350
+ X.fillRect(0, 0, W, H);
5351
+
5352
+ // ── subtle haze band along the horizon ──
5353
+ // Sits just above the road so the cars pop against a soft
5354
+ // horizon-glow rather than a hard line.
5355
+ var haze = X.createLinearGradient(0, horizon - 30, 0, horizon + 50);
5356
+ haze.addColorStop(0, 'rgba(255, 220, 160, 0)');
5357
+ haze.addColorStop(0.5, 'rgba(255, 220, 160, 0.22)');
5358
+ haze.addColorStop(1, 'rgba(255, 220, 160, 0)');
5359
+ X.fillStyle = haze;
5360
+ X.fillRect(0, horizon - 30, W, 80);
5361
+
5362
+ // ── ambient particles — drifting motes / pollen / fireflies ──
5363
+ // 40 tiny dots that float across the scene. Each one is held in
5364
+ // an array; the array is seeded once (so they don't all start at
5365
+ // the same position) and advanced by a tiny per-frame velocity.
5366
+ // On first call we lazily seed it; on resize we re-distribute.
5367
+ if(!_atmos.particles){
5368
+ _atmos.particles = [];
5369
+ for(var pi=0; pi<40; pi++){
5370
+ _atmos.particles.push({
5371
+ x: Math.random()*W,
5372
+ y: H*0.20 + Math.random()*H*0.55,
5373
+ r: 0.8 + Math.random()*1.6,
5374
+ vx: -6 + Math.random()*12, // px/sec, mostly right
5375
+ vy: -2 + Math.random()*4, // slight up/down sway
5376
+ phase: Math.random()*Math.PI*2,
5377
+ twinkle: 0.5 + Math.random()*0.5, // brightness amplitude
5378
+ hue: Math.random() < 0.35 ? 'warm' : 'cool', // mix of gold + silver
5379
+ });
5380
+ }
5381
+ }
5382
+ var dt = _atmos.lastNow ? Math.min(64, now - _atmos.lastNow)/1000 : 0.016;
5383
+ _atmos.lastNow = now;
5384
+ for(var pj=0; pj<_atmos.particles.length; pj++){
5385
+ var pt = _atmos.particles[pj];
5386
+ // simple wrap
5387
+ pt.x += pt.vx * dt;
5388
+ pt.y += Math.sin(now/1200 + pt.phase) * 0.3;
5389
+ if(pt.x > W + 8) pt.x = -8;
5390
+ if(pt.x < -8) pt.x = W + 8;
5391
+ if(pt.y < H*0.10) pt.y = H*0.75;
5392
+ if(pt.y > H*0.80) pt.y = H*0.15;
5393
+ // soft twinkle alpha
5394
+ var a = 0.25 + 0.45 * Math.abs(Math.sin(now/700 + pt.phase)) * pt.twinkle;
5395
+ X.fillStyle = pt.hue === 'warm'
5396
+ ? 'rgba(255, 230, 160, '+a.toFixed(3)+')'
5397
+ : 'rgba(220, 235, 250, '+a.toFixed(3)+')';
5398
+ X.beginPath(); X.arc(pt.x, pt.y, pt.r, 0, Math.PI*2); X.fill();
5399
+ // tiny halo
5400
+ X.fillStyle = pt.hue === 'warm'
5401
+ ? 'rgba(255, 220, 140, '+(a*0.35).toFixed(3)+')'
5402
+ : 'rgba(200, 220, 245, '+(a*0.30).toFixed(3)+')';
5403
+ X.beginPath(); X.arc(pt.x, pt.y, pt.r*2.5, 0, Math.PI*2); X.fill();
5404
+ }
5405
+ }
5406
+ // Holds the persistent atmospheric state (particle positions + last tick time).
5407
+ var _atmos = { particles: null, lastNow: 0 };
5408
+
4988
5409
  // Sun-shade pavilion: 4 thin white poles supporting a flat white square canopy.
4989
5410
  function drawPavilion(wx,wy){
4990
5411
  var p=iso(wx,wy), z=cam.z;
@@ -5839,6 +6260,26 @@ button.act:disabled{opacity:0.5;cursor:default;}
5839
6260
  }
5840
6261
  }
5841
6262
  }
6263
+ // Cars share the same elapsed-dt clock so a tab-switch doesn't
6264
+ // catapult every car off the road at once. speed is world units
6265
+ // per second, dt is milliseconds. cdt is the already-clamped dt.
6266
+ var cdt = Math.min(64, dt);
6267
+ for(var ci=0; ci<CARS.length; ci++){
6268
+ var car = CARS[ci];
6269
+ car.x += car.dir * car.speed * (cdt/1000);
6270
+ // Wrap the car around the road. When it leaves one end, it
6271
+ // re-enters the opposite end on the other lane so oncoming
6272
+ // traffic feels continuous.
6273
+ if(car.dir > 0 && car.x > PARK_W + 0.5){
6274
+ car.x = -0.5;
6275
+ car.y = (car.y < -0.9) ? -0.65 : -1.18;
6276
+ car.dir = (car.y < -0.9) ? +1 : -1;
6277
+ } else if(car.dir < 0 && car.x < -0.5){
6278
+ car.x = PARK_W + 0.5;
6279
+ car.y = (car.y < -0.9) ? -0.65 : -1.18;
6280
+ car.dir = (car.y < -0.9) ? +1 : -1;
6281
+ }
6282
+ }
5842
6283
  }
5843
6284
  function walkerPos(w){
5844
6285
  // Decode w.distance (in units of "segments") into a (seg, t) so
@@ -5952,8 +6393,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
5952
6393
  if(!running) return;
5953
6394
  var now = performance.now();
5954
6395
  X.clearRect(0,0,W,H);
5955
- drawGround();
5956
- // step the looping walkers along their fixed paths
6396
+ // ── atmosphere: sky + sun + clouds + distant mountains + haze ──
6397
+ // Painted first, behind everything else, so the rest of the map
6398
+ // sits on top of an actual horizon instead of on the empty page
6399
+ // background. Drawn every frame so the sun and clouds drift even
6400
+ // when the rest of the scene is static.
6401
+ drawAtmosphere(now);
6402
+ drawGround(now);
6403
+ // step the looping walkers (and road cars) along their paths
5957
6404
  tickWalkers(now);
5958
6405
  // Re-derive status every frame so the "ended" countdown (a wall-
5959
6406
  // clock timer) decrements smoothly between telemetry polls (3s).
@@ -6721,6 +7168,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6721
7168
  + '<div class="pk-ctl" style="margin-top:0.55rem"><button class="rp-btn primary" id="pk-unattended-test">Run unattended readiness</button><button class="rp-btn" id="pk-pipeline-start">Start full test</button><button class="rp-btn danger" id="pk-pipeline-stop">Stop conversation</button></div>'
6722
7169
  + '<div class="pk-note" id="pk-unattended-result">Unattended readiness runs an acoustic speaker/mic round-trip, triggers the production motion path, and requires greeting playback on the robot.</div>'
6723
7170
  + '<div class="pk-pipeline" id="pk-pipeline"></div>')
7171
+ + sect('Production run history','<div class="pk-pipe-history" style="margin-top:0;padding-top:0;border-top:0"><div class="pk-pipe-history-head"><b>Persistent sessions</b><div class="pk-history-pager"><button class="rp-btn" id="pk-history-prev">Prev</button><span id="pk-history-page">page 1</span><button class="rp-btn" id="pk-history-next">Next</button></div></div><div class="pk-pipe-history-list" id="pk-pipeline-history"><div class="pk-note">Loading persistent sessions...</div></div></div>')
6724
7172
  + 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><div class="pk-ctl" style="margin-top:.5rem"><button class="rp-btn" id="pk-mic-listen">Listen in browser</button><label class="pk-note" style="display:flex;align-items:center;gap:.35rem;margin:0">volume <input id="pk-mic-volume" type="range" min="0" max="100" value="80" style="width:90px"></label></div><div class="pk-note" id="pk-mic-listen-note">Muted by default. Use headphones to prevent acoustic feedback.</div>')
6725
7173
  + 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">TTS plays on the robot speaker — use “Test speaker” below to round-trip a tone and confirm the audio path</div>'
6726
7174
  + '<div class="rp-form" id="pk-spk-test" style="grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);">'
@@ -6743,6 +7191,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
6743
7191
  + 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>')
6744
7192
  + buildShellSection(schedId(n))
6745
7193
  + 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><div class="rp-config-actions" style="margin-top:.45rem"><button class="rp-btn primary" id="pk-eff-save" data-robot="'+esc(schedId(n))+'">Apply to robot</button><button class="rp-btn" id="pk-edit-character">Edit character</button><button class="rp-btn" id="pk-edit-stack">Edit stack</button><button class="rp-btn" id="pk-new-stack">New stack</button></div>');
7194
+ b.innerHTML += sect('Microphone setup suite','<div class="pk-mic-suite"><div class="pk-mic-health" id="pk-mic-health"><div class="pk-mic-check"><div class="k">Hardware lock</div><div class="v">USB mic hw:3,0</div></div><div class="pk-mic-check"><div class="k">PCM capture</div><div class="v">not tested</div></div><div class="pk-mic-check"><div class="k">Production track</div><div class="v">waiting</div></div></div><div class="rp-form" style="grid-template-columns:1fr 1fr;gap:.4rem"><div class="rp-field"><label>Ground-truth capture</label><select id="pk-mic-duration"><option value="2">2 seconds</option><option value="4" selected>4 seconds</option><option value="8">8 seconds</option></select></div><div class="rp-field"><label>Locked input</label><div class="pk-note" style="margin:.45rem 0 0">USB microphone (plughw:3,0)</div></div></div><div class="pk-ctl"><button class="rp-btn primary" id="pk-mic-groundtruth">Run raw mic capture</button><button class="rp-btn" id="pk-mic-roundtrip">Run speaker + mic test</button><button class="rp-btn" id="pk-mic-apply">Reapply production profile</button></div><div class="pk-note" id="pk-mic-test-state">Raw capture bypasses LiveKit and tests the exact ALSA path. Round-trip then verifies the physical speaker-to-mic path.</div><div id="pk-mic-test-result"></div></div>');
7195
+ b.innerHTML += sect('Motor registry + sequence canvas','<div class="rp-field"><label>Robot-local motor server</label><input id="pk-motor-url" value="http://127.0.0.1:8001" placeholder="http://127.0.0.1:8001"></div><div class="pk-motor-registry" id="pk-motor-registry"></div><button class="rp-btn" id="pk-motor-add" style="margin-top:.4rem">Add registered motor</button><div class="rp-form" style="grid-template-columns:1fr 1fr;margin-top:.7rem"><div class="rp-field"><label>Saved sequence</label><select id="pk-motor-sequence"></select></div><div class="rp-field"><label>Sequence name</label><input id="pk-motor-sequence-name" placeholder="Greeting motion"></div></div><div class="pk-motor-canvas" id="pk-motor-canvas"></div><div class="pk-ctl"><button class="rp-btn" id="pk-motor-step-add">Add timed node</button><button class="rp-btn primary" id="pk-motor-save">Save motor profile</button><button class="rp-btn" id="pk-motor-run">Run selected sequence</button><button class="rp-btn danger" id="pk-motor-test-all">Test all registered GPIO</button></div><label class="pk-prodtoggle"><input type="checkbox" id="pk-motor-greeting"><span>Run this sequence automatically with the greeting pipeline</span></label><div class="pk-note">Full GPIO test pulses registered relays only: 300 ms on, 200 ms pause, one at a time.</div><div class="pk-motor-status" id="pk-motor-status">Loading motor profile...</div>');
6746
7196
  b.innerHTML += sect('Production camera + audio','<div class="pk-note" id="pk-device-search" style="margin-bottom:.55rem">Hardware locked for launch. Camera, microphone, and speaker cannot drift between restarts.</div><div class="rp-field"><label>Camera input</label><select id="pk-video-device" disabled></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input" disabled></select></div><div class="rp-field"><label>Speaker output</label><select id="pk-audio-output" disabled></select></div><button class="rp-btn primary" id="pk-device-test-speaker" style="margin-top:0.35rem">Test USB speaker now</button><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save greetings + enforce hardware</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
6747
7197
  // Put live hardware selection beside the camera and audio tests,
6748
7198
  // ahead of persona and service administration in the Park drawer.
@@ -6765,6 +7215,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
6765
7215
  });
6766
7216
  var micListen=$('pk-mic-listen');if(micListen)micListen.addEventListener('click',function(){setMicMonitorAudible(!micMonitorAudible);});
6767
7217
  var micVolume=$('pk-mic-volume');if(micVolume)micVolume.addEventListener('input',function(){micMonitorVolume=Math.max(0,Math.min(1,Number(micVolume.value||80)/100));if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;});
7218
+ var micGround=$('pk-mic-groundtruth');if(micGround)micGround.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicGroundtruth(n);});
7219
+ var micRound=$('pk-mic-roundtrip');if(micRound)micRound.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicRoundtrip(n);});
7220
+ var micApply=$('pk-mic-apply');if(micApply)micApply.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveDeviceSelectors(n);});
7221
+ var motorAdd=$('pk-motor-add');if(motorAdd)motorAdd.addEventListener('click',function(){motorEditorAddMotor();});
7222
+ var motorStep=$('pk-motor-step-add');if(motorStep)motorStep.addEventListener('click',function(){motorEditorAddStep();});
7223
+ var motorSave=$('pk-motor-save');if(motorSave)motorSave.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveMotorProfile(n);});
7224
+ var motorRun=$('pk-motor-run');if(motorRun)motorRun.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMotorSequence(n);});
7225
+ var motorTestAll=$('pk-motor-test-all');if(motorTestAll)motorTestAll.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}testAllMotorRelays(n);});
7226
+ var motorSeq=$('pk-motor-sequence');if(motorSeq)motorSeq.addEventListener('change',function(){selectMotorSequence(motorSeq.value);});
7227
+ var motorRegistry=$('pk-motor-registry');if(motorRegistry)motorRegistry.addEventListener('click',function(ev){var button=ev.target.closest('[data-motor-action]');if(!button)return;var index=Number(button.dataset.index);if(button.dataset.motorAction==='remove'){motorProfile.registry.splice(index,1);renderMotorEditor();}else if(button.dataset.motorAction==='test'){testMotorRelay(n,index);}});
7228
+ var motorCanvas=$('pk-motor-canvas');if(motorCanvas)motorCanvas.addEventListener('click',function(ev){var button=ev.target.closest('[data-step-remove]');if(button){captureMotorEditor();motorEditorSequence().steps.splice(Number(button.dataset.stepRemove),1);renderMotorEditor();}});
7229
+ loadMotorProfile(n);
6768
7230
  startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
6769
7231
  _bindShellSection(n);
6770
7232
  _spkTestRenderHistory(schedId(n));
@@ -6792,6 +7254,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
6792
7254
  var pipelineStart=$('pk-pipeline-start'); if(pipelineStart) pipelineStart.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } startPipelineTest(n); });
6793
7255
  var unattended=$('pk-unattended-test'); if(unattended) unattended.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runUnattendedReadiness(n); });
6794
7256
  var pipelineStop=$('pk-pipeline-stop'); if(pipelineStop) pipelineStop.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } stopPipelineTest(n); });
7257
+ var historyPrev=$('pk-history-prev');if(historyPrev)historyPrev.addEventListener('click',function(){pipelineHistoryPage=Math.max(1,pipelineHistoryPage-1);loadPipelineHistory(n);});
7258
+ var historyNext=$('pk-history-next');if(historyNext)historyNext.addEventListener('click',function(){pipelineHistoryPage+=1;loadPipelineHistory(n);});
7259
+ var historyList=$('pk-pipeline-history');if(historyList)historyList.addEventListener('click',function(ev){var card=ev.target.closest('.pk-run-card');if(card)openSessionDetailModal({session_id:card.dataset.session});});
7260
+ pipelineHistoryPage=1;
7261
+ loadPipelineHistory(n);
6795
7262
  pollPipeline(n);
6796
7263
  if(pipelineTimer) clearInterval(pipelineTimer);
6797
7264
  pipelineTimer=setInterval(function(){ if(drawerId===n.id) pollPipeline(n); }, 1500);
@@ -6806,12 +7273,88 @@ button.act:disabled{opacity:0.5;cursor:default;}
6806
7273
  refreshDrawer();
6807
7274
  }
6808
7275
 
7276
+ var motorProfile={registry:[],sequences:[],greeting_sequence_id:null,motor_server_url:'http://127.0.0.1:8001'};
7277
+ var motorSequenceId='';
7278
+ function motorEditorSequence(){
7279
+ var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});
7280
+ if(!sequence){sequence={id:motorSequenceId||('sequence-'+Date.now()),name:'New sequence',steps:[]};motorSequenceId=sequence.id;motorProfile.sequences.push(sequence);}
7281
+ if(!Array.isArray(sequence.steps))sequence.steps=[];
7282
+ return sequence;
7283
+ }
7284
+ function captureMotorEditor(){
7285
+ var rows=document.querySelectorAll('#pk-motor-registry .pk-motor-row');
7286
+ if(rows.length)motorProfile.registry=Array.prototype.map.call(rows,function(row,index){
7287
+ var fields=row.querySelectorAll('input');
7288
+ return {id:slugify(fields[0].value)||('motor-'+(index+1)),name:fields[1].value.trim()||('Motor '+(index+1)),gpio:Number(fields[2].value),active_high:fields[3].checked,max_duration_ms:Number(fields[4].value)||3000};
7289
+ });
7290
+ var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});
7291
+ if(sequence){
7292
+ var name=$('pk-motor-sequence-name');if(name)sequence.name=name.value.trim()||sequence.name||'Sequence';
7293
+ sequence.steps=Array.prototype.map.call(document.querySelectorAll('#pk-motor-canvas .pk-motor-node'),function(node){
7294
+ return {motor_id:node.querySelector('[data-step-motor]').value,delay_ms:Number(node.querySelector('[data-step-delay]').value)||0,duration_ms:Number(node.querySelector('[data-step-duration]').value)||300};
7295
+ });
7296
+ var greeting=$('pk-motor-greeting');if(greeting)motorProfile.greeting_sequence_id=greeting.checked?sequence.id:(motorProfile.greeting_sequence_id===sequence.id?null:motorProfile.greeting_sequence_id);
7297
+ }
7298
+ var url=$('pk-motor-url');if(url)motorProfile.motor_server_url=url.value.trim()||'http://127.0.0.1:8001';
7299
+ }
7300
+ function renderMotorEditor(){
7301
+ var registry=$('pk-motor-registry'),canvas=$('pk-motor-canvas'),select=$('pk-motor-sequence');if(!registry||!canvas||!select)return;
7302
+ registry.innerHTML=(motorProfile.registry||[]).map(function(motor,index){return '<div class="pk-motor-row"><input value="'+esc(motor.id)+'" aria-label="Motor id"><input value="'+esc(motor.name)+'" aria-label="Motor name"><input type="number" min="2" max="27" value="'+esc(motor.gpio)+'" aria-label="BCM GPIO"><label title="Active high"><input type="checkbox" '+(motor.active_high!==false?'checked':'')+'> high</label><input type="number" min="50" max="10000" step="50" value="'+esc(motor.max_duration_ms||3000)+'" aria-label="Max pulse milliseconds"><button class="rp-btn" data-motor-action="test" data-index="'+index+'">Test</button><button class="rp-btn danger" data-motor-action="remove" data-index="'+index+'">Remove</button></div>';}).join('')||'<div class="pk-note">No relays registered. Add only wired BCM outputs.</div>';
7303
+ select.innerHTML=(motorProfile.sequences||[]).map(function(sequence){return '<option value="'+esc(sequence.id)+'" '+(sequence.id===motorSequenceId?'selected':'')+'>'+esc(sequence.name||sequence.id)+'</option>';}).join('')+'<option value="__new__">+ New sequence</option>';
7304
+ var sequence=motorEditorSequence(),name=$('pk-motor-sequence-name'),greeting=$('pk-motor-greeting'),url=$('pk-motor-url');
7305
+ if(name)name.value=sequence.name||'';if(greeting)greeting.checked=motorProfile.greeting_sequence_id===sequence.id;if(url)url.value=motorProfile.motor_server_url||'http://127.0.0.1:8001';
7306
+ var motorOptions=(motorProfile.registry||[]).map(function(motor){return '<option value="'+esc(motor.id)+'">'+esc(motor.name+' | BCM '+motor.gpio)+'</option>';}).join('');
7307
+ canvas.innerHTML=(sequence.steps||[]).map(function(step,index){return '<div class="pk-motor-node"><div class="pk-motor-node-num">'+(index+1)+'</div><label>Relay<select data-step-motor>'+motorOptions.replace('value="'+esc(step.motor_id)+'"','value="'+esc(step.motor_id)+'" selected')+'</select></label><label>Delay ms<input data-step-delay type="number" min="0" max="30000" step="50" value="'+esc(step.delay_ms||0)+'"></label><label>Pulse ms<input data-step-duration type="number" min="50" max="10000" step="50" value="'+esc(step.duration_ms||300)+'"></label><button class="rp-btn danger" data-step-remove="'+index+'">Remove</button></div>';}).join('')||'<div class="pk-note">No timed nodes. Add a node to build the sequence.</div>';
7308
+ }
7309
+ function selectMotorSequence(id){
7310
+ captureMotorEditor();
7311
+ if(id==='__new__'){var sequence={id:'sequence-'+Date.now(),name:'New sequence',steps:[]};motorProfile.sequences.push(sequence);motorSequenceId=sequence.id;}else motorSequenceId=id;
7312
+ renderMotorEditor();
7313
+ }
7314
+ function motorEditorAddMotor(){captureMotorEditor();var number=motorProfile.registry.length+1;motorProfile.registry.push({id:'motor-'+number,name:'Motor '+number,gpio:Math.min(27,Math.max(2,number+4)),active_high:true,max_duration_ms:3000});renderMotorEditor();}
7315
+ function motorEditorAddStep(){captureMotorEditor();if(!motorProfile.registry.length){motorStatus('Register a motor before adding a timed node','bad');return;}motorEditorSequence().steps.push({motor_id:motorProfile.registry[0].id,delay_ms:0,duration_ms:300});renderMotorEditor();}
7316
+ function motorStatus(message,kind){var host=$('pk-motor-status');if(host){host.textContent=message;host.className='pk-motor-status '+(kind||'');}}
7317
+ function loadMotorProfile(n){
7318
+ var robotId=schedId(n);motorStatus('Loading saved motor profile...');
7319
+ getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile').then(function(data){motorProfile={registry:data.registry||[],sequences:data.sequences||[],greeting_sequence_id:data.greeting_sequence_id||null,motor_server_url:(schedulerDevice(n)||{}).motor_server_url||'http://127.0.0.1:8001'};if(!motorProfile.sequences.length)motorProfile.sequences=[{id:'greeting-motion',name:'Greeting motion',steps:[]}];motorSequenceId=motorProfile.greeting_sequence_id||motorProfile.sequences[0].id;renderMotorEditor();motorStatus('Saved profile loaded | '+motorProfile.registry.length+' relay(s), '+motorProfile.sequences.length+' sequence(s)','ok');}).catch(function(error){motorStatus('Motor profile unavailable: '+error.message,'bad');});
7320
+ }
7321
+ function saveMotorProfile(n){
7322
+ captureMotorEditor();var robotId=schedId(n),url=motorProfile.motor_server_url;
7323
+ var parsedUrl=null;try{parsedUrl=new URL(url);}catch(e){}
7324
+ if(!parsedUrl||parsedUrl.protocol!=='http:'||(parsedUrl.hostname!=='127.0.0.1'&&parsedUrl.hostname!=='localhost')||!parsedUrl.port){motorStatus('Motor server must be robot-local: http://127.0.0.1:PORT','bad');return Promise.reject(new Error('motor server must be localhost'));}
7325
+ motorStatus('Validating and saving motor profile...');
7326
+ return rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(robotId),{motor_server_url:url}).then(function(){return rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile',{registry:motorProfile.registry,sequences:motorProfile.sequences,greeting_sequence_id:motorProfile.greeting_sequence_id});}).then(function(data){motorProfile.registry=data.registry||[];motorProfile.sequences=data.sequences||[];motorProfile.greeting_sequence_id=data.greeting_sequence_id||null;renderMotorEditor();motorStatus('Profile saved. Greeting motion will start on first TTS playback.','ok');return data;}).catch(function(error){motorStatus('Save failed: '+error.message,'bad');throw error;});
7327
+ }
7328
+ function runMotorSequence(n){
7329
+ var robotId=schedId(n),sequence=motorEditorSequence();motorStatus('Saving and queueing '+sequence.name+'...');
7330
+ saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-sequences/'+encodeURIComponent(sequence.id)+'/run',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-sequence');}).then(function(result){motorStatus(result.ok?'Sequence completed on robot.':'Sequence failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Run failed: '+error.message,'bad');});
7331
+ }
7332
+ function testMotorRelay(n,index){
7333
+ captureMotorEditor();var motor=motorProfile.registry[index];if(!motor)return;var robotId=schedId(n);motorStatus('Saving profile before safe 300 ms '+motor.name+' test...');
7334
+ saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/'+encodeURIComponent(motor.id)+'/test?duration_ms=300',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-test');}).then(function(result){motorStatus(result.ok?motor.name+' relay test completed.':'Relay test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Relay test failed: '+error.message,'bad');});
7335
+ }
7336
+ function testAllMotorRelays(n){
7337
+ captureMotorEditor();if(!motorProfile.registry.length){motorStatus('Register at least one relay before running the GPIO test.','bad');return;}
7338
+ if(!window.confirm('Pulse all '+motorProfile.registry.length+' registered relays one at a time? Ensure the motor area is clear.'))return;
7339
+ var robotId=schedId(n);motorStatus('Saving profile and testing '+motorProfile.registry.length+' registered GPIO relay(s), one at a time...');
7340
+ saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/test-all?duration_ms=300&pause_ms=200',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','gpio-test-all');}).then(function(result){motorStatus(result.ok?'Full registered GPIO test completed: '+(result.completed_steps||[]).length+' relay(s).':'GPIO test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('GPIO test failed: '+error.message,'bad');});
7341
+ }
7342
+
7343
+ var pipelineHistoryPage=1;
6809
7344
  var PIPELINE_LABELS={
6810
7345
  robot_online:'Robot online',camera_ready:'Camera detected',microphone_ready:'Microphone detected',speaker_ready:'Speaker detected',
6811
7346
  motion_detected:'Motion received',scheduler_session:'Scheduler session',livekit_join:'LiveKit joined',camera_published:'Camera published',
6812
7347
  microphone_published:'Microphone published',voice_worker:'Voice worker',stt_listening:'STT listening',llm_response:'LLM response',
6813
- tts_subscribed:'TTS subscribed',playback_started:'Robot playback',session_ended:'Session ended'
7348
+ tts_subscribed:'TTS subscribed',playback_started:'Robot playback',motor_sequence:'Greeting motion',session_ended:'Session ended'
6814
7349
  };
7350
+ var PIPELINE_PHASES={robot_online:'hardware',camera_ready:'hardware',microphone_ready:'hardware',speaker_ready:'hardware',motion_detected:'trigger',scheduler_session:'session',livekit_join:'session',camera_published:'media',microphone_published:'media',voice_worker:'intelligence',stt_listening:'intelligence',llm_response:'intelligence',tts_subscribed:'playback',playback_started:'playback',motor_sequence:'playback',session_ended:'close'};
7351
+ function pipelineCurrentStage(stages,latest,running){
7352
+ for(var i=0;i<stages.length;i++){var bad=latest[stages[i]];if(bad&&(bad.status==='failed'||bad.status==='blocked'))return stages[i];}
7353
+ if(!running)return null;
7354
+ var lastOk=-1;
7355
+ for(var j=0;j<stages.length;j++){var ev=latest[stages[j]];if(ev&&(ev.status==='ok'||ev.status==='skipped'))lastOk=j;if(ev&&ev.status==='running')return stages[j];}
7356
+ return stages[Math.min(stages.length-1,lastOk+1)];
7357
+ }
6815
7358
  function renderPipeline(data){
6816
7359
  var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'); if(!host||!summary) return;
6817
7360
  var latest={}, prereqs=data.prerequisites||{}, session=data.latest_session||{}, sid=session.id||null;
@@ -6822,16 +7365,37 @@ button.act:disabled{opacity:0.5;cursor:default;}
6822
7365
  ['robot_online','camera_ready','microphone_ready','speaker_ready'].forEach(function(stage){
6823
7366
  if(!latest[stage]) latest[stage]={stage:stage,status:prereqs[stage]?'ok':'blocked',message:prereqs[stage]?'Ready':'Not reported'};
6824
7367
  });
6825
- var running=data.overall==='running';
6826
- host.innerHTML=(data.stages||Object.keys(PIPELINE_LABELS)).map(function(stage){
6827
- var ev=latest[stage], status=ev&&ev.status||((running&&stage==='motion_detected')?'running':'pending');
7368
+ var running=data.overall==='running',stages=data.stages||Object.keys(PIPELINE_LABELS),current=pipelineCurrentStage(stages,latest,running);
7369
+ host.innerHTML=stages.map(function(stage,index){
7370
+ var ev=latest[stage],isCurrent=stage===current,status=ev&&ev.status||(isCurrent?'running':'pending');
6828
7371
  var detail=ev&&(ev.message||ev.timestamp)||'waiting';
6829
- if(ev&&ev.timestamp) detail=detail+' · '+new Date(ev.timestamp+'Z').toLocaleTimeString();
6830
- return '<div class="pk-pipe-step '+esc(status)+'"><i></i><span class="stage">'+esc(PIPELINE_LABELS[stage]||stage)+'</span><span class="detail">'+esc(detail)+'</span></div>';
7372
+ var time=ev&&ev.timestamp?new Date(ev.timestamp+'Z').toLocaleString():'';
7373
+ var phase=PIPELINE_PHASES[stage]||'session';
7374
+ return '<div class="pk-pipe-step phase-'+phase+' '+esc(status)+(isCurrent?' current':'')+'"><span class="step-index">'+String(index+1).padStart(2,'0')+'</span><span><span class="stage">'+esc(PIPELINE_LABELS[stage]||stage)+'</span><span class="phase">'+esc(phase)+'</span></span><span class="detail">'+esc(detail)+(time?'<span class="time">'+esc(time)+'</span>':'')+'</span></div>';
6831
7375
  }).join('');
6832
7376
  var blockers=data.blockers||[];
6833
7377
  summary.className='pk-pipeline-summary '+esc(data.overall||'blocked');
6834
- summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?'TEST RUNNING: watch the live camera and stage timeline':'READY: camera-to-speaker remote test can start');
7378
+ summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?('ACTIVE: '+(PIPELINE_LABELS[current]||current||'starting')+' | session '+(sid||'allocating')):'READY: camera-to-speaker remote test can start');
7379
+ var micHealth=$('pk-mic-health');if(micHealth){
7380
+ var micEvent=latest.microphone_published,trackOk=micEvent&&micEvent.status==='ok',inventoryOk=!!prereqs.microphone_ready;
7381
+ micHealth.innerHTML='<div class="pk-mic-check ok"><div class="k">Hardware lock</div><div class="v">USB mic hw:3,0</div></div><div class="pk-mic-check '+(inventoryOk?'ok':'bad')+'"><div class="k">Inventory</div><div class="v">'+(inventoryOk?'detected':'missing')+'</div></div><div class="pk-mic-check '+(trackOk?'ok':micEvent?'bad':'')+'"><div class="k">Production track</div><div class="v">'+esc(micEvent?(micEvent.message||micEvent.status):'waiting for session')+'</div></div>';
7382
+ }
7383
+ }
7384
+ function loadPipelineHistory(n){
7385
+ var host=$('pk-pipeline-history'),pageLabel=$('pk-history-page');if(!host)return;
7386
+ host.innerHTML='<div class="pk-note">Loading session page...</div>';
7387
+ getJson('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-history?page='+pipelineHistoryPage+'&page_size=6'+(QS?'&'+QS.slice(1):''))
7388
+ .then(function(data){
7389
+ pipelineHistoryPage=data.page||1;if(pageLabel)pageLabel.textContent='page '+pipelineHistoryPage+' / '+(data.pages||1)+' | '+(data.total||0)+' runs';
7390
+ var prev=$('pk-history-prev'),next=$('pk-history-next');if(prev)prev.disabled=pipelineHistoryPage<=1;if(next)next.disabled=pipelineHistoryPage>=(data.pages||1);
7391
+ host.innerHTML=(data.items||[]).map(function(run){
7392
+ var events=run.events||[],latest={};events.forEach(function(ev){latest[ev.stage]=ev;});
7393
+ var hasError=Number(run.error_count||0)>0,state=!run.ended_at?'running':hasError?'bad':'ok',label=!run.ended_at?'active':hasError?'attention':(run.end_reason||'complete');
7394
+ var started=run.started_at?new Date(run.started_at+'Z'):null,duration=run.duration_seconds!=null?run.duration_seconds:(started?Math.max(0,Math.floor((Date.now()-started.getTime())/1000)):0);
7395
+ var stageBar=Object.keys(PIPELINE_LABELS).map(function(stage){var ev=latest[stage];return '<i class="'+esc(ev&&ev.status||'pending')+'" title="'+esc(PIPELINE_LABELS[stage])+'"></i>';}).join('');
7396
+ return '<div class="pk-run-card" data-session="'+esc(run.session_id)+'"><div class="pk-run-top"><div><div class="pk-run-title">'+esc(run.session_id)+'</div><div class="pk-run-meta">'+esc(started?started.toLocaleString():'unknown time')+' | '+esc(rpFmtDuration(duration))+' | '+esc(run.transcript_count||0)+' transcript turns | '+esc(run.pipeline_event_count||0)+' events</div></div><span class="pk-run-state '+state+'">'+esc(label)+'</span></div><div class="pk-run-stages">'+stageBar+'</div></div>';
7397
+ }).join('')||'<div class="pk-note">No production sessions recorded for this robot.</div>';
7398
+ }).catch(function(e){host.innerHTML='<div class="pk-note" style="color:var(--bad)">History unavailable: '+esc(e.message)+'</div>';});
6835
7399
  }
6836
7400
  function pollPipeline(n){
6837
7401
  fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-status'+QS,{cache:'no-store'})
@@ -7225,7 +7789,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
7225
7789
  }).join('');
7226
7790
  host.innerHTML = '<div class="pk-spk-hist-head">last '+list.length+' tests</div>' + rows;
7227
7791
  }
7228
- function _spkTestRenderResult(robotId, result){
7792
+ function _spkTestRenderResult(robotId, result){
7229
7793
  var host = $('pk-spk-test-result'); if(!host) return;
7230
7794
  if(!result){
7231
7795
  host.innerHTML = '';
@@ -7252,9 +7816,32 @@ button.act:disabled{opacity:0.5;cursor:default;}
7252
7816
  + '<div><span class="l">format</span><span class="v">'+esc((result.output_sample_rate||result.sample_rate)+' Hz / '+(result.output_channels||1)+'ch out'+(result.playback_only?'':' · '+(result.input_sample_rate||result.sample_rate)+' Hz / '+(result.input_channels||1)+'ch in'))+'</span></div>'
7253
7817
  + '</div>';
7254
7818
  var diag = result.diagnostic ? '<div class="pk-spk-diag">'+esc(result.diagnostic)+'</div>' : '';
7255
- host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
7256
- + banner + meta + diag + '</div>';
7257
- }
7819
+ host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
7820
+ + banner + meta + diag + '</div>';
7821
+ }
7822
+ function _micTestRenderResult(result,label){
7823
+ var host=$('pk-mic-test-result'),state=$('pk-mic-test-state');if(!host)return;
7824
+ if(!result||!result.ok){var error=result&&result.error||'robot did not return a result';host.innerHTML='<div class="pk-mic-result bad"><b>TEST FAILED</b><div style="margin-top:.3rem">'+esc(error)+'</div></div>';if(state)state.textContent='Failed | '+error;return;}
7825
+ var passed=result.pass===true,mode=result.mode||label;
7826
+ host.innerHTML='<div class="pk-mic-result '+(passed?'ok':'bad')+'"><b>'+(passed?'MIC PATH VERIFIED':'NO USABLE SIGNAL')+'</b><div class="pk-mic-readings"><div class="pk-mic-reading"><span>Peak</span><b>'+esc(result.recorded_peak_db!=null?result.recorded_peak_db+' dB':'--')+'</b></div><div class="pk-mic-reading"><span>RMS</span><b>'+esc(result.recorded_rms_db!=null?result.recorded_rms_db+' dB':'--')+'</b></div><div class="pk-mic-reading"><span>Nonzero</span><b>'+esc(result.nonzero_percent!=null?result.nonzero_percent+'%':'--')+'</b></div><div class="pk-mic-reading"><span>Format</span><b>'+esc((result.sample_rate||result.input_sample_rate||'--')+' Hz / '+(result.channels||result.input_channels||1)+'ch')+'</b></div></div><div style="margin-top:.38rem;color:var(--muted)">'+esc(result.input_device||'USB microphone')+' | '+esc(result.duration_ms||0)+' ms | '+esc(result.diagnostic||mode)+'</div></div>';
7827
+ if(state)state.textContent=passed?'Passed | '+mode+' confirmed on the robot':'Capture completed but no usable PCM signal was detected';
7828
+ }
7829
+ function setMicTestBusy(busy,text){
7830
+ [$('pk-mic-groundtruth'),$('pk-mic-roundtrip'),$('pk-mic-apply')].forEach(function(button){if(button)button.disabled=busy;});
7831
+ var state=$('pk-mic-test-state');if(state&&text)state.textContent=text;
7832
+ }
7833
+ function runMicGroundtruth(n){
7834
+ var robotId=schedId(n),duration=parseFloat((($('pk-mic-duration')||{}).value)||'4');setMicTestBusy(true,'Recording raw PCM on the robot for '+duration+' seconds...');
7835
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'mic_groundtruth',duration:duration,input:'2',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})
7836
+ .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-groundtruth');})
7837
+ .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'mic_groundtruth');setMicTestBusy(false);});
7838
+ }
7839
+ function runMicRoundtrip(n){
7840
+ var robotId=schedId(n);setMicTestBusy(true,'Playing a reference tone and recording the locked USB microphone...');
7841
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'tone',playback_only:false,frequency:1000,duration:1,amplitude:.6,threshold_db:-30,output:'1',input:'2',output_name:'USB Audio Device: - (hw:2,0)',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})
7842
+ .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-roundtrip');})
7843
+ .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'roundtrip');setMicTestBusy(false);});
7844
+ }
7258
7845
  function runSpeakerTest(n, forcedMode){
7259
7846
  var robotId = schedId(n), mode=forcedMode||(($('pk-spk-mode')||{}).value||'tts');
7260
7847
  var buttons=[$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')];buttons.forEach(function(b){if(b)b.disabled=true;});
@@ -7310,7 +7897,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
7310
7897
  var _origRenderShellResult = _renderShellResult;
7311
7898
  _renderShellResult = function(robotId, result, label){
7312
7899
  _origRenderShellResult(robotId, result, label);
7313
- if(label === 'speaker-test'){
7900
+ if(label === 'speaker-test'){
7314
7901
  _spkTestSetLive(robotId, {live:false});
7315
7902
  _spkTestMark(robotId, result);
7316
7903
  _spkTestRenderResult(robotId, result);
@@ -7323,8 +7910,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
7323
7910
  else { status.textContent = '✗ no signal — '+result.recorded_peak_db+' dB peak'; }
7324
7911
  }
7325
7912
  var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = (($('pk-spk-mode')||{}).value==='tone'?'Run tone test':'Play cached voice'); }
7326
- }
7327
- };
7913
+ }
7914
+ if(label==='mic-groundtruth'||label==='mic-roundtrip'){
7915
+ _micTestRenderResult(result,label);
7916
+ setMicTestBusy(false);
7917
+ }
7918
+ };
7328
7919
 
7329
7920
  function openDrawerForRobot(name){
7330
7921
  var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;