[object Object]

← back to Scarlet Riverboat Masquerade

Scarlet Riverboat Masquerade: working single-file animated gallery

a1ca1d2e3ae863d359c1d2bbdac03c16c26340eb · 2026-09-01 16:30:01 -0700 · Steve Abrams

Files touched

Diff

commit a1ca1d2e3ae863d359c1d2bbdac03c16c26340eb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 16:30:01 2026 -0700

    Scarlet Riverboat Masquerade: working single-file animated gallery
---
 .gitignore |   9 +
 README.md  |  33 +++
 index.html | 972 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 1014 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..169aa3b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.playwright-mcp/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..df7e7ae
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# Scarlet Riverboat Masquerade — An Animated Gallery
+
+A single-file generative canvas gallery: a fantastical paddlewheel riverboat
+glides through a hand-painted crimson canyon at dusk while masked dancers fill
+three decks and animals loop along the shoreline. Every movement is **authored
+visual rhythm** — a cadence written into the drawing loop — **not** audio
+analysis. There is no sound.
+
+## Run
+Open `index.html` in any modern browser. No build, no dependencies.
+
+## What you can steer
+- **Cameras** — Wide River · Deck · Overhead
+- **Sliders** — Visual Tempo · Lantern Glow · Crowd Density · Animal Visibility
+- **Toggles** — Pause/Play · Restart · Reduced Motion · Motion Trails
+- **Chapters** — I Departure · II Moon Bend · III Dawn Landing
+  (palette *and* choreography change between them; the boat also drifts
+  through them on a gentle auto-journey until you pick a chapter yourself)
+
+## Keyboard
+`Space` play/pause · `R` restart · `M` reduced motion · `T` trails ·
+`1/2/3` cameras · `← →` chapters · `L` museum label. Focus states are visible
+throughout; a live region announces state changes for screen readers.
+
+## Choreography (original visual language)
+- Couples pivot in shared arcs beneath the lanterns (lower deck)
+- A linked circle turns about the smokestack (middle deck / overhead)
+- Soloists spin trailing scarves; musicians appear only as abstract silhouettes
+- Shoreline: a trotting fox, a slow-stepping heron, a squash-and-stretch
+  rabbit, and a bear conducting fireflies — each a distinct weighted loop
+
+No band logos, album art, or borrowed emblems — an original homage to the
+communal, exploratory spirit of the scene.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cd04390
--- /dev/null
+++ b/index.html
@@ -0,0 +1,972 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="description" content="Scarlet Riverboat Masquerade — an authored, animated generative gallery of a paddlewheel riverboat drifting through a crimson canyon at dusk. Steer tempo, lanterns, crowd, animals, cameras and chapters.">
+<meta name="color-scheme" content="dark">
+<title>Scarlet Riverboat Masquerade — An Animated Gallery</title>
+<style>
+  :root{
+    --ink:#f4e7d6;
+    --ink-dim:#c9a98c;
+    --gold:#f2b64a;
+    --crimson:#7c1522;
+    --panel:rgba(24,8,12,0.72);
+    --panel-edge:rgba(242,182,74,0.28);
+    --focus:#ffd27a;
+    --serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
+    --sans:"Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;background:#0a0406;color:var(--ink);font-family:var(--sans);overflow:hidden}
+  #stage{position:fixed;inset:0;display:block}
+  canvas{position:absolute;inset:0;width:100%;height:100%}
+
+  /* ---- Skip link ---- */
+  .skip{
+    position:fixed;top:-60px;left:12px;z-index:20;background:var(--gold);color:#2a0a0e;
+    padding:10px 16px;border-radius:8px;font-weight:600;text-decoration:none;transition:top .2s;
+  }
+  .skip:focus{top:12px}
+
+  /* ---- Title plate ---- */
+  .titleplate{
+    position:fixed;top:22px;left:26px;z-index:6;max-width:min(46vw,520px);
+    pointer-events:none;text-shadow:0 2px 18px rgba(0,0,0,.7);
+  }
+  .titleplate .kicker{
+    font-family:var(--sans);letter-spacing:.42em;text-transform:uppercase;
+    font-size:11px;color:var(--gold);opacity:.9;margin:0 0 6px 2px;
+  }
+  .titleplate h1{
+    font-family:var(--serif);font-weight:600;font-style:italic;
+    font-size:clamp(26px,4.4vw,52px);line-height:.98;margin:0;color:var(--ink);
+  }
+  .titleplate .chap{
+    font-family:var(--serif);font-size:14px;color:var(--ink-dim);margin-top:8px;font-style:italic;
+  }
+
+  /* ---- Chapter rail ---- */
+  .chapters{
+    position:fixed;top:22px;right:26px;z-index:6;display:flex;gap:8px;flex-direction:column;align-items:flex-end;
+  }
+  .chapters button{
+    font-family:var(--serif);font-size:13px;font-style:italic;
+    background:var(--panel);color:var(--ink-dim);border:1px solid transparent;
+    border-radius:999px;padding:7px 15px;cursor:pointer;backdrop-filter:blur(6px);
+    transition:color .3s,border-color .3s,background .3s;
+    display:flex;align-items:center;gap:8px;
+  }
+  .chapters button .dot{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.6}
+  .chapters button[aria-current="true"]{color:var(--gold);border-color:var(--panel-edge);background:rgba(40,12,16,.86)}
+  .chapters button:hover{color:var(--ink)}
+
+  /* ---- Control dock ---- */
+  .dock{
+    position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:7;
+    display:flex;gap:18px;align-items:stretch;flex-wrap:wrap;justify-content:center;
+    max-width:min(96vw,1080px);
+    background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;
+    padding:14px 20px;backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5);
+  }
+  .group{display:flex;gap:12px;align-items:center}
+  .group + .group{border-left:1px solid rgba(242,182,74,.16);padding-left:18px}
+  .field{display:flex;flex-direction:column;gap:5px;min-width:96px}
+  .field label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim)}
+  .field .val{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:12px}
+  input[type=range]{
+    -webkit-appearance:none;appearance:none;height:4px;border-radius:4px;
+    background:linear-gradient(90deg,var(--crimson),var(--gold));outline:none;width:100%;
+  }
+  input[type=range]::-webkit-slider-thumb{
+    -webkit-appearance:none;width:16px;height:16px;border-radius:50%;
+    background:var(--ink);border:2px solid var(--gold);cursor:pointer;box-shadow:0 0 8px rgba(242,182,74,.6);
+  }
+  input[type=range]::-moz-range-thumb{
+    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;
+  }
+  .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}
+
+  /* ---- Camera pills ---- */
+  .cams{position:fixed;left:26px;bottom:20px;z-index:7;display:flex;gap:8px;flex-direction:column}
+  .cams .btn{justify-content:flex-start;min-width:118px}
+
+  /* ---- Museum label ---- */
+  .label-toggle{position:fixed;right:26px;bottom:20px;z-index:7}
+  .museum{
+    position:fixed;right:26px;bottom:66px;z-index:8;width:min(88vw,368px);max-height:74vh;overflow:auto;
+    background:linear-gradient(180deg,rgba(28,10,14,.96),rgba(16,6,9,.96));
+    border:1px solid var(--panel-edge);border-radius:14px;padding:20px 22px;
+    box-shadow:0 18px 50px rgba(0,0,0,.6);transform-origin:bottom right;
+    transition:opacity .3s,transform .3s;font-size:13px;line-height:1.55;color:var(--ink-dim);
+  }
+  .museum[hidden]{display:none}
+  .museum h2{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:18px;margin:0 0 4px}
+  .museum .meta{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:12px;opacity:.8}
+  .museum p{margin:0 0 10px}
+  .museum strong{color:var(--ink);font-weight:600}
+  .museum .keys{margin:14px 0 0;padding:12px 0 0;border-top:1px solid rgba(242,182,74,.16);
+    font-size:11px;line-height:1.7;letter-spacing:.02em}
+  .museum .keys b{display:inline-block;min-width:74px;color:var(--gold);font-family:var(--serif);font-style:italic;font-weight:600}
+
+  :focus-visible{outline:3px solid var(--focus);outline-offset:2px;border-radius:6px}
+  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+
+  @media (max-width:820px){
+    .titleplate{max-width:64vw}
+    .dock{gap:10px;padding:10px 12px}
+    .group + .group{padding-left:10px}
+    .field{min-width:74px}
+    .cams{flex-direction:row;left:12px;bottom:100px}
+    .cams .btn{min-width:auto;padding:7px 10px}
+    .label-toggle{bottom:100px;right:12px}
+    .museum{bottom:148px;right:12px}
+    .chapters{top:auto;bottom:100px;right:12px;flex-direction:row}
+  }
+  @media (prefers-reduced-motion: reduce){
+    .museum,.chapters button,.btn,.skip{transition:none}
+  }
+</style>
+</head>
+<body>
+<a href="#dock" class="skip">Skip to controls</a>
+<main id="stage" role="application" aria-label="Scarlet Riverboat Masquerade — an animated visual gallery you can steer with the controls and keyboard">
+  <h1 class="sr">Scarlet Riverboat Masquerade — an animated generative gallery</h1>
+  <canvas id="sky" aria-hidden="true"></canvas>
+  <canvas id="scene" aria-hidden="true"></canvas>
+
+  <div class="titleplate">
+    <p class="kicker">An Animated Gallery</p>
+    <h1 aria-hidden="true">Scarlet Riverboat<br>Masquerade</h1>
+    <p class="chap" id="chapLabel">Chapter I · Departure</p>
+  </div>
+
+  <nav class="chapters" aria-label="Chapters">
+    <button data-chapter="0" aria-current="true"><span class="dot" aria-hidden="true"></span>I · Departure</button>
+    <button data-chapter="1"><span class="dot" aria-hidden="true"></span>II · Moon Bend</button>
+    <button data-chapter="2"><span class="dot" aria-hidden="true"></span>III · Dawn Landing</button>
+  </nav>
+
+  <div class="cams" role="group" aria-label="Camera view">
+    <button class="btn" data-cam="wide" aria-pressed="true">◈ Wide River</button>
+    <button class="btn" data-cam="deck" aria-pressed="false">◈ Deck</button>
+    <button class="btn" data-cam="over" aria-pressed="false">◈ Overhead</button>
+  </div>
+
+  <div class="dock" id="dock" role="group" aria-label="Visual controls">
+    <div class="group">
+      <div class="toggle">
+        <button class="btn" id="playBtn" aria-pressed="true">❚❚ Pause</button>
+        <button class="btn" id="restartBtn">↻ Restart</button>
+      </div>
+      <div class="toggle">
+        <button class="btn" id="reduceBtn" aria-pressed="false">◐ Reduced Motion</button>
+        <button class="btn" id="trailBtn" aria-pressed="true">✧ Motion Trails</button>
+      </div>
+    </div>
+    <div class="group">
+      <div class="field">
+        <label for="tempo">Visual Tempo <span class="val" id="tempoV">Moderato</span></label>
+        <input type="range" id="tempo" min="0" max="200" value="100" aria-valuetext="Moderato">
+      </div>
+      <div class="field">
+        <label for="lantern">Lantern Glow <span class="val" id="lanternV">Warm</span></label>
+        <input type="range" id="lantern" min="0" max="100" value="70" aria-valuetext="Warm">
+      </div>
+      <div class="field">
+        <label for="crowd">Crowd Density <span class="val" id="crowdV">Full</span></label>
+        <input type="range" id="crowd" min="0" max="100" value="80" aria-valuetext="Full">
+      </div>
+      <div class="field">
+        <label for="animals">Animal Visibility <span class="val" id="animalsV">All</span></label>
+        <input type="range" id="animals" min="0" max="100" value="100" aria-valuetext="All">
+      </div>
+    </div>
+  </div>
+
+  <button class="btn label-toggle" id="labelBtn" aria-expanded="false" aria-controls="museum">🏷 Museum Label</button>
+  <aside class="museum" id="museum" hidden aria-label="Museum label">
+    <h2>Scarlet Riverboat Masquerade</h2>
+    <div class="meta">Generative Canvas · Authored Visual Rhythm · Single File</div>
+    <p>A fantastical paddlewheel riverboat drifts through a hand-painted crimson canyon while masked revelers fill three decks. <strong>Every movement here is composed, not measured</strong> — the tempo you steer is an <em>authored visual cadence</em> written into the drawing loop itself, never an analysis of sound. There is no audio; the "rhythm" is pure choreography of light and line.</p>
+    <p>The <strong>original visual language</strong> reduces each dancer to a weighted silhouette: couples pivot in shared arcs, a linked circle turns about the smokestack, soloists trail scarves, and musicians appear only as abstract shadow-forms — a communal, exploratory spirit rendered without any borrowed logo, emblem, or album art.</p>
+    <p>On the shore, four creatures each carry a distinct loop with believable weight — a <strong>trotting fox</strong>, a <strong>slow-stepping heron</strong>, a <strong>squash-and-stretch rabbit</strong>, and a <strong>bear conducting fireflies</strong>. Palette <em>and</em> choreography change across the three chapters as the boat travels from dusk (Departure) through moonrise (Moon Bend) into sunrise (Dawn Landing).</p>
+    <div class="keys">
+      <div><b>Space</b> pause / play</div>
+      <div><b>R</b> restart the journey</div>
+      <div><b>M</b> reduced motion</div>
+      <div><b>T</b> motion trails</div>
+      <div><b>1&nbsp;2&nbsp;3</b> wide · deck · overhead</div>
+      <div><b>← →</b> previous / next chapter</div>
+      <div><b>L</b> this museum label</div>
+    </div>
+  </aside>
+
+  <p class="sr" aria-live="polite" id="live">Wide river view. Chapter one, Departure.</p>
+</main>
+
+<script>
+(() => {
+"use strict";
+const sky = document.getElementById('sky');
+const scene = document.getElementById('scene');
+const sctx = sky.getContext('2d');
+const ctx = scene.getContext('2d');
+const live = document.getElementById('live');
+
+// ---------- State ----------
+const S = {
+  t: 0,               // authored master clock (visual beats)
+  playing: true,
+  reduced: false,
+  trails: true,
+  tempo: 1.0,         // 0..2
+  lantern: 0.7,
+  crowd: 0.8,
+  animals: 1.0,
+  cam: 'wide',        // wide | deck | over
+  chapter: 0,         // 0..2 (target)
+  chapterMix: 0,      // eased 0..2 for palette blend
+  autoJourney: true,  // gentle auto-advance until the visitor picks a chapter
+  journeyClock: 0,    // seconds accrued toward the next auto-advance
+  W: 0, H: 0, DPR: 1
+};
+const CHAPTER_SECONDS = 55; // dwell before the boat drifts to the next chapter
+
+// Respect OS reduced-motion at load
+if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches){
+  S.reduced = true;
+}
+
+// ---------- Palettes + per-chapter choreography ----------
+const CHAPTERS = [
+  { // Departure — dusk crimson/gold, couples lead, easy circle
+    name:'Departure',
+    sky:['#2a0a12','#6d1220','#c0432b','#e6893f'],
+    canyon:['#3a0d16','#7a1a25','#521018'],
+    river:'#5a1420', riverHi:'#c95a3a',
+    lantern:'#ffcf6e', dancer:'rgba(20,4,8,0.92)', accent:'#f2b64a',
+    choreo:{ ringDir:1, ringSpeed:0.45, coupleTurn:1.0, scarf:0.9, ringSway:1.0 }
+  },
+  { // Moon Bend — deep violet/blue night, the linked circle dominates and quickens
+    name:'Moon Bend',
+    sky:['#0a0620','#241245','#4a1c52','#7a2b46'],
+    canyon:['#180a2a','#341a44','#20102e'],
+    river:'#1a1030', riverHi:'#8a6bd0',
+    lantern:'#ffe08a', dancer:'rgba(8,4,18,0.92)', accent:'#c9a2ff',
+    choreo:{ ringDir:1, ringSpeed:0.9, coupleTurn:0.7, scarf:1.15, ringSway:1.4 }
+  },
+  { // Dawn Landing — rose/amber sunrise, soloists + scarves take the floor, circle reverses
+    name:'Dawn Landing',
+    sky:['#3a1428','#8a3550','#d97a5a','#f6c88a'],
+    canyon:['#4a1a2a','#8a3a44','#5c2230'],
+    river:'#6a2438', riverHi:'#f6b06a',
+    lantern:'#fff0c0', dancer:'rgba(28,10,14,0.9)', accent:'#ffcf8a',
+    choreo:{ ringDir:-1, ringSpeed:0.4, coupleTurn:1.25, scarf:1.5, ringSway:0.8 }
+  }
+];
+
+function lerp(a,b,t){return a+(b-a)*t;}
+function clamp(v,lo,hi){return Math.max(lo,Math.min(hi,v));}
+function hexToRgb(h){h=h.replace('#','');return [parseInt(h.substr(0,2),16),parseInt(h.substr(2,2),16),parseInt(h.substr(4,2),16)];}
+function mixHex(h1,h2,t){const a=hexToRgb(h1),b=hexToRgb(h2);return `rgb(${Math.round(lerp(a[0],b[0],t))},${Math.round(lerp(a[1],b[1],t))},${Math.round(lerp(a[2],b[2],t))})`;}
+function pal(key,i){ // blended palette between integer chapters
+  const m = S.chapterMix; const lo=Math.floor(m), hi=Math.min(2,lo+1), f=m-lo;
+  const A=CHAPTERS[lo][key], B=CHAPTERS[hi][key];
+  if(Array.isArray(A)) return mixHex(A[i],B[i],f);
+  return mixHex(A,B,f);
+}
+// Choreography snaps to the nearest chapter (palette blends, steps change crisply)
+function CH(){ return CHAPTERS[clamp(Math.round(S.chapterMix),0,2)].choreo; }
+
+// ---------- Resize ----------
+function resize(){
+  S.DPR = Math.min(2, window.devicePixelRatio||1);
+  S.W = window.innerWidth; S.H = window.innerHeight;
+  [sky,scene].forEach(c=>{
+    c.width = Math.max(1,Math.round(S.W*S.DPR)); c.height = Math.max(1,Math.round(S.H*S.DPR));
+    c.getContext('2d').setTransform(S.DPR,0,0,S.DPR,0,0);
+  });
+  buildSky();
+}
+window.addEventListener('resize', resize);
+
+// ---------- Sky (redrawn on chapter change / resize) ----------
+let skyDirty = true;
+function buildSky(){ skyDirty = true; }
+function drawSky(){
+  const W=S.W,H=S.H;
+  const g = sctx.createLinearGradient(0,0,0,H*0.72);
+  g.addColorStop(0, pal('sky',0));
+  g.addColorStop(0.45, pal('sky',1));
+  g.addColorStop(0.8, pal('sky',2));
+  g.addColorStop(1, pal('sky',3));
+  sctx.clearRect(0,0,W,H);
+  sctx.fillStyle=g; sctx.fillRect(0,0,W,H*0.74);
+
+  // Moon / sun — position drifts with the journey
+  const cx = lerp(W*0.78, W*0.28, S.chapterMix/2);
+  const cy = lerp(H*0.18, H*0.30, Math.sin(S.chapterMix/2*Math.PI));
+  const r = lerp(46, 62, S.chapterMix/2);
+  const glow = sctx.createRadialGradient(cx,cy,4,cx,cy,r*3.2);
+  glow.addColorStop(0, pal('lantern',0)); glow.addColorStop(0.15, pal('accent',0));
+  glow.addColorStop(1,'rgba(0,0,0,0)');
+  sctx.fillStyle=glow; sctx.beginPath(); sctx.arc(cx,cy,r*3.2,0,7); sctx.fill();
+  sctx.fillStyle=pal('lantern',0); sctx.beginPath(); sctx.arc(cx,cy,r,0,7); sctx.fill();
+
+  // Stars, densest at Moon Bend
+  const starA = Math.max(0, 1-Math.abs(S.chapterMix-1)) * 0.9;
+  if(starA>0.02){
+    sctx.fillStyle=`rgba(255,240,210,${starA})`;
+    for(let i=0;i<120;i++){
+      const sx=(i*137.5)%W, sy=((i*89.3)%(H*0.5));
+      const tw = 0.4+0.6*Math.abs(Math.sin(i*1.7));
+      sctx.globalAlpha=starA*tw; sctx.fillRect(sx,sy,1.5,1.5);
+    }
+    sctx.globalAlpha=1;
+  }
+  skyDirty=false;
+}
+
+// ---------- Canyon layers (parallax, far -> near) ----------
+function canyonSilhouette(baseY, amp, seed, color, offset){
+  const W=S.W,H=S.H;
+  ctx.fillStyle=color;
+  ctx.beginPath();
+  ctx.moveTo(0,H);
+  ctx.lineTo(0,baseY);
+  const step = W/14;
+  for(let x=0;x<=W+step;x+=step){
+    const n = Math.sin((x*0.006)+seed) + 0.5*Math.sin((x*0.017)+seed*2.3);
+    const y = baseY - amp*(0.6+0.4*n) - offset;
+    ctx.lineTo(x, y);
+  }
+  ctx.lineTo(W,H);
+  ctx.closePath();
+  ctx.fill();
+}
+
+// ---------- Boat transform for the active camera ----------
+function boatTransform(){
+  const W=S.W,H=S.H;
+  const bob = S.reduced?0:Math.sin(S.t*0.6)*6;
+  const sway = S.reduced?0:Math.sin(S.t*0.33)*0.012;
+  let cx=W*0.5, cy=H*0.60+bob, scale=Math.min(W,H)/620, rot=sway;
+  if(S.cam==='deck'){ cx=W*0.5; cy=H*0.66+bob; scale=Math.min(W,H)/300; rot=sway*0.6; }
+  if(S.cam==='over'){ cx=W*0.5; cy=H*0.52; scale=Math.min(W,H)/540; rot=0; }
+  return {cx,cy,scale,rot,bob};
+}
+
+// ---------- Dancers ----------
+function drawFigure(x,y,s,phase,type,accent){
+  const t=S.t, ch=CH();
+  ctx.save();
+  ctx.translate(x,y);
+  ctx.scale(s,s);
+
+  let turn=0, lean=0, bob=0, scarf=0, armSwing=0;
+  const ph = phase;
+  if(type==='couple'){
+    turn = Math.sin(t*1.1+ph)*0.9*ch.coupleTurn;   // shared pivot, chapter-weighted
+    lean = Math.sin(t*1.1+ph)*0.12*ch.coupleTurn;
+    bob = Math.abs(Math.sin(t*1.1+ph))* -3;         // rise on the turn
+    armSwing = 0.7;
+  } else if(type==='circle'){
+    turn = Math.sin(t*0.8+ph)*0.35;
+    bob = Math.abs(Math.sin(t*1.6+ph))*-2.4;
+    lean = Math.sin(t*0.8+ph)*0.05;
+    armSwing=0.4;
+  } else if(type==='solo'){
+    turn = (t*1.8+ph)%(Math.PI*2);                  // continuous spin
+    scarf = 1; bob = Math.abs(Math.sin(t*2.2+ph))*-2;
+    armSwing=1.0;
+  } else if(type==='musician'){
+    bob = Math.sin(t*1.4+ph)*-1.6;
+    armSwing = 0.5+0.3*Math.sin(t*2+ph);
+  }
+  ctx.rotate(lean);
+  ctx.translate(0,bob);
+
+  // scarf trail for soloists — length/opacity swell across chapters
+  if(type==='solo' && scarf){
+    ctx.save();
+    ctx.rotate(turn);
+    ctx.strokeStyle=accent; ctx.globalAlpha=0.5*ch.scarf; ctx.lineWidth=2.2;
+    ctx.beginPath();
+    const seg = Math.round(10*ch.scarf);
+    for(let i=0;i<seg;i++){
+      const a=i*0.5, rr=8+i*2.4;
+      const px=Math.cos(a+t*3)* rr, py=-14+Math.sin(a+t*3)*rr*0.4;
+      i===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
+    }
+    ctx.stroke(); ctx.globalAlpha=1; ctx.restore();
+  }
+
+  ctx.rotate(turn*0.28);
+  ctx.fillStyle=pal('dancer',0);
+
+  // head (with a small mask mark)
+  ctx.beginPath(); ctx.arc(0,-22,4.6,0,7); ctx.fill();
+  ctx.fillStyle=accent; ctx.globalAlpha=0.85;
+  ctx.fillRect(-3.4,-24,6.8,2.4);          // mask band
+  ctx.globalAlpha=1; ctx.fillStyle=pal('dancer',0);
+
+  // torso (tapered)
+  ctx.beginPath();
+  ctx.moveTo(-4,-18); ctx.quadraticCurveTo(0,-6,-3,4);
+  ctx.lineTo(3,4); ctx.quadraticCurveTo(0,-6,4,-18);
+  ctx.closePath(); ctx.fill();
+
+  // arms — swing conveys weight/effort
+  const sw = Math.sin(t*(type==='musician'?2:1.4)+ph)*armSwing;
+  ctx.lineWidth=2.6; ctx.strokeStyle=pal('dancer',0); ctx.lineCap='round';
+  ctx.beginPath();
+  ctx.moveTo(-3,-15); ctx.lineTo(-9-sw*4,-10+sw*3);
+  ctx.moveTo(3,-15);  ctx.lineTo(9-sw*4,-10-sw*3);
+  ctx.stroke();
+
+  // legs — bent-knee push (the source of the bob)
+  const legPush = Math.abs(Math.sin(t*(type==='solo'?2.2:1.1)+ph));
+  ctx.beginPath();
+  ctx.moveTo(-2,3); ctx.lineTo(-3-legPush*1.5, 16);
+  ctx.moveTo(2,3);  ctx.lineTo(3+ (1-legPush)*1.5, 16);
+  ctx.stroke();
+
+  // musician's instrument = abstract shadow bar
+  if(type==='musician'){
+    ctx.strokeStyle=accent; ctx.globalAlpha=0.5; ctx.lineWidth=2;
+    ctx.beginPath(); ctx.moveTo(4,-14); ctx.lineTo(14,-18); ctx.stroke();
+    ctx.globalAlpha=1;
+  }
+  ctx.restore();
+}
+
+// Little lantern with glow
+function lantern(x,y,s,glow,color){
+  if(glow>0.02){
+    const g=ctx.createRadialGradient(x,y,1,x,y,26*s*(0.5+glow));
+    g.addColorStop(0,color); g.addColorStop(0.4,`rgba(255,190,90,${0.35*glow})`);
+    g.addColorStop(1,'rgba(0,0,0,0)');
+    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,26*s*(0.5+glow),0,7); ctx.fill();
+  }
+  ctx.fillStyle=color; ctx.beginPath(); ctx.arc(x,y,2.6*s,0,7); ctx.fill();
+}
+
+// ---------- The Riverboat ----------
+function drawBoat(){
+  const {cx,cy,scale,rot}=boatTransform();
+  const accent=pal('accent',0), lan=pal('lantern',0);
+  const glow=S.lantern;
+  ctx.save();
+  ctx.translate(cx,cy); ctx.rotate(rot); ctx.scale(scale,scale);
+
+  if(S.cam==='over'){ drawBoatOverhead(accent,lan,glow); ctx.restore(); return; }
+
+  // ----- hull -----
+  ctx.fillStyle='rgba(30,8,12,0.96)';
+  ctx.beginPath();
+  ctx.moveTo(-230,40);
+  ctx.quadraticCurveTo(-260,10,-210,4);
+  ctx.lineTo(210,4);
+  ctx.quadraticCurveTo(262,10,232,40);
+  ctx.quadraticCurveTo(0,78,-230,40);
+  ctx.closePath(); ctx.fill();
+  // waterline highlight
+  ctx.strokeStyle=pal('riverHi',0); ctx.globalAlpha=0.5; ctx.lineWidth=2;
+  ctx.beginPath(); ctx.moveTo(-215,42); ctx.quadraticCurveTo(0,72,215,42); ctx.stroke();
+  ctx.globalAlpha=1;
+
+  // ----- three decks -----
+  const deckDefs=[
+    {y:4,  w:200, h:-34, n:9},   // lower
+    {y:-30,w:160, h:-30, n:7},   // middle
+    {y:-60,w:120, h:-26, n:5}    // upper
+  ];
+  deckDefs.forEach((d,di)=>{
+    ctx.fillStyle='rgba(46,14,18,0.95)';
+    ctx.fillRect(-d.w, d.y+d.h, d.w*2, 6);
+    ctx.strokeStyle='rgba(70,24,28,0.9)'; ctx.lineWidth=2;
+    for(let px=-d.w; px<=d.w; px+=d.w/6){
+      ctx.beginPath(); ctx.moveTo(px,d.y+d.h); ctx.lineTo(px,d.y+d.h+6); ctx.stroke();
+    }
+    const nl = 6;
+    for(let i=0;i<=nl;i++){
+      const lx=-d.w + (2*d.w)*(i/nl);
+      const flick = S.reduced?1:(0.85+0.15*Math.sin(S.t*3+i+di));
+      lantern(lx, d.y+d.h-4, 1, glow*flick, lan);
+    }
+  });
+
+  // ----- smokestacks -----
+  ctx.fillStyle='rgba(24,6,10,0.98)';
+  [-40,40].forEach(sx=>{
+    ctx.fillRect(sx-7,-140,14,86);
+    ctx.fillStyle=accent; ctx.globalAlpha=0.6; ctx.fillRect(sx-9,-142,18,6); ctx.globalAlpha=1;
+    ctx.fillStyle='rgba(24,6,10,0.98)';
+  });
+  // smoke drift
+  if(!S.reduced){
+    ctx.fillStyle=`rgba(200,140,120,0.10)`;
+    for(let i=0;i<6;i++){
+      const t=S.t*0.5+i; const sx=-40+Math.sin(t)*8;
+      ctx.beginPath(); ctx.arc(sx-Math.sin(t*0.4)*30, -150-i*16-(S.t*4%20), 8+i*2,0,7); ctx.fill();
+    }
+  }
+
+  // ----- pilot house -----
+  ctx.fillStyle='rgba(40,12,16,0.98)';
+  ctx.fillRect(-30,-104,60,30);
+  ctx.fillStyle=accent; ctx.globalAlpha=0.35; ctx.fillRect(-24,-98,48,10); ctx.globalAlpha=1;
+
+  // ----- paddlewheel (right side) -----
+  drawPaddlewheel(232,26,accent);
+
+  // ----- populate decks with dancers -----
+  populateDecks(deckDefs,accent);
+
+  ctx.restore();
+}
+
+function drawPaddlewheel(x,y,accent){
+  const spin = S.reduced? 0 : S.t*2.1;
+  ctx.save(); ctx.translate(x,y);
+  ctx.strokeStyle='rgba(60,20,24,0.9)'; ctx.lineWidth=3;
+  ctx.beginPath(); ctx.arc(0,0,34,0,7); ctx.stroke();
+  ctx.save(); ctx.rotate(spin);
+  ctx.strokeStyle=accent; ctx.lineWidth=2.4; ctx.globalAlpha=0.85;
+  for(let i=0;i<8;i++){
+    ctx.rotate(Math.PI/4);
+    ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-32); ctx.stroke();
+    ctx.fillStyle='rgba(50,16,20,0.9)';
+    ctx.fillRect(-9,-34,18,7);
+  }
+  ctx.restore();
+  ctx.globalAlpha=1;
+  ctx.fillStyle=accent; ctx.beginPath(); ctx.arc(0,0,4,0,7); ctx.fill();
+  if(!S.reduced){
+    ctx.fillStyle='rgba(240,200,170,0.18)';
+    for(let i=0;i<5;i++){ const a=(S.t*3+i)%1; ctx.beginPath(); ctx.arc(6+a*24,34+Math.sin(i)*3,3-a*2,0,7); ctx.fill();}
+  }
+  ctx.restore();
+}
+
+function populateDecks(deckDefs, accent){
+  const density = S.crowd, ch = CH();
+  // Deck 0 (lower): couples turning beneath lanterns
+  const d0=deckDefs[0];
+  const nCouples = Math.round(lerp(0,5,density));
+  for(let i=0;i<nCouples;i++){
+    const gx = -d0.w+30 + (2*d0.w-60)*(nCouples<=1?0.5:i/(nCouples-1));
+    const gap = 9+Math.sin(S.t*1.1+i)*3;
+    drawFigure(gx-gap, d0.y+d0.h-2, 0.62, i*1.3, 'couple', accent);
+    drawFigure(gx+gap, d0.y+d0.h-2, 0.62, i*1.3+Math.PI, 'couple', accent);
+  }
+  // Deck 1 (middle): linked circle around the smokestack + a soloist
+  const d1=deckDefs[1];
+  const ringN = Math.round(lerp(3,8,density));
+  const ringR = 46, ringSpin = S.reduced?0:S.t*0.5*ch.ringSpeed*ch.ringDir;
+  // linking ribbon between the circle dancers
+  ctx.strokeStyle=accent; ctx.globalAlpha=0.28; ctx.lineWidth=1.6;
+  ctx.beginPath();
+  for(let i=0;i<=ringN;i++){
+    const a=(i/ringN)*Math.PI*2+ringSpin;
+    const rx=Math.cos(a)*ringR, ry=d1.y+d1.h-2+Math.sin(a)*6*ch.ringSway;
+    i===0?ctx.moveTo(rx,ry):ctx.lineTo(rx,ry);
+  }
+  ctx.stroke(); ctx.globalAlpha=1;
+  for(let i=0;i<ringN;i++){
+    const a = (i/ringN)*Math.PI*2 + ringSpin;
+    const rx = Math.cos(a)*ringR, rz=Math.sin(a)*0.42;
+    const fx = rx, fy = d1.y+d1.h-2 + Math.sin(a)*6*ch.ringSway;
+    drawFigure(fx, fy, 0.5*(0.8+rz*0.2+0.2), i, 'circle', accent);
+  }
+  if(density>0.3) drawFigure(d1.w-30, d1.y+d1.h-2, 0.6, 2.0, 'solo', accent);
+
+  // Deck 2 (upper): musicians as abstract silhouettes + a soloist
+  const d2=deckDefs[2];
+  const nMus = Math.round(lerp(1,4,density));
+  for(let i=0;i<nMus;i++){
+    const gx=-d2.w+26 + (2*d2.w-52)*(i/Math.max(1,nMus));
+    drawFigure(gx, d2.y+d2.h-2, 0.55, i*2.1, 'musician', accent);
+  }
+  if(density>0.5) drawFigure(0, d2.y+d2.h-2, 0.58, 1.1, 'solo', accent);
+}
+
+// Overhead camera: top-down of the circle around the smokestack
+function drawBoatOverhead(accent,lan,glow){
+  const ch=CH();
+  ctx.fillStyle='rgba(38,12,16,0.92)';
+  ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.fill();
+  ctx.strokeStyle='rgba(70,24,28,0.8)'; ctx.lineWidth=3;
+  ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.stroke();
+  // bow marker
+  ctx.fillStyle=accent; ctx.globalAlpha=0.5;
+  ctx.beginPath(); ctx.moveTo(0,-118); ctx.lineTo(-10,-102); ctx.lineTo(10,-102); ctx.closePath(); ctx.fill();
+  ctx.globalAlpha=1;
+  // central smokestack (top)
+  ctx.fillStyle='rgba(20,6,10,0.98)';
+  ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.fill();
+  ctx.strokeStyle=accent; ctx.globalAlpha=0.6; ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.stroke(); ctx.globalAlpha=1;
+  // linked circle rotating around it — the marquee choreography, seen from above
+  const ringN=Math.round(lerp(4,12,S.crowd)); const R=78; const spin=S.reduced?0:S.t*0.55*ch.ringSpeed*ch.ringDir;
+  ctx.strokeStyle=accent; ctx.globalAlpha=0.35; ctx.lineWidth=2;
+  ctx.beginPath();
+  for(let i=0;i<=ringN;i++){const a=(i/ringN)*Math.PI*2+spin; const x=Math.cos(a)*R,y=Math.sin(a)*R*0.62; i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
+  ctx.closePath(); ctx.stroke(); ctx.globalAlpha=1;
+  for(let i=0;i<ringN;i++){
+    const a=(i/ringN)*Math.PI*2+spin;
+    const x=Math.cos(a)*R, y=Math.sin(a)*R*0.62;
+    ctx.save(); ctx.translate(x,y);
+    ctx.fillStyle=pal('dancer',0); ctx.beginPath(); ctx.arc(0,0,5,0,7); ctx.fill();
+    ctx.fillStyle=accent; ctx.globalAlpha=0.8; ctx.fillRect(-4,-1.5,8,3); ctx.globalAlpha=1;
+    ctx.restore();
+    lantern(Math.cos(a)*(R+22),Math.sin(a)*(R+22)*0.62,0.7,glow*0.8,lan);
+  }
+  // couples as small paired dots on the outer deck
+  const nc=Math.round(lerp(0,6,S.crowd));
+  for(let i=0;i<nc;i++){
+    const a=(i/Math.max(1,nc))*Math.PI*2 - spin*0.6; const r=150;
+    const x=Math.cos(a)*r, y=Math.sin(a)*r*0.6;
+    const g=6+Math.sin(S.t*1.1+i)*3;
+    ctx.fillStyle=pal('dancer',0);
+    ctx.beginPath(); ctx.arc(x-g,y,3.4,0,7); ctx.fill();
+    ctx.beginPath(); ctx.arc(x+g,y,3.4,0,7); ctx.fill();
+  }
+}
+
+// ---------- Shoreline animals ----------
+function drawAnimals(){
+  if(S.animals<=0.02) return;
+  const W=S.W,H=S.H;
+  const baseY=H*0.78;
+  const accent=pal('accent',0);
+  ctx.save();
+  ctx.globalAlpha=Math.min(1,S.animals);
+  // shore band
+  ctx.fillStyle=pal('canyon',0);
+  ctx.beginPath(); ctx.moveTo(0,baseY);
+  for(let x=0;x<=W;x+=40){ctx.lineTo(x,baseY+Math.sin(x*0.02)*6);}
+  ctx.lineTo(W,H); ctx.lineTo(0,H); ctx.fill();
+
+  const t=S.t;
+  const scaleA = S.cam==='deck'?1.15:1;
+
+  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);
+
+  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;
+  ctx.save(); ctx.translate(x + Math.sin(t*0.4)*30, y+bob); ctx.scale(s,s);
+  ctx.fillStyle='rgba(30,8,12,0.95)';
+  ctx.beginPath(); ctx.ellipse(0,-10,15,7,0,0,7); ctx.fill();
+  ctx.beginPath(); ctx.moveTo(12,-14); ctx.lineTo(24,-16); ctx.lineTo(13,-8); ctx.closePath(); ctx.fill();
+  ctx.beginPath(); ctx.moveTo(14,-18); ctx.lineTo(17,-24); ctx.lineTo(19,-17);ctx.closePath(); ctx.fill();
+  ctx.strokeStyle=accent; ctx.lineWidth=5; ctx.lineCap='round'; ctx.globalAlpha=0.8;
+  ctx.beginPath(); ctx.moveTo(-14,-10); ctx.quadraticCurveTo(-26,-14-gait*5,-30,-4-gait*3); ctx.stroke();
+  ctx.globalAlpha=1;
+  ctx.strokeStyle='rgba(30,8,12,0.95)'; ctx.lineWidth=2.4;
+  const lg=gait*4;
+  ctx.beginPath();
+  ctx.moveTo(-8,-4); ctx.lineTo(-8-lg,3);
+  ctx.moveTo(8,-4);  ctx.lineTo(8+lg,3);
+  ctx.moveTo(-4,-4); ctx.lineTo(-4+lg,3);
+  ctx.moveTo(4,-4);  ctx.lineTo(4-lg,3);
+  ctx.stroke();
+  ctx.restore();
+}
+
+function heron(x,y,s,t,accent){
+  const step=Math.sin(t*0.9);
+  const wing=Math.sin(t*1.2)*0.5;
+  ctx.save(); ctx.translate(x + Math.sin(t*0.25)*18, y); ctx.scale(s,s);
+  ctx.strokeStyle='rgba(24,8,14,0.95)'; ctx.fillStyle='rgba(24,8,14,0.95)';
+  ctx.lineWidth=2;
+  ctx.beginPath();
+  ctx.moveTo(-2,-18); ctx.lineTo(-4,2+ (step>0?0:step*8));
+  ctx.moveTo(3,-18);  ctx.lineTo(5,2+ (step>0?step*8:0));
+  ctx.stroke();
+  ctx.beginPath(); ctx.ellipse(0,-24,10,7,-0.2,0,7); ctx.fill();
+  ctx.save(); ctx.translate(0,-26); ctx.rotate(wing*0.6);
+  ctx.beginPath(); ctx.moveTo(0,0); ctx.quadraticCurveTo(-14,-4,-20,4); ctx.quadraticCurveTo(-10,2,0,3); ctx.closePath(); ctx.fill();
+  ctx.restore();
+  ctx.lineWidth=2.4;
+  ctx.beginPath(); ctx.moveTo(6,-28);
+  ctx.quadraticCurveTo(16,-34,12,-46); ctx.stroke();
+  ctx.beginPath(); ctx.arc(12,-48,3,0,7); ctx.fill();
+  ctx.strokeStyle=accent; ctx.beginPath(); ctx.moveTo(14,-48); ctx.lineTo(24,-49); ctx.stroke();
+  ctx.restore();
+}
+
+function rabbit(x,y,s,t,accent){
+  const cyc=(t*1.6)%(Math.PI*2);
+  const air=Math.max(0,Math.sin(cyc));
+  const stretch=1+air*0.4, squash=1-Math.max(0,-Math.sin(cyc))*0.35;
+  const hopY=-air*26;
+  ctx.save();
+  ctx.translate(x + ((t*22)% (S.W*0.14)) - S.W*0.05, y+hopY);
+  ctx.scale(s*(2-stretch)*0.9 + s*0.1, s*stretch*squash);
+  ctx.fillStyle='rgba(34,10,14,0.95)';
+  ctx.beginPath(); ctx.ellipse(0,-8,9,7,0,0,7); ctx.fill();
+  ctx.beginPath(); ctx.arc(8,-12,4.5,0,7); ctx.fill();
+  const ear = air*0.8;
+  ctx.save(); ctx.translate(8,-15); ctx.rotate(ear);
+  ctx.beginPath(); ctx.ellipse(0,-6,2,6,0.2,0,7); ctx.fill();
+  ctx.beginPath(); ctx.ellipse(4,-6,2,6,0.4,0,7); ctx.fill();
+  ctx.restore();
+  ctx.fillStyle=accent; ctx.globalAlpha=0.85;
+  ctx.beginPath(); ctx.arc(-9,-8,3,0,7); ctx.fill(); ctx.globalAlpha=1;
+  ctx.fillStyle='rgba(34,10,14,0.95)';
+  ctx.beginPath(); ctx.ellipse(-3,-2+air*2,4,2,air*0.6,0,7); ctx.fill();
+  ctx.restore();
+}
+
+function bearAndFireflies(x,y,s,t,accent){
+  const wob=Math.sin(t*0.35)*3;
+  ctx.save(); ctx.translate(x+wob,y); ctx.scale(s,s);
+  ctx.fillStyle='rgba(20,6,10,0.97)';
+  ctx.beginPath(); ctx.ellipse(0,-16,18,20,0,0,7); ctx.fill();
+  ctx.beginPath(); ctx.ellipse(-14,-2,10,8,0,0,7); ctx.fill();
+  ctx.beginPath(); ctx.ellipse(14,-2,10,8,0,0,7); ctx.fill();
+  ctx.beginPath(); ctx.arc(0,-36,11,0,7); ctx.fill();
+  ctx.beginPath(); ctx.arc(-9,-45,4,0,7); ctx.fill();
+  ctx.beginPath(); ctx.arc(9,-45,4,0,7); ctx.fill();
+  ctx.strokeStyle='rgba(20,6,10,0.97)'; ctx.lineWidth=6; ctx.lineCap='round';
+  const ax=Math.sin(t*0.7)*16, ay=-Math.abs(Math.cos(t*0.7))*10;
+  ctx.beginPath(); ctx.moveTo(6,-24); ctx.quadraticCurveTo(16,-34,18+ax,-40+ay); ctx.stroke();
+  ctx.strokeStyle=accent; ctx.lineWidth=2; ctx.globalAlpha=0.8;
+  ctx.beginPath(); ctx.moveTo(18+ax,-40+ay); ctx.lineTo(18+ax+6,-44+ay); ctx.stroke();
+  ctx.globalAlpha=1;
+  ctx.restore();
+
+  // fireflies respond to the conducting motion (authored, not audio)
+  const n=14;
+  for(let i=0;i<n;i++){
+    const ph=i*0.7;
+    const orbit=30+ (i%5)*10;
+    const a=t*0.7*(0.6+ (i%3)*0.2) + ph;
+    const fx=x + Math.cos(a)*orbit + Math.sin(t*0.7)*14;
+    const fy=y-40 + Math.sin(a*1.3)*orbit*0.5 - (i%4)*6;
+    const blink=0.35+0.65*Math.abs(Math.sin(t*3+ph));
+    const g=ctx.createRadialGradient(fx,fy,0,fx,fy,7);
+    g.addColorStop(0,`rgba(255,230,140,${blink})`); g.addColorStop(1,'rgba(255,200,90,0)');
+    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fy,7,0,7); ctx.fill();
+  }
+}
+
+// ---------- River foreground with parallax reflections ----------
+function drawRiver(){
+  const W=S.W,H=S.H;
+  const top=H*0.72;
+  const g=ctx.createLinearGradient(0,top,0,H);
+  g.addColorStop(0, pal('river',0));
+  g.addColorStop(1, mixHex(pal('river',0),'#000000',0.5));
+  ctx.fillStyle=g; ctx.fillRect(0,top,W,H-top);
+  const hi=pal('riverHi',0);
+  ctx.strokeStyle=hi;
+  for(let layer=0;layer<3;layer++){
+    const alpha=0.16-layer*0.04, speed=(layer+1)*0.5;
+    ctx.globalAlpha=alpha; ctx.lineWidth=1.5+layer;
+    for(let i=0;i<12;i++){
+      const y=top+ (H-top)*((i+ (S.t*speed*0.02*(layer+1))%1)/12);
+      const amp=(y-top)*0.06;
+      ctx.beginPath();
+      for(let x=0;x<=W;x+=24){
+        const yy=y+Math.sin((x*0.02)+S.t*speed+layer)*amp*(S.reduced?0.3:1);
+        x===0?ctx.moveTo(x,yy):ctx.lineTo(x,yy);
+      }
+      ctx.stroke();
+    }
+  }
+  ctx.globalAlpha=1;
+  if(S.lantern>0.05 && S.cam!=='over'){
+    const {cx}=boatTransform();
+    const rg=ctx.createRadialGradient(cx,top+30,4,cx,top+30,120);
+    rg.addColorStop(0,`rgba(255,190,90,${0.22*S.lantern})`); rg.addColorStop(1,'rgba(0,0,0,0)');
+    ctx.fillStyle=rg; ctx.beginPath(); ctx.ellipse(cx,top+40,120,40,0,0,7); ctx.fill();
+  }
+}
+
+// ---------- Main loop ----------
+let last=performance.now();
+function frame(now){
+  const dt=Math.min(0.05,(now-last)/1000); last=now;
+
+  if(S.playing){
+    const spd = S.reduced ? 0.28 : 1.0;
+    S.t += dt * S.tempo * spd * 1.4;
+
+    // Gentle auto-journey through the three chapters (until the visitor takes over)
+    if(S.autoJourney && !S.reduced){
+      S.journeyClock += dt;
+      if(S.journeyClock >= CHAPTER_SECONDS){
+        S.journeyClock = 0;
+        setChapter((S.chapter+1)%3, {auto:true});
+      }
+    }
+  }
+
+  // ease chapter mix toward target
+  const prevMix=S.chapterMix;
+  S.chapterMix += (S.chapter - S.chapterMix)*Math.min(1,dt*2.2);
+  if(Math.abs(S.chapter-S.chapterMix)>0.001 || Math.abs(prevMix-S.chapterMix)>0.001) skyDirty=true;
+
+  if(skyDirty) drawSky();
+
+  const W=S.W,H=S.H;
+  if(S.trails && !S.reduced && S.playing){
+    ctx.fillStyle='rgba(6,2,4,0.16)';
+    ctx.fillRect(0,0,W,H);
+  } else {
+    ctx.clearRect(0,0,W,H);
+  }
+
+  // parallax canyon (farthest -> nearest)
+  canyonSilhouette(H*0.54, 150, 6.1, mixHex(pal('canyon',0),'#000000',0.35), Math.sin(S.t*0.03)*3);
+  canyonSilhouette(H*0.62, 120, 1.2, pal('canyon',0), Math.sin(S.t*0.05)*4);
+  canyonSilhouette(H*0.70, 90, 4.5, pal('canyon',2), Math.sin(S.t*0.05+2)*3);
+
+  drawRiver();
+  drawAnimals();
+  drawBoat();
+
+  requestAnimationFrame(frame);
+}
+
+// ---------- UI wiring ----------
+const tempoWords=['Adagio','Andante','Moderato','Allegro','Vivace'];
+const lanternWords=['Doused','Dim','Warm','Bright','Blazing'];
+const crowdWords=['Empty','Sparse','Lively','Full','Teeming'];
+const animalWords=['Hidden','Few','Some','Most','All'];
+function word(arr,v){return arr[Math.min(arr.length-1,Math.floor(v*arr.length))];}
+
+const tempoEl=document.getElementById('tempo');
+const tempoV=document.getElementById('tempoV');
+tempoEl.addEventListener('input',e=>{
+  S.tempo=e.target.value/100; // 0..2
+  const w=word(tempoWords, Math.min(0.999,S.tempo/2));
+  tempoV.textContent=w;
+  e.target.setAttribute('aria-valuetext',w);
+});
+
+function wireSlider(id,valId,arr,setter){
+  const el=document.getElementById(id), v=document.getElementById(valId);
+  el.addEventListener('input',e=>{
+    const val=e.target.value/100; setter(val);
+    const w=word(arr,val); v.textContent=w; e.target.setAttribute('aria-valuetext',w);
+  });
+}
+wireSlider('lantern','lanternV',lanternWords,val=>S.lantern=val);
+wireSlider('crowd','crowdV',crowdWords,val=>S.crowd=val);
+wireSlider('animals','animalsV',animalWords,val=>S.animals=val);
+
+// Sync every label to its true computed word on load (no hardcoded mismatch)
+['tempo','lantern','crowd','animals'].forEach(id=>{
+  document.getElementById(id).dispatchEvent(new Event('input'));
+});
+
+const playBtn=document.getElementById('playBtn');
+playBtn.addEventListener('click',()=>{
+  S.playing=!S.playing;
+  playBtn.setAttribute('aria-pressed',S.playing);
+  playBtn.textContent=S.playing?'❚❚ Pause':'► Play';
+  announce(S.playing?'Playing.':'Paused.');
+});
+document.getElementById('restartBtn').addEventListener('click',()=>{
+  S.t=0; S.playing=true; S.autoJourney=true; S.journeyClock=0;
+  playBtn.setAttribute('aria-pressed','true'); playBtn.textContent='❚❚ Pause';
+  setChapter(0,{silent:true});
+  ctx.clearRect(0,0,S.W,S.H);
+  announce('Restarted the journey from Departure.');
+});
+const reduceBtn=document.getElementById('reduceBtn');
+reduceBtn.addEventListener('click',()=>{
+  S.reduced=!S.reduced; reduceBtn.setAttribute('aria-pressed',S.reduced);
+  if(S.reduced) ctx.clearRect(0,0,S.W,S.H);
+  announce(S.reduced?'Reduced motion on. Movement gentled.':'Reduced motion off.');
+});
+if(S.reduced) reduceBtn.setAttribute('aria-pressed','true');
+const trailBtn=document.getElementById('trailBtn');
+trailBtn.addEventListener('click',()=>{
+  S.trails=!S.trails; trailBtn.setAttribute('aria-pressed',S.trails);
+  if(!S.trails) ctx.clearRect(0,0,S.W,S.H);
+  announce(S.trails?'Motion trails on.':'Motion trails off.');
+});
+
+// cameras
+document.querySelectorAll('[data-cam]').forEach(b=>{
+  b.addEventListener('click',()=>{
+    S.cam=b.dataset.cam;
+    document.querySelectorAll('[data-cam]').forEach(x=>x.setAttribute('aria-pressed', x===b));
+    ctx.clearRect(0,0,S.W,S.H);
+    const names={wide:'Wide river',deck:'Deck',over:'Overhead'};
+    announce(names[S.cam]+' view.');
+  });
+});
+
+// chapters
+const chapLabel=document.getElementById('chapLabel');
+const romans=['I','II','III'];
+function setChapter(i, opts){
+  opts=opts||{};
+  S.chapter=i; skyDirty=true;
+  if(!opts.auto){ S.autoJourney=false; } // a manual pick takes the wheel from the auto-journey
+  S.journeyClock=0;
+  document.querySelectorAll('[data-chapter]').forEach(b=>b.setAttribute('aria-current', String(+b.dataset.chapter===i)));
+  chapLabel.textContent=`Chapter ${romans[i]} · ${CHAPTERS[i].name}`;
+  if(!opts.silent) announce(`Chapter ${romans[i]}, ${CHAPTERS[i].name}. Palette and choreography shifting.`);
+}
+document.querySelectorAll('[data-chapter]').forEach(b=>{
+  b.addEventListener('click',()=>setChapter(+b.dataset.chapter));
+});
+
+// museum label
+const labelBtn=document.getElementById('labelBtn');
+const museum=document.getElementById('museum');
+labelBtn.addEventListener('click',()=>{
+  const open=museum.hidden;
+  museum.hidden=!open;
+  labelBtn.setAttribute('aria-expanded',String(open));
+});
+
+function announce(msg){ live.textContent=msg; }
+
+// keyboard shortcuts
+window.addEventListener('keydown',e=>{
+  // Let native controls handle their own Space/Enter activation
+  if(e.target.matches('input,button')&&(e.key===' '||e.key==='Enter')) return;
+  switch(e.key.toLowerCase()){
+    case ' ': e.preventDefault(); playBtn.click(); break;
+    case 'r': document.getElementById('restartBtn').click(); break;
+    case 'm': reduceBtn.click(); break;
+    case 't': trailBtn.click(); break;
+    case '1': document.querySelector('[data-cam="wide"]').click(); break;
+    case '2': document.querySelector('[data-cam="deck"]').click(); break;
+    case '3': document.querySelector('[data-cam="over"]').click(); break;
+    case 'arrowright': e.preventDefault(); setChapter(Math.min(2,S.chapter+1)); break;
+    case 'arrowleft': e.preventDefault(); setChapter(Math.max(0,S.chapter-1)); break;
+    case 'l': labelBtn.click(); break;
+  }
+});
+
+// init
+resize();
+requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>

(oldest)  ·  back to Scarlet Riverboat Masquerade  ·  Fix NaN-color crash that froze the render loop (pal() rgb st f968af5 →