← back to Dead Agentabrams

docs/TK-11101-perf-a11y.patch

114 lines

--- a/room/index.html	2026-09-02 08:59:47
+++ b/room/index.html	2026-09-02 09:03:51
@@ -237,6 +237,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>
@@ -266,9 +270,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>
@@ -401,6 +405,18 @@
 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 = [
@@ -1240,6 +1256,8 @@
   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
@@ -1619,6 +1637,8 @@
 }
 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
@@ -1647,7 +1667,9 @@
   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',()=>{
@@ -1707,7 +1729,7 @@
   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`; });
@@ -1734,11 +1756,11 @@
 
 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:'');
   });
@@ -1804,8 +1826,16 @@
   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); });
+  let _orbStatic=false;
   function loop(){ requestAnimationFrame(loop);
     const s=window.SRM||{}, t=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){ group.rotation.set(Math.sin(t*0.3)*0.18,0,0); group.scale.setScalar(1);
+        core.material.emissiveIntensity=0.7; renderer.render(scene,camera); _orbStatic=true; }
+      return;
+    }
+    _orbStatic=false;
     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
     const energy=s.react ? (level*0.6+pulse*0.7) : idle*0.12;