[object Object]

← back to Scarlet Riverboat Masquerade

snapshot before restart: preserve in-flight work

a5a2c5a70fd282e55bf9187762f038b3705d2920 · 2026-09-04 22:38:51 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gbmuMHgCLDEPM4TpfAr4G

Files touched

Diff

commit a5a2c5a70fd282e55bf9187762f038b3705d2920
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Sep 4 22:38:51 2026 -0700

    snapshot before restart: preserve in-flight work
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_011gbmuMHgCLDEPM4TpfAr4G
---
 index.html           | 429 +++++++++++++++++++++++++++++++++++++++++++++------
 tests/live-happy.cjs |  41 +++--
 2 files changed, 409 insertions(+), 61 deletions(-)

diff --git a/index.html b/index.html
index 768f6b6..6fba8f1 100644
--- a/index.html
+++ b/index.html
@@ -103,14 +103,26 @@
     width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--gold);cursor:pointer;
   }
   .btn{
-    font-family:var(--sans);font-size:12px;letter-spacing:.05em;
-    background:rgba(40,12,16,.7);color:var(--ink);border:1px solid var(--panel-edge);
-    border-radius:10px;padding:9px 13px;cursor:pointer;transition:background .2s,color .2s;
-    display:inline-flex;align-items:center;gap:7px;white-space:nowrap;
+    font-family:var(--sans);font-size:12px;letter-spacing:.04em;font-weight:500;
+    background:linear-gradient(180deg,rgba(58,18,24,.72),rgba(34,10,14,.72));
+    color:var(--ink);border:1px solid rgba(242,182,74,.22);
+    border-radius:11px;padding:0 14px;min-height:38px;cursor:pointer;
+    transition:background .18s,color .18s,border-color .18s,box-shadow .18s,transform .06s;
+    display:inline-flex;align-items:center;gap:8px;white-space:nowrap;
+    box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 1px 2px rgba(0,0,0,.35);
   }
-  .btn:hover{background:rgba(70,20,26,.9);color:var(--gold)}
-  .btn[aria-pressed="true"]{background:var(--gold);color:#2a0a0e;border-color:var(--gold);font-weight:600}
-  .toggle{display:flex;flex-direction:column;gap:6px}
+  .btn:hover{border-color:rgba(242,182,74,.5);color:var(--gold);
+    background:linear-gradient(180deg,rgba(78,24,30,.85),rgba(48,14,18,.85))}
+  .btn:active{transform:translateY(1px)}
+  /* active "on" state — a lit lantern glow, not a solid candy fill */
+  .btn[aria-pressed="true"]{
+    color:var(--gold);border-color:rgba(242,182,74,.6);font-weight:600;
+    background:linear-gradient(180deg,rgba(242,182,74,.20),rgba(242,182,74,.07));
+    box-shadow:inset 0 0 0 1px rgba(242,182,74,.22),inset 0 1px 0 rgba(255,235,190,.16),0 0 14px rgba(242,182,74,.16);
+  }
+  .btn[aria-pressed="true"]:hover{background:linear-gradient(180deg,rgba(242,182,74,.28),rgba(242,182,74,.12))}
+  .toggle{display:flex;flex-direction:column;gap:8px;align-items:stretch}
+  .toggle .btn{justify-content:flex-start}   /* equal-width pills, left-aligned labels */
 
   /* ---- Camera pills ---- */
   .cams{position:fixed;left:26px;bottom:20px;z-index:7;display:flex;gap:8px;flex-direction:column}
@@ -175,8 +187,6 @@
   .mr-yr .yc{font-size:8.5px;color:var(--ink-dim);letter-spacing:.02em;line-height:1}
   .mr-yr.on .yc{color:#6a2c12}
   .mr-yr.loading{animation:mrPulse 1s ease-in-out infinite}
-  .mr-yr.err{opacity:.55;border-style:dashed}
-  .mr-yr.err .yc{color:var(--ink-dim)}
   @keyframes mrPulse{0%,100%{opacity:.4}50%{opacity:.8}}
   .mr-map-err{font-size:11px;color:#e2a08a;padding:6px 4px;font-style:italic}
   .mr-results{max-height:150px;overflow:auto;margin-bottom:8px;display:flex;flex-direction:column;gap:2px}
@@ -239,6 +249,10 @@
   }
   @media (prefers-reduced-motion: reduce){
     .museum,.chapters button,.btn,.skip{transition:none}
+    /* also silence the always-on keyframe loops (live-dot pulse, loading tiles) */
+    *,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important}
+    .musicroom.playing .live-dot{animation:none}
+    .mr-yr.loading{animation:none}
   }
 </style>
 </head>
@@ -268,9 +282,9 @@
     </header>
     <div class="mrbody" id="mrbody">
       <input class="mr-search" id="mrSearch" type="search" placeholder="🔍 Search the archive — date, city, song…" aria-label="Search the Grateful Dead archive">
-      <div class="mr-src" id="mrSrc" role="tablist" aria-label="Choose a music source"></div>
+      <div class="mr-src" id="mrSrc" role="group" aria-label="Choose a music source"></div>
       <div class="mr-map-head"><span id="mrMapLabel">Tap a year</span><button type="button" class="mr-allbtn on" id="mrAll">All years</button></div>
-      <div class="mr-map" id="mrMap" role="listbox" aria-label="Browse shows by year"></div>
+      <div class="mr-map" id="mrMap" role="group" aria-label="Browse shows by year"></div>
       <input type="hidden" id="mrYear" value="">
       <div class="mr-results" id="mrResults" role="list"></div>
       <div class="mr-picks">Curated nights:</div>
@@ -403,6 +417,18 @@ const CHAPTER_SECONDS = 55; // dwell before the boat drifts to the next chapter
 if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches){
   S.reduced = true;
 }
+// ...and keep honoring it if the visitor flips the OS setting mid-session
+if (window.matchMedia){
+  const _rmq = matchMedia('(prefers-reduced-motion: reduce)');
+  const _sync = e => {
+    S.reduced = e.matches;
+    const rb = document.getElementById('reduceBtn');
+    if (rb) rb.setAttribute('aria-pressed', String(S.reduced));
+    if (S.reduced) ctx.clearRect(0,0,S.W,S.H);
+  };
+  if (_rmq.addEventListener) _rmq.addEventListener('change', _sync);
+  else if (_rmq.addListener) _rmq.addListener(_sync);
+}
 
 // ---------- Palettes + per-chapter choreography ----------
 const CHAPTERS = [
@@ -932,6 +958,32 @@ function drawBoatOverhead(accent,lan,glow){
 }
 
 // ---------- Shoreline animals ----------
+// ---- roaming / tempo helpers: animals travel across the scene and step to the beat ----
+// _tempoMul: 1.0 ≈ neutral. Detected music BPM drives it when reacting, else the Visual Tempo slider.
+function _tempoMul(){
+  if(window.SRM && window.SRM.react && window.SRM.bpm>40) return clamp(window.SRM.bpm/110, 0.7, 2.2);
+  return 0.7 + S.tempo*0.65;                      // 0.7..2.0 from the slider
+}
+// _roam: a horizontal position that walks across the screen and wraps off-screen; returns x + facing dir.
+// Derived from S.t (which already integrates tempo) so locomotion speeds up/slows with the music.
+function _roam(seed, speed){
+  const dir = (seed%2===0) ? 1 : -1;
+  const span = S.W*1.30;
+  const p = (((S.t*speed*0.06*_tempoMul()) + seed*0.31) % 1 + 1) % 1;
+  const x = dir>0 ? (-S.W*0.15 + p*span) : (S.W*1.15 - p*span);
+  return {x, dir};
+}
+// every animal gets a small upward HOP on each detected beat when reacting to the music
+function _beatHop(){ return (window.SRM && window.SRM.react) ? (S.pulse||0)*10 : 0; }
+// place a creature at its roamed position, facing its travel direction, hopping on the beat
+function _place(fn, roam, y, s, accent, hop){
+  ctx.save();
+  ctx.translate(roam.x, y - hop);
+  ctx.scale(roam.dir, 1);                         // flip to face the way it's walking/flying
+  fn(0, 0, s, S.t, accent);
+  ctx.restore();
+}
+
 function drawAnimals(){
   if(S.animals<=0.02) return;
   const W=S.W,H=S.H;
@@ -947,17 +999,35 @@ function drawAnimals(){
 
   const t=S.t;
   const scaleA = S.cam==='deck'?1.15:1;
+  const hop=_beatHop();
 
-  fox(W*0.16, baseY-6, 1*scaleA, t, accent);
-  heron(W*0.40, baseY-4, 1*scaleA, t, accent);
-  rabbit(W*0.63, baseY-4, 1*scaleA, t, accent);
-  bearAndFireflies(W*0.85, baseY-2, 1.1*scaleA, t, accent);
-  drawBearCubs(t, scaleA, baseY);
-  drawSongbirds(t, scaleA, baseY);
+  // walkers now ROAM the shore (each faces its direction of travel, hops on the beat)
+  _place(fox,              _roam(0,1.00), baseY-6, 1*scaleA,   accent, hop);
+  _place(heron,            _roam(3,0.55), baseY-4, 1*scaleA,   accent, hop*0.6);
+  _place(rabbit,           _roam(4,1.35), baseY-4, 1*scaleA,   accent, hop*1.4);
+  _place(bearAndFireflies, _roam(1,0.42), baseY-2, 1.1*scaleA, accent, hop*0.8);
+  if(!S.playing && !S.reduced){ drawIdleBackflipBear(scaleA, baseY); } else { drawBearCubs(t, scaleA, baseY); }   // idle: a single bear backflips across; playing: the troupe dances + sidesteps to tempo
+  drawSongbirds(t, scaleA, baseY);  // songbirds now FLY around the sky
 
   ctx.restore();
 }
 
+function drawIdleBackflipBear(scaleA, baseY){
+  const W=S.W;
+  const cross=8.5, gap=1.4, span=cross+gap;
+  const tt=S.idleT||0; const idx=Math.floor(tt/span); const local=tt%span;
+  if(local>cross) return;
+  const prog=local/cross;
+  const FUR=BEAR_COLORS[idx%BEAR_COLORS.length];
+  const size=1.55*scaleA, margin=64;
+  const x=-margin + prog*(W+margin*2);
+  const flips=3, fp=(prog*flips)%1;
+  const lift=Math.sin(fp*Math.PI)*54*scaleA;
+  const spin=-(prog*flips)*Math.PI*2;
+  ctx.save(); ctx.translate(x,(baseY+8)-lift); ctx.rotate(spin);
+  bearCub(0,0,size,(S.idleT||0)*0.8,idx*1.3,FUR); ctx.restore();
+}
+
 function fox(x,y,s,t,accent){
   const gait=Math.sin(t*3.2);
   const bob=Math.abs(Math.sin(t*3.2))*-4;
@@ -1009,7 +1079,7 @@ function rabbit(x,y,s,t,accent){
   const stretch=1+air*0.4, squash=1-Math.max(0,-Math.sin(cyc))*0.35;
   const hopY=-air*9;   // low, grounded hop — stays on the shore
   ctx.save();
-  ctx.translate(x + ((t*22)% (S.W*0.14)) - S.W*0.05, y+hopY);
+  ctx.translate(x, y+hopY);   // roam is handled by _place(); keep only the vertical hop
   ctx.scale(s*(2-stretch)*0.9 + s*0.1, s*stretch*squash);
   cut('rgba(120,66,60,0.98)',2);
   ctx.beginPath(); ctx.ellipse(0,-8,9,7,0,0,7); fillOutline();
@@ -1095,8 +1165,12 @@ function drawBearCubs(t,scaleA,baseY){
   const W=S.W, N=Math.max(1,Math.min(10,Math.round(S.bears)));
   const margin=W*0.05, span=W-2*margin;
   const size=Math.max(0.9, Math.min(2.1, 13/(N+2.5)))*scaleA;   // fewer bears bigger, more bears smaller to fit
+  const mul=_tempoMul();
+  const groupDrift=Math.sin(t*0.22*mul)*W*0.05;                 // the whole troupe sways across the shore to tempo
   for(let i=0;i<N;i++){
-    const x = (N===1) ? W*0.16 : margin + span*(i/(N-1));
+    const home = (N===1) ? W*0.16 : margin + span*(i/(N-1));
+    const step = Math.sin(t*2.0*mul + i*1.3)*(W*0.022+8);       // each cub sidesteps/boogies in time
+    const x = Math.max(margin*0.4, Math.min(W-margin*0.4, home + groupDrift + step));
     const yj = ((i*53)%9)-4;                                    // slight stagger so the row isn't a flat line
     bearCub(x, baseY+8+yj, size, t, i*1.3, BEAR_COLORS[i%BEAR_COLORS.length]);
   }
@@ -1131,10 +1205,15 @@ function songbird(x,y,s,t,ph){
   }
 }
 function drawSongbirds(t,scaleA,baseY){
-  const W=S.W;   // kept clear of the center control dock
-  songbird(W*0.075, baseY-9,  1.0*scaleA, t, 0.0);
-  songbird(W*0.235, baseY-7,  0.9*scaleA, t, 2.1);
-  songbird(W*0.93,  baseY-11, 1.05*scaleA, t, 4.2);
+  const W=S.W, H=S.H, N=5, mul=_tempoMul();   // songbirds now FLY around the sky, facing their heading
+  for(let i=0;i<N;i++){
+    const r=_roam(10+i, 0.85+i*0.14);
+    const lane=0.16+0.30*(0.5+0.5*Math.sin(i*1.7));            // spread across sky lanes
+    const y=H*lane + Math.sin(t*2.2*mul + i*1.5)*24;           // rise/dip flight-path, cadence follows tempo
+    ctx.save(); ctx.translate(r.x, y); ctx.scale(r.dir,1);
+    songbird(0, 0, (0.85+0.2*Math.sin(i))*scaleA, t, i*1.3);
+    ctx.restore();
+  }
 }
 
 // ---------- River foreground with parallax reflections ----------
@@ -1237,11 +1316,14 @@ function drawPtero(dt){
 let last=performance.now();
 function frame(now){
   const dt=Math.min(0.05,(now-last)/1000); last=now;
+  S.idleT=(S.idleT||0)+dt;
 
   // live audio energy (0..1) when "React to Music" is on — otherwise motion stays authored
   const reacting = !!(window.SRM && window.SRM.react);
   S.amp = reacting ? (window.SRM.level||0) : 0;
   S.pulse = reacting ? (window.SRM.pulse||0) : 0;   // sharp per-beat throb for lamps
+  // bridge visual state to the separate WebGL orb loop so it can honor reduced motion + pause
+  if(window.SRM){ window.SRM.reduced = S.reduced; window.SRM.playing = S.playing; }
   // fire a burst on each new detected beat
   if(reacting && !S.reduced && window.SRM.beat>_seenBeat){ _seenBeat=window.SRM.beat; spawnFirework(); }
   else if(!reacting){ _seenBeat=window.SRM?(window.SRM.beat||0):0; }   // let fireworks decay naturally
@@ -1465,13 +1547,26 @@ requestAnimationFrame(frame);
 (() => {
 "use strict";
 const CATALOG = [
-  {id:'gd69-02-27.sbd.16track.kaplan.6315.sbeok.shnf', date:'February 27, 1969', title:'Fillmore West',    sub:'San Francisco · primal psychedelic era'},
-  {id:'gd72-08-27.sbd.orf.3328.sbeok.shnf',            date:'August 27, 1972',   title:'Veneta',           sub:'Oregon · field, heat, and open sky'},
-  {id:'gd74-06-23.sbd.cribbs.16780.sbeok.shnf',        date:'June 23, 1974',     title:'Jai-Alai Fronton', sub:'Miami · Wall of Sound era'},
-  {id:'gd77-05-08.sbd.hicks.4982.sbeok.shnf',          date:'May 8, 1977',       title:'Cornell',          sub:'Ithaca · Barton Hall'},
-  {id:'gd90-03-29.aud-fob.set2.unknown.1317.sbeok.shnf',date:'March 29, 1990',   title:'Nassau',           sub:'Uniondale · late-era expansion'}
+  {id:"gd69-02-27.sbd.16track.kaplan.6315.sbeok.shnf", date:"February 27, 1969", title:"Fillmore West", sub:"San Francisco · primal psychedelic era"},
+  {id:"gd70-05-24.sbd.hanno.6481.sbeok.shnf", date:"May 24, 1970", title:"Newcastle-Under-Lyme", sub:"Newcastle · acoustic-and-electric era"},
+  {id:"gd71-04-14.sbd.lai.6250.sbefail.shnf", date:"April 14, 1971", title:"Christy Mathewson Stadium, Bucknell U.", sub:"Lewisburg · Skull & Roses era"},
+  {id:"gd72-08-27.sbd.orf.3328.sbeok.shnf", date:"August 27, 1972", title:"Veneta", sub:"Oregon · field, heat, and open sky"},
+  {id:"gd73-02-15.sbd.hall.1580.sbeok.shnf", date:"February 15, 1973", title:"Dane County Coliseum", sub:"Madison · jazz-loose / Wake of the Flood era"},
+  {id:"gd74-06-23.sbd.cribbs.16780.sbeok.shnf", date:"June 23, 1974", title:"Jai-Alai Fronton", sub:"Miami · Wall of Sound era"},
+  {id:"gd76-06-10.sbd.bertha-ashley.23370.sbeok.shnf", date:"June 10, 1976", title:"Boston Music Hall", sub:"Boston · return-from-hiatus era"},
+  {id:"gd77-05-08.sbd.hicks.4982.sbeok.shnf", date:"May 8, 1977", title:"Cornell", sub:"Ithaca · Barton Hall"},
+  {id:"gd78-02-01.sbd.parillo.1017.sbeok.shnf", date:"February 1, 1978", title:"Uptown Theater", sub:"Chicago · tight & funky late-’70s era"},
+  {id:"gd81-03-21.sbd.clugston.6666.sbeok.shnf", date:"March 21, 1981", title:"Rainbow Theatre", sub:"London · European-tour era"},
+  {id:"gd85-04-27.sbd.jerugim.359.sbeok.shnf", date:"April 27, 1985", title:"Frost Amphitheatre", sub:"Palo Alto · 20th-anniversary / Frost era"},
+  {id:"gd1987-01-28.sbd.walker-scotton.miller.83286.flac16", date:"January 28, 1987", title:"S.F Civic Auditorium", sub:"San Francisco · In the Dark comeback era"},
+  {id:"gd89-12-31.sbd.bertha-ashley.18674.sbeok.shnf", date:"December 31, 1989", title:"Oakland-Alameda County Coliseum", sub:"Oakland · late-peak / Built to Last era"},
+  {id:"gd90-03-29.aud-fob.set2.unknown.1317.sbeok.shnf", date:"March 29, 1990", title:"Nassau", sub:"Uniondale · late-era expansion"},
+  {id:"gd91-10-31.sbd.gardner.2897.sbeok.shnf", date:"October 31, 1991", title:"Oakland-Alameda County Coliseum", sub:"Oakland · post-Brent / Bruce Hornsby era"},
+  {id:"gd93-03-24.sbd.keyser.25664.sbeok.flacf", date:"March 24, 1993", title:"Dean Smith Center", sub:"Chapel Hill · Vince era / stadium years"},
+  {id:"gd94-10-05.sbd.unknown.8030.sbeok.shnf", date:"October 5, 1994", title:"The Spectrum", sub:"Philadelphia · final-years era"},
+  {id:"gd95-07-09.sbd.7233.sbeok.shnf", date:"July 9, 1995", title:"Soldier Field", sub:"Chicago · the final show"}
 ];
-const DEFAULT_SHOW = 3; // Cornell '77
+const DEFAULT_SHOW = 7; // Cornell '77
 
 const $ = id => document.getElementById(id);
 const audio = $('mrAudio'), room = $('musicroom'), sel = $('mrShow'), live = $('live');
@@ -1684,7 +1779,18 @@ function setPlay(on){
 // Default OFF — visuals stay authored until the visitor flips "React to Music".
 audio.crossOrigin='anonymous';                 // set before any src load so the CDN stream is analyzable
 window.SRM = window.SRM || { level:0, react:false };
+// song-analysis fields the orb + animals read: bpm (detected tempo), vocal (0..1 lyric presence),
+// change (a counter that ticks on a NOTICEABLE song change), changeKind ('track'|'tempo'|'vocal-in'|'vocal-out')
+Object.assign(window.SRM,{ bpm:0, vocal:0, change:window.SRM.change||0, changeKind:'', changeAt:0 });
 let _actx, _analyser, _freq, _looping=false;
+// beat-interval ring buffer -> BPM, plus running baselines for change detection
+let _beatTimes=[], _bpmBase=0, _vocalSm=0, _vocalState=false, _lastTrack='', _lastChangeClock=-999;
+function _median(a){ const b=a.slice().sort((x,y)=>x-y); return b.length? b[(b.length>>1)] : 0; }
+function _fireChange(kind){
+  if(_clock-_lastChangeClock < 90) return;   // rate-limit: ~1.5s between change events
+  _lastChangeClock=_clock;
+  window.SRM.change++; window.SRM.changeKind=kind; window.SRM.changeAt=performance.now();
+}
 function ensureGraph(){
   if(_actx) return true;
   try{
@@ -1698,18 +1804,47 @@ function ensureGraph(){
 }
 let _avg=0, _clock=0, _lastBeat=-99;
 function sampleLevel(){
+  // stop the rAF loop when it has nothing to analyze; reactBtn / audio 'play' restart it
+  if(!window.SRM.react || audio.paused){ _looping=false; return; }
   if(_analyser){
     _analyser.getByteFrequencyData(_freq);
-    let sum=0, n=Math.max(1,Math.floor(_freq.length/6));  // low-end (bass) energy
+    const L=_freq.length;                                     // 128 bins, ~0..22kHz
+    let sum=0, n=Math.max(1,Math.floor(L/6));                 // low-end (bass) energy
     for(let i=0;i<n;i++) sum+=_freq[i];
     const raw=(sum/n)/255;
-    window.SRM.level = window.SRM.level*0.6 + raw*0.4;       // smoothed 0..1
+    window.SRM.level = window.SRM.level*0.6 + raw*0.4;        // smoothed 0..1
+    // vocal-band (~300Hz..3.4kHz -> mid bins) energy, normalised against overall energy:
+    // a strong, sustained mid presence over the bass floor reads as "lyrics / a voice is in"
+    let vsum=0, vn=0, tsum=0;
+    for(let i=3;i<24 && i<L;i++){ vsum+=_freq[i]; vn++; }
+    for(let i=0;i<L;i++) tsum+=_freq[i];
+    const vocalRaw = tsum>1 ? Math.min(1,(vsum/Math.max(1,vn))/ (tsum/L) * 0.5) : 0;
+    _vocalSm = _vocalSm*0.9 + vocalRaw*0.1;
+    window.SRM.vocal = _vocalSm;
     // beat detection: a bass spike well above the running average, rate-limited
     _avg = _avg*0.92 + raw*0.08; _clock++;
     if(raw > _avg*1.10 && raw > 0.12 && (_clock-_lastBeat) > 5){
+      const now=performance.now();
+      if(window.SRM.beat){ const dtb=now-(window.SRM._lastBeatMs||now); if(dtb>240 && dtb<1400){ _beatTimes.push(dtb); if(_beatTimes.length>8) _beatTimes.shift(); } }
+      window.SRM._lastBeatMs=now;
       _lastBeat=_clock; window.SRM.beat=(window.SRM.beat||0)+1; window.SRM.pulse=1;
+      // BPM from the median beat interval, smoothed
+      if(_beatTimes.length>=4){ const bpm=60000/_median(_beatTimes); window.SRM.bpm = window.SRM.bpm? window.SRM.bpm*0.8+bpm*0.2 : bpm; }
     }
-    window.SRM.pulse = (window.SRM.pulse||0)*0.86;           // decaying throb (spikes to 1 on a beat)
+    window.SRM.pulse = (window.SRM.pulse||0)*0.86;            // decaying throb (spikes to 1 on a beat)
+
+    // ---- NOTICEABLE-CHANGE detection (drives the orb's UFO-split & celestial morphs) ----
+    // 1) a new track started
+    const tr=window.SRM.track||'';
+    if(tr && tr!==_lastTrack){ _lastTrack=tr; _bpmBase=0; _beatTimes=[]; _fireChange('track'); }
+    // 2) a real tempo shift vs the running baseline
+    if(window.SRM.bpm>40){
+      if(!_bpmBase) _bpmBase=window.SRM.bpm;
+      else { if(Math.abs(window.SRM.bpm-_bpmBase)/_bpmBase > 0.18) _fireChange('tempo'); _bpmBase=_bpmBase*0.98+window.SRM.bpm*0.02; }
+    }
+    // 3) lyrics-vs-just-music crossover (hysteresis so it doesn't flicker)
+    if(!_vocalState && _vocalSm>0.42){ _vocalState=true; _fireChange('vocal-in'); }
+    else if(_vocalState && _vocalSm<0.26){ _vocalState=false; _fireChange('vocal-out'); }
   }
   requestAnimationFrame(sampleLevel);
 }
@@ -1726,7 +1861,9 @@ if(reactBtn) reactBtn.addEventListener('click',()=>{
   announce(on?'Reacting to the music — lanterns, fireflies, and dancers now move to the live audio.':'Back to authored motion.');
 });
 
-audio.addEventListener('play',()=>setPlay(true));
+audio.addEventListener('play',()=>{ setPlay(true);
+  if(window.SRM.react && _actx){ if(_actx.state==='suspended') _actx.resume(); if(!_looping){ _looping=true; sampleLevel(); } }
+});
 audio.addEventListener('pause',()=>setPlay(false));
 audio.addEventListener('ended',()=>{ if(trackIdx<tracks.length-1) playTrack(trackIdx+1); else setPlay(false); });
 audio.addEventListener('timeupdate',()=>{
@@ -1807,7 +1944,7 @@ function mrApplyHeat(src){
 
 let buildCtl=null;   // cancels the previous batch of year-count fetches on a source switch
 function buildMap(){
-  const src=window.MR_SOURCES[window.MR_SRC], mySrc=window.MR_SRC, srcId=src.id;
+  const src=window.MR_SOURCES[window.MR_SRC], mySrc=window.MR_SRC;
   if(buildCtl) buildCtl.abort();                            // kill in-flight fetches from the prior build
   buildCtl=(typeof AbortController!=='undefined')?new AbortController():null;
   const sig=buildCtl?buildCtl.signal:undefined;
@@ -1815,13 +1952,12 @@ function buildMap(){
   mapEl.innerHTML='';
   YEARS.forEach(y=>{
     const b=document.createElement('button'); b.type='button'; b.className='mr-yr loading';
-    b.dataset.y=String(y); b.setAttribute('role','option'); b.title=y+' — counting…';
+    b.dataset.y=String(y); b.title=y+' — counting…';
     b.innerHTML=`<span class="yl">${y}</span><span class="yc">…</span>`;
     b.addEventListener('click',()=>setYear(y));
     b.addEventListener('mouseenter',()=>{ const c=counts[ck(window.MR_SRC,y)]; if(c!=null) mapLabel.textContent=`${y} · ${c.toLocaleString()} shows`; });
     mapEl.appendChild(b);
   });
-
   // 1) paint known-good CACHED counts immediately — kills the "…" ellipsis for repeat visitors
   YEARS.forEach(y=>{ const c=mrCacheLoad(mySrc,y); if(c!=null){ counts[ck(mySrc,y)]=c; mrPaintCount(mySrc,y,c); } });
   mrApplyHeat(mySrc);
@@ -1829,7 +1965,7 @@ function buildMap(){
   // 2) refresh live via a capped, retrying, circuit-broken pool (max 5 concurrent)
   const breaker={fails:0, open:false};
   mrPool(YEARS, MR_POOL_LIMIT, y=>
-    mrFetchCount('https://archive.org/advancedsearch.php?q='+encodeURIComponent('collection:'+srcId+' AND year:'+y)+'&rows=0&output=json', sig, breaker)
+    mrFetchCount('https://archive.org/advancedsearch.php?q='+encodeURIComponent('collection:'+src.id+' AND year:'+y)+'&rows=0&output=json', sig, breaker)
       .then(n=>{
         if(mySrc!==window.MR_SRC) return {y,ok:false};
         const nn=Math.max(0,n);
@@ -1850,11 +1986,11 @@ function buildMap(){
 
 if(srcEl){
   srcEl.innerHTML=Object.entries(window.MR_SOURCES).map(([k,s])=>
-    `<button type="button" data-src="${k}" class="${k===window.MR_SRC?'on':''}" role="tab" aria-selected="${k===window.MR_SRC}"><b>${s.label}</b><span class="yr">${s.years}</span></button>`).join('');
+    `<button type="button" data-src="${k}" class="${k===window.MR_SRC?'on':''}" aria-pressed="${k===window.MR_SRC}"><b>${s.label}</b><span class="yr">${s.years}</span></button>`).join('');
   srcEl.addEventListener('click',e=>{
     const btn=e.target.closest('button[data-src]'); if(!btn||btn.dataset.src===window.MR_SRC) return;
     window.MR_SRC=btn.dataset.src;
-    [...srcEl.children].forEach(c=>{ const on=c.dataset.src===window.MR_SRC; c.classList.toggle('on',on); c.setAttribute('aria-selected',String(on)); });
+    [...srcEl.children].forEach(c=>{ const on=c.dataset.src===window.MR_SRC; c.classList.toggle('on',on); c.setAttribute('aria-pressed',String(on)); });
     yearEl.value=''; allBtn.classList.add('on'); mapLabel.textContent='Tap a year';
     buildMap(); searchArchive(searchEl?searchEl.value:'');
   });
@@ -1889,8 +2025,55 @@ mrToggle.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.pre
 // Music Room stays EXPANDED (incl. small screens) so revealing the chrome shows a
 // working player in one tap — no hidden second collapse (CTA/screenrecord CRITICAL #1).
 
-// cue the default night (no autoplay — the source policy is user-initiated playback)
-loadShow(DEFAULT_SHOW);
+// ---- Autostart on load: queue "Me and My Uncle → Bertha → Deal" and play ----
+const WISHLIST=['Me and My Uncle','Bertha','Deal'];
+function _norm(t){ return (t||'').toLowerCase().replace(/&/g,' and ').replace(/[^a-z0-9 ]+/g,' ').replace(/\bthe\b/g,' ').replace(/\s+/g,' ').trim(); }
+function _matches(title,want){ const n=_norm(title), w=_norm(want); if(!n||!w) return false;
+  return new RegExp('\\b'+w.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace(/\s+/g,'\\s+')+'\\b').test(n); }
+// fan out to the curated shows in parallel, pool every mp3 track (title + direct url)
+async function _poolFromShows(){
+  const results=await Promise.allSettled(CATALOG.map(s=>
+    fetch(`https://archive.org/metadata/${s.id}`,{mode:'cors'}).then(r=>r.ok?r.json():null).then(j=>({s,j}))));
+  const pool=[];
+  for(const res of results){
+    if(res.status!=='fulfilled' || !res.value || !res.value.j) continue;
+    const s=res.value.s, j=res.value.j;
+    let mp3=(j.files||[]).filter(f=>/vbr mp3/i.test(f.format||'')); if(!mp3.length) mp3=(j.files||[]).filter(f=>/mp3/i.test(f.format||''));
+    mp3.forEach(f=>pool.push({ title:(f.title&&f.title.trim())?f.title.trim():f.name.replace(/\.mp3$/i,''),
+      url:`https://archive.org/download/${s.id}/${encodeURIComponent(f.name)}`, show:s }));
+  }
+  return pool;
+}
+// browsers block silent autoplay — try, and if blocked, start on the first tap/keypress
+function _tryAutoplay(){
+  const go=()=>audio.play().then(()=>setPlay(true)).catch(()=>{});
+  audio.play().then(()=>setPlay(true)).catch(()=>{
+    const unlock=()=>{ go(); document.removeEventListener('pointerdown',unlock,true); document.removeEventListener('keydown',unlock,true); };
+    document.addEventListener('pointerdown',unlock,true);
+    document.addEventListener('keydown',unlock,true);
+    announce('Tap or press any key to start the music — Me and My Uncle, Bertha, Deal.');
+  });
+}
+async function bootMusic(){
+  $('mrNow').textContent='Cueing the set…';
+  try{
+    const pool=await _poolFromShows();
+    const list=[]; for(const w of WISHLIST){ const hit=pool.find(p=>_matches(p.title,w)); if(hit) list.push(hit); }
+    if(list.length){
+      tracks=list; trackIdx=0; showIdx=-1; renderTracks(); highlight();
+      audio.src=tracks[0].url; if(window.SRM) window.SRM.track=tracks[0].title;
+      $('mrNow').textContent=`♪ ${tracks[0].title}`;
+      $('mrSub').textContent='Autostart — '+list.map(t=>t.title).join(' · ');
+      setFoot(list[0].show);
+      announce('Autostarting: '+list.map(t=>t.title).join(', ')+'.');
+      _tryAutoplay();
+      return;
+    }
+  }catch(e){}
+  await loadShow(DEFAULT_SHOW);   // fallback: archive slow/down → cue the default night, still try to start
+  _tryAutoplay();
+}
+bootMusic();
 
 // reveal the DEAD back-link only when this room is served as part of the exhibition
 const _p=location.pathname.replace(/\/(index\.html)?$/,'');
@@ -1912,28 +2095,172 @@ if(_p.endsWith('/room') || /dead\.agentabrams/i.test(location.hostname)){
   const scene=new THREE.Scene();
   const camera=new THREE.PerspectiveCamera(50,W/H,0.1,100); camera.position.z=5;
   const group=new THREE.Group(); group.position.y=1.45; scene.add(group);   // floats up in the sky over the boat
+
+  // ---- the core orb (its own sub-group so effects can rotate it WITHOUT tilting the world-down beams) ----
+  const orb=new THREE.Group(); group.add(orb);
   const wire=new THREE.Mesh(new THREE.IcosahedronGeometry(0.95,2), new THREE.MeshBasicMaterial({wireframe:true,color:0xf2b64a,transparent:true,opacity:0.8}));
   const core=new THREE.Mesh(new THREE.IcosahedronGeometry(0.76,3), new THREE.MeshStandardMaterial({color:0x2a0810,emissive:0xc9432b,emissiveIntensity:0.7,roughness:0.35,metalness:0.6,transparent:true,opacity:0.85}));
-  group.add(core,wire);
+  orb.add(core,wire);
+
+  // ---- 5 UFO saucers, each casting a downward light beam (searchlight cone, additive) ----
+  const ufo=new THREE.Group(); ufo.visible=false; group.add(ufo);
+  const saucers=[];
+  for(let i=0;i<5;i++){
+    const u=new THREE.Group();
+    const body=new THREE.Mesh(new THREE.SphereGeometry(0.24,20,12), new THREE.MeshStandardMaterial({color:0x0d1220,emissive:0x24406e,emissiveIntensity:0.7,roughness:0.25,metalness:0.85}));
+    body.scale.set(1,0.32,1);
+    const dome=new THREE.Mesh(new THREE.SphereGeometry(0.13,16,10,0,Math.PI*2,0,Math.PI/2), new THREE.MeshStandardMaterial({color:0xbdf0ff,emissive:0x46c4ff,emissiveIntensity:1.4,roughness:0.15,metalness:0.4,transparent:true,opacity:0.9}));
+    dome.position.y=0.055;
+    const rim=new THREE.Mesh(new THREE.TorusGeometry(0.215,0.028,8,26), new THREE.MeshBasicMaterial({color:0x9fe8ff,transparent:true,opacity:0.9}));
+    rim.rotation.x=Math.PI/2;
+    // searchlight: cone apex at the saucer (top), wide base sweeping DOWN to the boat
+    const beam=new THREE.Mesh(new THREE.ConeGeometry(0.42,1.7,22,1,true), new THREE.MeshBasicMaterial({color:0x8fe0ff,transparent:true,opacity:0.14,side:THREE.DoubleSide,depthWrite:false,blending:THREE.AdditiveBlending}));
+    beam.position.y=-0.9;                    // hangs below the saucer
+    u.add(beam,body,rim,dome);
+    ufo.add(u); saucers.push({g:u,beam,dome,rim,ang:i/5*Math.PI*2,spin:0.4+i*0.12});
+  }
+
+  // ---- celestial patterns: a morphing point-cloud + constellation lines ----
+  const CN=224;
+  const cur=new Float32Array(CN*3), targ=new Float32Array(CN*3);
+  const pgeo=new THREE.BufferGeometry(); pgeo.setAttribute('position',new THREE.BufferAttribute(cur,3));
+  const stars=new THREE.Points(pgeo,new THREE.PointsMaterial({color:0xfff1cf,size:0.055,transparent:true,opacity:0.95,depthWrite:false,blending:THREE.AdditiveBlending}));
+  const MAXSEG=110, lpos=new Float32Array(MAXSEG*2*3), lpair=[];
+  const lgeo=new THREE.BufferGeometry(); lgeo.setAttribute('position',new THREE.BufferAttribute(lpos,3));
+  const lines=new THREE.LineSegments(lgeo,new THREE.LineBasicMaterial({color:0x9fd0ff,transparent:true,opacity:0.28,depthWrite:false,blending:THREE.AdditiveBlending}));
+  const celest=new THREE.Group(); celest.visible=false; celest.add(stars,lines); group.add(celest);
+  // pattern builders write into targ[] and rebuild the constellation line pairs
+  const PATTERNS=['galaxy','orbits','shell','starburst'];
+  function buildPattern(name){
+    for(let i=0;i<CN;i++){
+      let x=0,y=0,z=0; const f=i/CN;
+      if(name==='galaxy'){ const arm=i%3, a=f*7.5+arm*2.094, r=0.18+f*1.35; x=Math.cos(a)*r; y=Math.sin(a)*r*0.55; z=Math.sin(i*0.7)*0.14; }
+      else if(name==='orbits'){ const ring=i%5, a=(i/5)*0.9+ring*1.3, r=0.5+ring*0.22, tilt=ring*0.5; x=Math.cos(a)*r; y=Math.sin(a)*r*Math.cos(tilt); z=Math.sin(a)*r*Math.sin(tilt); }
+      else if(name==='shell'){ const gr=(1+Math.sqrt(5))/2, th=2*Math.PI*i/gr, ph=Math.acos(1-2*(i+0.5)/CN), r=1.25; x=Math.sin(ph)*Math.cos(th)*r; y=Math.cos(ph)*r; z=Math.sin(ph)*Math.sin(th)*r; }
+      else { const pt=Math.floor(f*10), sub=(f*10)%1, base=(pt%2?0.45:1.3), a=pt*Math.PI/5; const r=base*(1-sub*0.15); x=Math.cos(a)*r; y=Math.sin(a)*r; z=(Math.random()-0.5)*0.18; } // starburst
+      targ[i*3]=x; targ[i*3+1]=y; targ[i*3+2]=z;
+    }
+    // nearest-neighbour constellation edges (from the target shape)
+    lpair.length=0;
+    for(let i=0;i<CN && lpair.length<MAXSEG;i++){
+      let bj=-1,bd=1e9;
+      for(let j=i+1;j<CN;j++){ const dx=targ[i*3]-targ[j*3],dy=targ[i*3+1]-targ[j*3+1],dz=targ[i*3+2]-targ[j*3+2],d=dx*dx+dy*dy+dz*dz; if(d<bd){bd=d;bj=j;} }
+      if(bj>=0 && bd<0.16) lpair.push([i,bj]);
+    }
+  }
+  let patIdx=0; buildPattern(PATTERNS[0]);
+  for(let i=0;i<CN*3;i++) cur[i]=targ[i]*0.2;   // start collapsed toward the core
+  function cyclePattern(){ patIdx=(patIdx+1)%PATTERNS.length; buildPattern(PATTERNS[patIdx]); }
+
   const l1=new THREE.PointLight(0xff8a4a,1.3,60); l1.position.set(4,3,5); scene.add(l1);
   const l2=new THREE.PointLight(0xf2b64a,1.0,60); l2.position.set(-4,-1,4); scene.add(l2);
+  const l3=new THREE.PointLight(0x7fd0ff,0.0,60); l3.position.set(0,3,4); scene.add(l3);   // cool light, rises in UFO/celestial modes
   scene.add(new THREE.AmbientLight(0x402028,0.9));
   const col=new THREE.Color();
   addEventListener('resize',()=>{ W=innerWidth;H=innerHeight; camera.aspect=W/H; camera.updateProjectionMatrix(); renderer.setSize(W,H,false); });
-  function loop(){ requestAnimationFrame(loop);
-    const s=window.SRM||{}, t=performance.now()/1000;
-    const pulse=s.pulse||0, level=(s.react?(s.level||0):0);
-    const idle=0.5+0.5*Math.sin(t*1.2);                 // gentle breathing when no music
+
+  // ---- effect state machine: 'none' (orb), 'ufo', 'celestial', 'bloom' ----
+  let fx='none', fxT=0, fxDur=7, w=0, seenChange=(window.SRM&&window.SRM.change)||0;
+  let ai=false, quietT=0;
+  function enterFx(name,dur){ fx=name; fxT=0; fxDur=dur||7; if(name==='celestial') cyclePattern(); }
+  // AI VJ picks the next effect from the live audio; when off, a noticeable change still splits the orb
+  function director(kind,level,vocal,bpm){
+    if(ai){
+      if(level>0.55 || bpm>132) enterFx('ufo',6);
+      else if(vocal<0.24 || kind==='vocal-out') enterFx('celestial',9);
+      else enterFx(Math.random()<0.55?'ufo':'bloom', 5);
+    } else {
+      if(kind==='vocal-out') enterFx('celestial',8);   // instrumental stretch -> stars
+      else enterFx('ufo',7);                            // any other noticeable change -> 5 UFOs
+    }
+  }
+
+  // click the orb to hand it to the AI VJ (autonomous, reads the live audio and choreographs itself)
+  function orbScreen(){ const v=group.position.clone().project(camera); return {x:(v.x*0.5+0.5)*W, y:(-v.y*0.5+0.5)*H, r:Math.max(60,Math.min(W,H)*0.16)}; }
+  addEventListener('pointerdown', e=>{
+    const o=orbScreen();
+    if(Math.hypot(e.clientX-o.x, e.clientY-o.y) < o.r){
+      ai=!ai; e.stopPropagation();
+      if(ai){ enterFx('celestial',9); if(window.SRM) window.SRM.aiVJ=true; }
+      else { fx='none'; if(window.SRM) window.SRM.aiVJ=false; }
+      const lv=document.getElementById('live'); if(lv) lv.textContent = ai ? 'The orb is taken over by the AI VJ — it choreographs UFOs, celestial patterns and blooms to the music.' : 'AI VJ released. The orb responds to song changes on its own.';
+    }
+  }, true);
+
+  const easeOut=x=>1-Math.pow(1-x,3);
+  let last=performance.now();
+  let _orbStatic=false;
+  function loop(now){ requestAnimationFrame(loop);
+    const dt=Math.min(0.05,((now||performance.now())-last)/1000); last=now||performance.now();
+    const s=window.SRM||{}, t=(now||performance.now())/1000;
+    // Honor reduced motion (WCAG 2.3.3): draw one calm static frame, then stop spinning + re-rendering.
+    if(s.reduced){
+      if(!_orbStatic){ orb.visible=true; ufo.visible=false; celest.visible=false;
+        orb.rotation.set(Math.sin(t*0.3)*0.18,0,0); orb.scale.setScalar(1);
+        core.material.emissiveIntensity=0.7; l3.intensity=0; renderer.render(scene,camera); _orbStatic=true; }
+      return;
+    }
+    _orbStatic=false;
+    const pulse=s.pulse||0, level=(s.react?(s.level||0):0), vocal=(s.react?(s.vocal||0):0), bpm=s.bpm||0;
+    const idle=0.5+0.5*Math.sin(t*1.2);
     const energy=s.react ? (level*0.6+pulse*0.7) : idle*0.12;
-    group.rotation.y+=0.008+energy*0.02; group.rotation.x=Math.sin(t*0.3)*0.18;
-    group.scale.setScalar(1+energy*0.35);
-    core.material.emissiveIntensity=0.5+energy*1.6;
-    const hue=(28 + Math.sin(t*0.2)*16 + level*40)%360;  // warm ember range, shifts with the music
+
+    // fire effects on a NOTICEABLE song change; the AI VJ also self-triggers on quiet stretches
+    const chg=(s.change||0);
+    if(chg>seenChange){ seenChange=chg; director(s.changeKind||'', level, vocal, bpm); }
+    if(s.react && level<0.16 && vocal<0.18){ quietT+=dt; if(quietT>9 && fx==='none'){ enterFx('celestial', ai?10:7); quietT=0; } } else quietT=0;
+
+    // blend weight w: ramp up while the effect holds, ramp back down, then release to the plain orb
+    const active = fx!=='none' && fxT<fxDur;
+    if(fx!=='none'){ fxT+=dt; if(fxT>=fxDur+0.7 && w<0.02){ fx='none'; } }
+    w += ((active?1:0)-w)*Math.min(1,dt*4.5);
+    const ew=easeOut(Math.max(0,Math.min(1,w)));
+
+    // ---- the plain orb (always present; shrinks away as an effect takes hold) ----
+    orb.visible = w<0.995;
+    orb.rotation.y+=0.008+energy*0.02; orb.rotation.x=Math.sin(t*0.3)*0.18;
+    const baseScale=(1+energy*0.35)*(fx==='bloom'? (1+ew*(0.8+pulse*1.4)) : (1-ew));
+    orb.scale.setScalar(Math.max(0.0001,baseScale));
+    core.material.emissiveIntensity=0.5+energy*1.6 + (fx==='bloom'?ew*2.5:0);
+    const hue=(28 + Math.sin(t*0.2)*16 + level*40)%360;
     wire.material.color.setHSL(((hue+18)%360)/360,0.85,0.6);
     col.setHSL(hue/360,0.85,0.5); core.material.emissive.copy(col);
+    l3.intensity = ew*1.4;
+
+    // ---- UFO formation ----
+    ufo.visible = (fx==='ufo') && w>0.002;
+    if(ufo.visible){
+      const spread=0.35+ew*0.95, hover=1.6;   // saucers hover above the boat then beam down
+      for(let i=0;i<saucers.length;i++){ const S5=saucers[i];
+        const a=S5.ang + t*0.25;
+        S5.g.position.set(Math.cos(a)*spread, (i-2)*0.06 + Math.sin(t*1.3+i)*0.08, Math.sin(a)*spread*0.65);
+        S5.g.rotation.y += (S5.spin+pulse*2.0)*dt*2.2;
+        S5.g.scale.setScalar(ew);
+        S5.beam.material.opacity = (0.10+pulse*0.22+level*0.14)*ew;   // beams throb on the beat
+        S5.beam.scale.y = 1+pulse*0.5 + Math.sin(t*3+i)*0.08;
+        const bh=(200+ i*26 + (bpm||0))%360; S5.dome.material.emissive.setHSL(bh/360,0.9,0.6);
+      }
+    }
+
+    // ---- celestial point-cloud ----
+    celest.visible = (fx==='celestial') && w>0.002;
+    if(celest.visible){
+      for(let i=0;i<CN*3;i++) cur[i]+=(targ[i]-cur[i])*Math.min(1,dt*2.6);
+      pgeo.attributes.position.needsUpdate=true;
+      for(let k=0;k<lpair.length;k++){ const a=lpair[k][0]*3,b=lpair[k][1]*3,o=k*6;
+        lpos[o]=cur[a];lpos[o+1]=cur[a+1];lpos[o+2]=cur[a+2]; lpos[o+3]=cur[b];lpos[o+4]=cur[b+1];lpos[o+5]=cur[b+2]; }
+      lgeo.setDrawRange(0,lpair.length*2); lgeo.attributes.position.needsUpdate=true;
+      celest.rotation.y += (0.12+energy*0.4)*dt; celest.rotation.x=Math.sin(t*0.2)*0.25;
+      celest.scale.setScalar(ew*(1+pulse*0.12));
+      stars.material.opacity=0.7+0.3*Math.sin(t*4)+pulse*0.3;
+      stars.material.color.setHSL(((t*10+level*60)%360)/360,0.5,0.85);
+      // re-morph to the next celestial pattern on a strong beat, a few seconds in
+      if(fxT>3 && pulse>0.85 && Math.random()<0.04) cyclePattern();
+    }
+
     renderer.render(scene,camera);
   }
-  loop();
+  requestAnimationFrame(loop);
 })();
 </script>
 </body>
diff --git a/tests/live-happy.cjs b/tests/live-happy.cjs
index 1203de4..aa14e34 100644
--- a/tests/live-happy.cjs
+++ b/tests/live-happy.cjs
@@ -34,23 +34,44 @@ function serve() {
 
   try {
     await page.goto(base, { waitUntil: 'load', timeout: 60000 });
-    await page.waitForFunction(() => document.querySelectorAll('.mr-yr').length > 0, { timeout: 30000 });
+    await page.waitForFunction(() => document.querySelectorAll('.mr-yr').length > 0, undefined, { timeout: 30000 });
     const tiles = await page.$$eval('.mr-yr', els => els.length);
     pass(tiles === 31, `GD source renders 31 year tiles (got ${tiles})`);
 
-    // wait for real counts to land (allow generous time — capped pool + real network)
-    await page.waitForFunction(() => {
-      const el = document.querySelector('.mr-yr[data-y="1994"] .yc');
-      const t = el ? (el.textContent || '').trim() : '';
-      return /^[0-9][0-9,]*$/.test(t) && parseInt(t.replace(/,/g, ''), 10) > 0;
-    }, { timeout: 60000 });
+    // Phase 1 DEGRADES GRACEFULLY: when archive.org is flaky the batch circuit-breaker
+    // short-circuits the remaining year-count fetches (those tiles show "—", never a
+    // false 0) and every good count is cached. A real visitor reloads and the cached-good
+    // tiles paint instantly while the still-missing years re-fetch with a fresh breaker.
+    // This harness exercises exactly that recovery — reload a bounded number of times
+    // (the srm.counts.v1 cache persists across reload in this same context) until the
+    // tour history is well-covered — rather than assuming one breaker-limited pass fills
+    // all 31 against a nonprofit archive that is intermittently slow.
+    const settleOne = async () => {
+      // resolve as soon as no tile is still "loading" (breaker short-circuits fast) or
+      // the whole-map error state appears; bounded so a hung archive can't stall forever.
+      await page.waitForFunction(() => {
+        if (document.querySelector('.mr-map-err')) return true;
+        const t = [...document.querySelectorAll('.mr-yr')];
+        return t.length > 0 && t.every(el => !el.classList.contains('loading'));
+      }, undefined, { timeout: 45000 }).catch(() => {});
+    };
+    const populatedNow = () => page.$$eval('.mr-yr .yc', els => els.filter(e => /^[0-9][0-9,]*$/.test((e.textContent||'').trim()) && +(e.textContent.replace(/,/g,'')) > 0).length);
+    let populated = 0;
+    for (let attempt = 1; attempt <= 4; attempt++) {
+      await settleOne();
+      populated = await populatedNow();
+      if (populated >= 28) break;
+      if (attempt < 4) {
+        await page.reload({ waitUntil: 'load', timeout: 60000 });
+        await page.waitForFunction(() => document.querySelectorAll('.mr-yr').length > 0, undefined, { timeout: 30000 });
+      }
+    }
 
     const y1994 = await page.$eval('.mr-yr[data-y="1994"] .yc', e => (e.textContent || '').trim());
     const n1994 = parseInt(y1994.replace(/,/g, ''), 10);
     pass(n1994 >= 400 && n1994 <= 520, `1994 shows a real count ~= 460 (got ${y1994})`);
 
-    // count how many of the 31 tiles populated with a real number
-    const populated = await page.$$eval('.mr-yr .yc', els => els.filter(e => /^[0-9][0-9,]*$/.test((e.textContent||'').trim()) && +(e.textContent.replace(/,/g,'')) > 0).length);
+    // how many of the 31 tiles populated with a real number (across cache-backed reloads)
     pass(populated >= 28, `most GD tiles populated with real counts (${populated}/31)`);
     const zero = await page.$$eval('.mr-yr .yc', els => els.some(e => (e.textContent || '').trim() === '0'));
     pass(!zero, `no GD tile shows a false "0"`);
@@ -64,7 +85,7 @@ function serve() {
     await page.waitForFunction(() => {
       const tiles = document.querySelectorAll('.mr-yr');
       return tiles.length >= 8 && [...tiles].some(t => /^[0-9][0-9,]*$/.test((t.querySelector('.yc')?.textContent||'').trim()));
-    }, { timeout: 60000 });
+    }, undefined, { timeout: 60000 });
     const dcTiles = await page.$$eval('.mr-yr', els => els.length);
     pass(dcTiles >= 8 && dcTiles <= 9, `D&C source loads (2015-2023 → ${dcTiles} tiles)`);
 

← ab81288 riverboat: Phase 1 archive.org reliability — cap year-count  ·  back to Scarlet Riverboat Masquerade  ·  (newest)