[object Object]

← back to Rose City Rooftop Solstice

Rose City Rooftop Solstice — self-contained animated rooftop-dance gallery piece

77a9df85eb32f760ec37f120eebc592e04e26187 · 2026-09-01 16:31:24 -0700 · Steve Abrams

Files touched

Diff

commit 77a9df85eb32f760ec37f120eebc592e04e26187
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 16:31:24 2026 -0700

    Rose City Rooftop Solstice — self-contained animated rooftop-dance gallery piece
---
 .gitignore |   10 +
 README.md  |   57 +++
 index.html | 1276 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 1343 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..729bb6a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.playwright-mcp/
+*.png
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ec0b831
--- /dev/null
+++ b/README.md
@@ -0,0 +1,57 @@
+# Rose City Rooftop Solstice
+
+A self-contained, animated urban-fantasy gallery piece. At sunset, interconnected
+city rooftops become a communal dance garden above a glowing city — the whole
+thing rendered live on a single HTML canvas with no external assets, no build
+step, and no network calls.
+
+Open `index.html` in any modern browser. That's the entire program.
+
+## What's in the scene
+
+**Four choreography zones**
+- A **turning circle** of figures orbiting an abstract rose-shaped light sculpture.
+- **Bridge couples** crossing narrow rooftop bridges in mirrored steps.
+- **Freestyle dancers** near the water towers, tracing curved motion trails.
+- A quiet **procession** climbing the fire escapes into the celebration.
+
+**Animal companions**
+- Bears sway with grounded weight, cats leap between ledges, pigeons rise in
+  coordinated spirals, dogs weave among the dancers, and moths gather at the lanterns.
+
+**Depth & atmosphere**
+- Parallax skyline layers, drifting clouds, swaying laundry lines, reflective
+  windows with a moving specular sweep, a luminous city-glow band, and light that
+  shifts from gold to indigo.
+
+**Three scenes**, each with its own palette and movement complexity:
+`Arrival` (warm, calm) → `Long Light` (luminous, fuller) → `Midnight Bloom`
+(deep indigo, moon, lanterns, the rose in full bloom). They drift automatically,
+or you can pin one.
+
+## Controls
+
+- **Cameras** — Skyline, Rooftop, Bird's-eye (with a plan-view tilt).
+- **Visual tempo** and **crowd density** sliders.
+- **Isolate group** — focus a single choreography zone; the others fade to ghosts.
+- Toggles for **animal & zone names**, **motion trails**, and **route diagrams**.
+- **Pause**, **Restart**, and a true **Reduced motion** still composition.
+
+Keyboard: `Space` pause · `1 2 3` cameras · `R` restart · `N` names ·
+`T` trails · `D` diagrams · `M` reduced motion · `C` hide panel.
+
+## An honest note
+
+The piece keeps its own time. Its rhythm, swells and blooms are **authored** — a
+composed visual tempo — and are **not** reactive to any audio or microphone.
+Turning on *Reduced motion* yields a calm, composed still (and the OS
+`prefers-reduced-motion` setting is honored on load).
+
+## Craft notes
+
+- Pure Canvas 2D, ~1.2k lines of vanilla JS in one file. Deterministic PRNG so the
+  composition is repeatable; `Restart` reseeds it.
+- Responsive + DPR-aware, keyboard-accessible native controls with strong focus
+  states, refined serif/sans type.
+- All imagery is original — the gathering evokes the generous, improvisational
+  spirit of Grateful Dead rooftop culture without reproducing any of it.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..e85ac7a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1276 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+<meta name="color-scheme" content="dark" />
+<meta name="description" content="Rose City Rooftop Solstice — an animated urban-fantasy gallery piece: interconnected rooftops become a communal dance garden above a glowing city at sunset." />
+<title>Rose City Rooftop Solstice</title>
+<style>
+  :root{
+    --ink:#f6efe4;
+    --ink-dim:#c9bfb0;
+    --gold:#ffd98a;
+    --rose:#ffb1d8;
+    --indigo:#2a2350;
+    --panel:rgba(18,15,34,.82);
+    --panel-edge:rgba(255,214,158,.22);
+    --focus:#8fe6ff;
+    --serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
+    --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+  }
+  *{box-sizing:border-box}
+  html,body{height:100%}
+  body{
+    margin:0;background:#0a0818;color:var(--ink);
+    font-family:var(--sans);overflow:hidden;
+    -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
+  }
+  #stage{position:fixed;inset:0}
+  canvas{display:block;width:100%;height:100%}
+
+  /* ---- Masthead ---- */
+  .masthead{
+    position:fixed;left:clamp(14px,3vw,42px);top:clamp(12px,2.4vw,30px);
+    z-index:5;max-width:min(60ch,74vw);pointer-events:none;
+    text-shadow:0 2px 22px rgba(6,4,20,.65);
+  }
+  .kicker{
+    font:600 clamp(.62rem,1.5vw,.76rem)/1 var(--sans);
+    letter-spacing:.42em;text-transform:uppercase;color:var(--gold);
+    margin:0 0 .55em;opacity:.9;
+  }
+  .title{
+    font:500 clamp(1.7rem,6vw,3.6rem)/.98 var(--serif);
+    letter-spacing:.005em;margin:0;
+    background:linear-gradient(96deg,#fff2d8 0%,var(--rose) 58%,#c7b6ff 100%);
+    -webkit-background-clip:text;background-clip:text;color:transparent;
+  }
+  .subtitle{
+    font:italic 400 clamp(.82rem,2.1vw,1.06rem)/1.35 var(--serif);
+    color:var(--ink-dim);margin:.5em 0 0;max-width:44ch;
+  }
+  .scene-chip{
+    display:inline-flex;gap:.5em;align-items:center;margin-top:.9em;
+    pointer-events:none;font:600 .72rem/1 var(--sans);letter-spacing:.14em;
+    text-transform:uppercase;color:var(--ink);
+    background:var(--panel);border:1px solid var(--panel-edge);
+    padding:.5em .8em;border-radius:999px;backdrop-filter:blur(6px);
+  }
+  .scene-chip .dot{width:.55em;height:.55em;border-radius:50%;background:var(--gold);
+    box-shadow:0 0 10px var(--gold)}
+
+  /* ---- Control dock ---- */
+  .dock{
+    position:fixed;right:clamp(10px,2vw,26px);top:clamp(12px,2.4vw,30px);bottom:auto;
+    z-index:6;width:min(320px,86vw);max-height:calc(100dvh - 48px);overflow:auto;
+    background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;
+    backdrop-filter:blur(12px) saturate(1.1);
+    box-shadow:0 18px 60px rgba(4,2,16,.5);
+    padding:14px 14px 16px;
+    transition:transform .35s cubic-bezier(.2,.8,.2,1),opacity .3s;
+  }
+  .dock[hidden]{display:block;transform:translateX(115%);opacity:0;pointer-events:none}
+  .dock h2{
+    font:600 .68rem/1 var(--sans);letter-spacing:.26em;text-transform:uppercase;
+    color:var(--gold);margin:.4em 0 .3em;
+  }
+  .dock .lede{font:400 .78rem/1.4 var(--sans);color:var(--ink-dim);margin:.1em 0 1em}
+  .grp{margin:0 0 14px;padding:0 0 12px;border-bottom:1px solid rgba(255,255,255,.07)}
+  .grp:last-of-type{border-bottom:0;padding-bottom:0}
+  .row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:8px 0}
+  label{font:500 .82rem/1.2 var(--sans);color:var(--ink)}
+  .hint{font:400 .72rem/1.3 var(--sans);color:var(--ink-dim)}
+
+  .seg{display:flex;gap:6px;flex-wrap:wrap}
+  .seg button, .btn{
+    font:600 .78rem/1 var(--sans);color:var(--ink);
+    background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);
+    padding:.55em .7em;border-radius:9px;cursor:pointer;
+    transition:background .18s,border-color .18s,color .18s,transform .05s;
+  }
+  .seg button:hover,.btn:hover{background:rgba(255,255,255,.13)}
+  .seg button[aria-pressed="true"]{
+    background:linear-gradient(180deg,rgba(255,217,138,.28),rgba(255,177,216,.16));
+    border-color:var(--gold);color:#fff;
+  }
+  .btn:active{transform:translateY(1px)}
+  select{
+    font:500 .82rem/1 var(--sans);color:var(--ink);background:rgba(10,8,24,.9);
+    border:1px solid rgba(255,255,255,.18);border-radius:9px;padding:.5em .6em;min-width:130px;
+  }
+  input[type=range]{width:150px;accent-color:var(--rose);height:22px}
+  input[type=checkbox]{width:18px;height:18px;accent-color:var(--gold)}
+
+  .actions{display:flex;gap:8px;flex-wrap:wrap}
+  .actions .btn{flex:1;text-align:center;min-width:84px}
+
+  /* ---- Focus states (strong, consistent) ---- */
+  :focus{outline:none}
+  :focus-visible{
+    outline:2.5px solid var(--focus);outline-offset:2px;border-radius:8px;
+    box-shadow:0 0 0 5px rgba(143,230,255,.22);
+  }
+  .skip{
+    position:fixed;left:8px;top:-60px;z-index:20;background:#0a0818;color:var(--ink);
+    padding:.6em .9em;border-radius:8px;border:1px solid var(--panel-edge);
+    transition:top .2s;font:600 .8rem var(--sans);text-decoration:none;
+  }
+  .skip:focus{top:8px}
+
+  /* ---- Toggle + note ---- */
+  .toolbar{
+    position:fixed;left:clamp(14px,3vw,42px);bottom:clamp(12px,2.4vw,26px);z-index:6;
+    display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;max-width:min(70ch,88vw);
+  }
+  .note{
+    font:400 .74rem/1.4 var(--sans);color:var(--ink-dim);
+    background:var(--panel);border:1px solid var(--panel-edge);border-radius:12px;
+    padding:.6em .85em;max-width:40ch;backdrop-filter:blur(8px);
+  }
+  .note b{color:var(--ink);font-weight:600}
+  .dock-toggle{
+    position:fixed;right:clamp(10px,2vw,26px);bottom:clamp(12px,2.4vw,26px);z-index:7;
+  }
+
+  /* ---- Legend ---- */
+  .legend{display:flex;flex-wrap:wrap;gap:6px}
+  .legend .tag{
+    font:600 .68rem/1 var(--sans);letter-spacing:.04em;color:var(--ink);
+    border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:.42em .6em;
+    background:rgba(255,255,255,.05);
+  }
+  .legend .tag i{width:.55em;height:.55em;border-radius:50%;display:inline-block;margin-right:.45em;vertical-align:middle}
+
+  @media (max-width:720px){
+    .subtitle{display:none}
+    .dock{top:auto;bottom:64px;right:8px}
+    .toolbar{max-width:64vw}
+  }
+  @media (prefers-reduced-motion:reduce){
+    .dock,.scene-chip{transition:none}
+  }
+</style>
+</head>
+<body>
+  <a class="skip" href="#controls">Skip to controls</a>
+
+  <div id="stage">
+    <canvas id="sky" role="img"
+      aria-label="An animated urban-fantasy scene at sunset: interconnected city rooftops host a communal dance garden. A ring of figures turns around a glowing rose-shaped light sculpture, couples cross narrow bridges in mirrored steps, freestyle dancers trace curved trails near water towers, and a quiet procession climbs fire escapes. Bears sway, cats leap between ledges, pigeons spiral, dogs weave, and moths gather at lanterns while the light shifts from gold to indigo."></canvas>
+  </div>
+
+  <header class="masthead">
+    <p class="kicker">A Rooftop Gathering</p>
+    <h1 class="title">Rose City Rooftop Solstice</h1>
+    <p class="subtitle">At sunset, the rooftops open like a garden — a communal dance above a glowing city, held together by light rather than sound.</p>
+    <div class="scene-chip"><span class="dot" id="sceneDot"></span><span id="sceneName">Arrival</span></div>
+  </header>
+
+  <!-- ============ CONTROL DOCK ============ -->
+  <aside class="dock" id="controls" aria-label="Composition controls">
+    <h2>Compose the piece</h2>
+    <p class="lede">Every choice below reshapes the gathering. The visual rhythm is authored — nothing here listens to audio.</p>
+
+    <div class="grp" role="group" aria-label="Camera">
+      <div class="row"><label id="camLbl">Camera</label></div>
+      <div class="seg" role="group" aria-labelledby="camLbl" id="camSeg">
+        <button data-cam="skyline" aria-pressed="false">Skyline</button>
+        <button data-cam="rooftop" aria-pressed="true">Rooftop</button>
+        <button data-cam="birdseye" aria-pressed="false">Bird’s-eye</button>
+      </div>
+    </div>
+
+    <div class="grp" role="group" aria-label="Scene">
+      <div class="row">
+        <label for="sceneSel">Scene</label>
+        <select id="sceneSel">
+          <option value="0">Arrival</option>
+          <option value="1">Long Light</option>
+          <option value="2">Midnight Bloom</option>
+        </select>
+      </div>
+      <div class="row">
+        <label for="autoScene">Drift through scenes</label>
+        <input type="checkbox" id="autoScene" checked />
+      </div>
+      <p class="hint">Each scene changes palette and movement complexity — Arrival is warm and calm, Midnight Bloom is deep and intricate.</p>
+    </div>
+
+    <div class="grp" role="group" aria-label="Motion">
+      <div class="row">
+        <label for="tempo">Visual tempo</label>
+        <input type="range" id="tempo" min="0.15" max="2" step="0.05" value="1" aria-describedby="tempoVal" />
+      </div>
+      <div class="row"><span class="hint" id="tempoVal">1.0×</span></div>
+      <div class="row">
+        <label for="density">Crowd density</label>
+        <input type="range" id="density" min="0.3" max="1.5" step="0.05" value="1" aria-describedby="densVal" />
+      </div>
+      <div class="row"><span class="hint" id="densVal">Full</span></div>
+    </div>
+
+    <div class="grp" role="group" aria-label="Choreography">
+      <div class="row">
+        <label for="isolate">Isolate group</label>
+        <select id="isolate">
+          <option value="all">All zones</option>
+          <option value="circle">Rose circle</option>
+          <option value="bridges">Bridge couples</option>
+          <option value="freestyle">Freestyle trails</option>
+          <option value="procession">Fire-escape procession</option>
+        </select>
+      </div>
+    </div>
+
+    <div class="grp" role="group" aria-label="Overlays">
+      <div class="row"><label for="tgNames">Animal &amp; zone names</label><input type="checkbox" id="tgNames" /></div>
+      <div class="row"><label for="tgTrails">Motion trails</label><input type="checkbox" id="tgTrails" checked /></div>
+      <div class="row"><label for="tgDiag">Route diagrams</label><input type="checkbox" id="tgDiag" /></div>
+    </div>
+
+    <div class="grp" role="group" aria-label="Accessibility &amp; playback">
+      <div class="row"><label for="tgReduce">Reduced motion (still composition)</label><input type="checkbox" id="tgReduce" /></div>
+      <div class="actions">
+        <button class="btn" id="btnPause" aria-pressed="false">Pause</button>
+        <button class="btn" id="btnRestart">Restart</button>
+      </div>
+    </div>
+
+    <div class="grp" role="group" aria-label="Companions legend">
+      <h2 style="margin-top:0">Companions</h2>
+      <div class="legend" id="legend"></div>
+    </div>
+
+    <p class="hint" style="margin-bottom:0">Keys — <b>Space</b> pause · <b>1 2 3</b> cameras · <b>R</b> restart · <b>N</b> names · <b>T</b> trails · <b>D</b> diagrams · <b>M</b> reduced motion · <b>C</b> hide panel.</p>
+  </aside>
+
+  <button class="btn dock-toggle" id="dockToggle" aria-expanded="true" aria-controls="controls">Hide panel</button>
+
+  <div class="toolbar">
+    <p class="note"><b>An honest note.</b> This piece keeps time on its own. Its rhythm, swells and blooms are <b>authored</b> — a composed visual tempo — <em>not</em> reactive to any music or microphone. Turn on <b>Reduced motion</b> for a calm, still composition.</p>
+  </div>
+
+<script>
+"use strict";
+(function(){
+/* =========================================================================
+   Rose City Rooftop Solstice — a self-contained animated gallery piece.
+   Canvas 2D. World coordinates (y grows downward). Cameras map world→screen.
+   No audio, no network, no external assets. All imagery is original.
+   ========================================================================= */
+
+const canvas = document.getElementById('sky');
+const ctx = canvas.getContext('2d', { alpha:false });
+
+/* ---------- deterministic PRNG (so the composition is repeatable) ---------- */
+function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
+let rnd = mulberry32(0x50C1CE|0);
+const R = (a,b)=>a+(b-a)*rnd();
+const RI = (a,b)=>Math.floor(R(a,b+1));
+const TAU = Math.PI*2;
+const clamp=(v,a,b)=>v<a?a:v>b?b:v;
+const lerp=(a,b,t)=>a+(b-a)*t;
+
+/* ---------- color helpers ---------- */
+function hx(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
+function mix(a,b,t){return [lerp(a[0],b[0],t),lerp(a[1],b[1],t),lerp(a[2],b[2],t)];}
+function rgb(c,a){return a==null?`rgb(${c[0]|0},${c[1]|0},${c[2]|0})`:`rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;}
+
+/* ---------- scenes: palette + complexity ---------- */
+const SCENES = [
+  { name:'Arrival', complexity:0.55, sunY:0.72, star:0.0,
+    skyTop:hx('4a3c7e'), skyMid:hx('c8724e'), skyBot:hx('f5b862'),
+    orb:hx('ffe6a8'), roof:hx('4a3d63'), city:hx('2a2350'),
+    glow:hx('ffcf8a'), accent:hx('ffe6b0'), lantern:hx('ffb257') },
+  { name:'Long Light', complexity:0.85, sunY:0.80, star:0.15,
+    skyTop:hx('2f6d86'), skyMid:hx('e0a15a'), skyBot:hx('ffd27a'),
+    orb:hx('fff0c0'), roof:hx('3a5566'), city:hx('153043'),
+    glow:hx('ffdca0'), accent:hx('bff0e0'), lantern:hx('ffbe5e') },
+  { name:'Midnight Bloom', complexity:1.0, sunY:0.30, star:1.0,
+    skyTop:hx('0a0e2c'), skyMid:hx('241a52'), skyBot:hx('5a2f7a'),
+    orb:hx('dfe6ff'), roof:hx('241f3a'), city:hx('0a0a24'),
+    glow:hx('cf8cff'), accent:hx('ffd1f0'), lantern:hx('ffb257') },
+];
+const SCENE_DUR = 26; // seconds per scene when drifting
+
+/* ---------- global state ---------- */
+const S = {
+  cam:'rooftop', targetScene:0, autoScene:true, sceneT:0,
+  tempo:1, density:1, isolate:'all', paused:false, reduced:false,
+  names:false, trails:true, diagrams:false,
+  t:0,                 // animation clock (scaled by tempo)
+  cfrac:SCENES[0].complexity,
+};
+
+/* current, continuously-lerped palette + numeric fields */
+const P = {
+  skyTop:SCENES[0].skyTop.slice(), skyMid:SCENES[0].skyMid.slice(), skyBot:SCENES[0].skyBot.slice(),
+  orb:SCENES[0].orb.slice(), roof:SCENES[0].roof.slice(), city:SCENES[0].city.slice(),
+  glow:SCENES[0].glow.slice(), accent:SCENES[0].accent.slice(), lantern:SCENES[0].lantern.slice(),
+  sunY:SCENES[0].sunY, star:SCENES[0].star,
+};
+
+/* ---------- world layout ---------- */
+const WORLD = { w:1600 };
+// Foreground rooftops (platforms). top = world-y of the roof surface (feet line).
+const ROOFS = [
+  { id:'westTower',  x:250,  w:150, top:64,  kind:'tower' },
+  { id:'proc',       x:585,  w:180, top:118, kind:'flat'  },
+  { id:'center',     x:840,  w:290, top:80,  kind:'plaza' },
+  { id:'eastTower',  x:1360, w:150, top:52,  kind:'tower' },
+];
+const roofById = id => ROOFS.find(r=>r.id===id);
+// bridges connect center to the towers (narrow crossing platforms)
+const BRIDGES = [
+  { x0:395, x1:695,  y:150, side:-1 },          // west bridge (westTower→proc/center)
+  { x0:985, x1:1285, y:132, side:1 },           // east bridge (center→eastTower)
+];
+// lanterns sit around the plaza + towers
+const LANTERNS = [
+  {x:705,y:66}, {x:975,y:66}, {x:840,y:150}, {x:250,y:44}, {x:1360,y:32}, {x:585,y:100},
+];
+
+/* ---------- background parallax skyline (procedural, deterministic) ---------- */
+function buildSkyline(seed, count, baseTop, spreadTop, wMin, wMax){
+  const r = mulberry32(seed);
+  const arr=[]; let x=-200;
+  while(x < WORLD.w+300){
+    const w = wMin + r()*(wMax-wMin);
+    const top = baseTop + (r()-0.5)*spreadTop;
+    arr.push({x, w, top, lit:r()});
+    x += w + r()*30 - 6;
+  }
+  return arr;
+}
+const FAR   = buildSkyline(11, 0, 230, 130, 60, 130);
+const MID   = buildSkyline(29, 0, 300, 150, 80, 170);
+
+/* ---------- clouds ---------- */
+const CLOUDS = Array.from({length:9},()=>({
+  x:R(-200,WORLD.w+200), y:R(-40,180), s:R(60,150), v:R(3,9), o:R(.10,.28), d:R(0,1)>.5?1:1,
+}));
+
+/* ---------- laundry lines ---------- */
+const LAUNDRY = [
+  { x0:170,y0:120,x1:330,y1:150, items:6 },
+  { x0:900,y0:150,x1:1030,y1:190, items:5 },
+  { x0:500,y0:190,x1:665,y1:210, items:6 },
+];
+
+/* ============================ ENTITIES ============================ */
+const BODY_COLORS = ['#e8756b','#f2b25e','#6fbf9b','#7aa6e6','#c98cd8','#e79ac0','#f0d27a','#79cbd0'];
+
+/* ---- Rose circle: ring of dancers turning around the light sculpture ---- */
+const center = roofById('center');
+const ROSE = { x:center.x, y:center.top-6, R:96 };
+function makeCircle(n){
+  const a=[];
+  for(let i=0;i<n;i++){
+    a.push({ a0:(i/n)*TAU, r:ROSE.R*R(.86,1.06), col:BODY_COLORS[i%BODY_COLORS.length], ph:R(0,TAU), sz:R(.9,1.12) });
+  }
+  return a;
+}
+let CIRCLE = makeCircle(20);
+
+/* ---- Bridge couples: mirrored steps crossing narrow bridges ---- */
+function makeCouples(){
+  const c=[];
+  BRIDGES.forEach((b,bi)=>{
+    for(let k=0;k<2;k++){
+      c.push({ b:bi, off:k*0.5, ph:R(0,TAU), col:BODY_COLORS[(bi*2+k)%BODY_COLORS.length] });
+    }
+  });
+  return c;
+}
+let COUPLES = makeCouples();
+
+/* ---- Freestyle dancers near water towers: curved trails ---- */
+function makeFreestyle(n){
+  const a=[]; const anchors=[roofById('westTower'),roofById('eastTower')];
+  for(let i=0;i<n;i++){
+    const an=anchors[i%2];
+    a.push({ cx:an.x+R(-38,38), cy:an.top-R(6,26), rx:R(26,48), ry:R(14,30),
+             sp:R(.6,1.15)*(rnd()>.5?1:-1), ph:R(0,TAU), col:BODY_COLORS[(i+3)%BODY_COLORS.length],
+             trail:[] });
+  }
+  return a;
+}
+let FREE = makeFreestyle(6);
+
+/* ---- Procession: quiet climb up the fire escape, then toward the plaza ---- */
+const escBuild = roofById('proc');
+// fire escape zig-zag path on the left face of the 'proc' building, then across the roof
+function processionPath(){
+  const bx = escBuild.x - escBuild.w/2;          // left edge
+  const roofY = escBuild.top;
+  const cityY = roofY + 210;                       // start low in the city
+  const pts=[];
+  pts.push([bx-8, cityY]);
+  let y=cityY, dir=1;
+  const steps=6;
+  for(let i=0;i<steps;i++){
+    y -= (cityY-roofY)/steps;
+    pts.push([bx + (dir>0? 26: -6), y]);
+    dir*=-1;
+  }
+  pts.push([bx+10, roofY]);
+  pts.push([escBuild.x+escBuild.w/2-10, roofY-2]);   // walk across roof
+  pts.push([center.x-ROSE.R-30, center.top+4]);      // arrive at the circle
+  return pts;
+}
+const PROC_PATH = processionPath();
+const PROC_LEN = (()=>{let L=0;for(let i=1;i<PROC_PATH.length;i++){L+=Math.hypot(PROC_PATH[i][0]-PROC_PATH[i-1][0],PROC_PATH[i][1]-PROC_PATH[i-1][1]);}return L;})();
+function pathAt(u){ // u in [0,1] along PROC_PATH → [x,y, angle]
+  let d=u*PROC_LEN;
+  for(let i=1;i<PROC_PATH.length;i++){
+    const seg=Math.hypot(PROC_PATH[i][0]-PROC_PATH[i-1][0],PROC_PATH[i][1]-PROC_PATH[i-1][1]);
+    if(d<=seg||i===PROC_PATH.length-1){
+      const t=seg?d/seg:0;
+      const x=lerp(PROC_PATH[i-1][0],PROC_PATH[i][0],t);
+      const y=lerp(PROC_PATH[i-1][1],PROC_PATH[i][1],t);
+      return [x,y];
+    }
+    d-=seg;
+  }
+  return PROC_PATH[PROC_PATH.length-1];
+}
+function makeProcession(n){
+  const a=[]; for(let i=0;i<n;i++) a.push({ u:i/(n+2)*0.9, col:BODY_COLORS[(i+5)%BODY_COLORS.length], ph:R(0,TAU) });
+  return a;
+}
+let PROC = makeProcession(9);
+
+/* ---- Animal companions ---- */
+let BEARS=[], CATS=[], DOGS=[], MOTHS=[], PIGEONS=null;
+function makeAnimals(){
+  BEARS = [
+    {x:center.x-150,y:center.top+2,s:1.1,ph:R(0,TAU)},
+    {x:center.x+158,y:center.top+2,s:1.0,ph:R(0,TAU)},
+    {x:escBuild.x+20,y:escBuild.top+2,s:.95,ph:R(0,TAU)},
+  ];
+  // cats leap between ledges: each has a start ledge & end ledge and a phase
+  const ledges=[[roofById('westTower').x, roofById('westTower').top],
+                [escBuild.x-escBuild.w/2+10, escBuild.top],
+                [center.x-ROSE.R-40, center.top],
+                [center.x+ROSE.R+40, center.top],
+                [roofById('eastTower').x, roofById('eastTower').top]];
+  CATS=[];
+  for(let i=0;i<4;i++){
+    const p0=ledges[i%ledges.length], p1=ledges[(i+1)%ledges.length];
+    CATS.push({p0,p1,ph:R(0,TAU),sp:R(.4,.7)});
+  }
+  // dogs weave among the circle (sine path across plaza)
+  DOGS=[];
+  for(let i=0;i<3;i++) DOGS.push({ph:R(0,TAU),sp:R(.5,.8),amp:R(26,40),col:i});
+  // moths cloud around each lantern
+  MOTHS=[];
+  LANTERNS.forEach((L,li)=>{ for(let k=0;k<6;k++) MOTHS.push({L:li,a:R(0,TAU),r:R(6,20),sp:R(1.4,2.6)*(rnd()>.5?1:-1),ph:R(0,TAU)}); });
+  // pigeons: a flock that periodically launches into a rising spiral
+  PIGEONS={ home:[escBuild.x+40, escBuild.top-4], birds:Array.from({length:14},(_,i)=>({a:R(0,TAU),r0:R(2,18),ph:R(0,TAU),i})), cycle:R(0,8) };
+}
+makeAnimals();
+
+/* rebuild population-scaled entities on density change */
+function rebuildDensity(){
+  const d=S.density;
+  CIRCLE = makeCircle(Math.round(20*d));
+  FREE   = makeFreestyle(Math.max(2,Math.round(6*d)));
+  PROC   = makeProcession(Math.max(3,Math.round(9*d)));
+  // couples & animals stay (small, structural)
+}
+
+/* ============================ CAMERA ============================ */
+const CAM_PRESETS = {
+  skyline:  { scale:0.60, cx:820, cy:150, tilt:0.02, name:'Skyline' },
+  rooftop:  { scale:1.34, cx:840, cy:60,  tilt:0.12, name:'Rooftop' },
+  birdseye: { scale:0.78, cx:840, cy:90,  tilt:0.72, name:'Bird’s-eye' },
+};
+const cam = { scale:1.08, cx:840, cy:70, tilt:0.10 };
+let VW=0, VH=0, DPR=1, screenCx=0, screenCy=0;
+
+function project(wx,wy,depth){
+  depth = depth==null?1:depth;
+  const s = cam.scale;
+  const sx = screenCx + (wx - cam.cx) * s * depth;
+  const sy = screenCy + (wy - cam.cy) * s * (1 - 0.5*cam.tilt);
+  return [sx, sy];
+}
+function pscale(depth){ return cam.scale * (depth==null?1:depth); }
+
+/* ============================ RESIZE ============================ */
+function resize(){
+  DPR = Math.min(window.devicePixelRatio||1, 2);
+  VW = window.innerWidth; VH = window.innerHeight;
+  canvas.width = Math.round(VW*DPR); canvas.height = Math.round(VH*DPR);
+  canvas.style.width=VW+'px'; canvas.style.height=VH+'px';
+  ctx.setTransform(DPR,0,0,DPR,0,0);
+  screenCx = VW*0.5;
+  screenCy = VH*0.62;
+}
+window.addEventListener('resize', resize);
+
+/* ============================ DRAW: SKY & BACKDROP ============================ */
+function drawSky(){
+  const g=ctx.createLinearGradient(0,0,0,VH);
+  g.addColorStop(0, rgb(P.skyTop));
+  g.addColorStop(0.52, rgb(P.skyMid));
+  g.addColorStop(1, rgb(P.skyBot));
+  ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
+
+  // stars (fade in for midnight)
+  if(P.star>0.02){
+    ctx.save(); ctx.globalAlpha=P.star;
+    const sr=mulberry32(7);
+    for(let i=0;i<120;i++){
+      const x=sr()*VW, y=sr()*VH*0.55, tw=0.4+0.6*Math.abs(Math.sin(S.t*0.6+i));
+      ctx.globalAlpha=P.star*tw*0.9;
+      ctx.fillStyle= i%9===0? rgb(P.accent):'#fff';
+      const s=sr()*1.6+0.3; ctx.fillRect(x,y,s,s);
+    }
+    ctx.restore();
+  }
+
+  // sun / moon — a soft luminous orb near the horizon
+  const orbX = screenCx + (890 - cam.cx)*cam.scale*0.4;
+  const orbY = VH*P.sunY;
+  const orbR = Math.max(48, VH*0.14)*(0.8+0.2*cam.scale);
+  const halo=ctx.createRadialGradient(orbX,orbY,orbR*0.2,orbX,orbY,orbR*3.4);
+  halo.addColorStop(0, rgb(P.orb,0.9));
+  halo.addColorStop(0.18, rgb(P.orb,0.34));
+  halo.addColorStop(1, rgb(P.orb,0));
+  ctx.fillStyle=halo; ctx.fillRect(0,0,VW,VH);
+  ctx.beginPath(); ctx.arc(orbX,orbY,orbR,0,TAU);
+  ctx.fillStyle=rgb(P.orb,0.96); ctx.fill();
+
+  // clouds drift (tinted by light)
+  const cloudCol = mix(P.skyMid,[255,255,255],0.35);
+  CLOUDS.forEach(c=>{
+    const [x,y]=project(c.x,c.y,0.5);
+    const s=c.s*cam.scale*0.5;
+    ctx.save(); ctx.globalAlpha=c.o*(0.7+0.3*(1-P.star));
+    ctx.fillStyle=rgb(cloudCol);
+    puff(x,y,s);
+    ctx.restore();
+  });
+}
+function puff(x,y,s){
+  ctx.beginPath();
+  ctx.ellipse(x,y,s,s*0.5,0,0,TAU);
+  ctx.ellipse(x-s*0.6,y+s*0.12,s*0.55,s*0.34,0,0,TAU);
+  ctx.ellipse(x+s*0.6,y+s*0.14,s*0.6,s*0.36,0,0,TAU);
+  ctx.fill();
+}
+
+function drawSkylineLayer(list, depth, colBase, alpha){
+  ctx.save(); ctx.globalAlpha=alpha; ctx.fillStyle=rgb(colBase);
+  list.forEach(b=>{
+    const [x,y]=project(b.x,b.top,depth);
+    const w=b.w*pscale(depth), h=(700)*pscale(depth);
+    ctx.fillRect(x,y,w,h);
+    // scattered lit windows
+    if(b.lit>0.3){
+      ctx.save(); ctx.globalAlpha=alpha*clamp(P.star+0.25,0.2,0.9);
+      ctx.fillStyle=rgb(P.lantern, 0.7);
+      const cols=Math.max(1,(w/12)|0), rows=Math.max(1,(h/16)|0);
+      const wr=mulberry32((b.x*7+b.top)|0);
+      for(let cc=0;cc<cols;cc++)for(let rr=0;rr<rows;rr++){
+        if(wr()>0.86){ ctx.fillRect(x+4+cc*12, y+6+rr*16, 4, 6); }
+      }
+      ctx.restore();
+    }
+  });
+  ctx.restore();
+}
+
+function drawCityGlow(){
+  // luminous band where the city meets the rooftops
+  const [,y]=project(0, 320, 1);
+  const g=ctx.createLinearGradient(0,y-60,0,y+120);
+  g.addColorStop(0, rgb(P.glow,0));
+  g.addColorStop(0.5, rgb(P.glow,0.18));
+  g.addColorStop(1, rgb(P.glow,0));
+  ctx.fillStyle=g; ctx.fillRect(0,y-60,VW,180);
+}
+
+/* ============================ DRAW: ROOFTOP ARCHITECTURE ============================ */
+function drawRoofs(){
+  ROOFS.forEach(r=>{
+    const half=r.w/2;
+    const [lx,ty]=project(r.x-half, r.top);
+    const [rx]=project(r.x+half, r.top);
+    const w=rx-lx;
+    const bottom=VH+40;
+    // building body
+    const bodyCol = mix(P.roof, P.city, 0.15);
+    ctx.fillStyle=rgb(bodyCol);
+    ctx.fillRect(lx, ty, w, bottom-ty);
+    // reflective window grid with a sweeping specular highlight
+    drawWindows(lx,ty,w,bottom-ty, r);
+    // roof cap (ellipse) — reads as looking-down when tilted
+    if(cam.tilt>0.05){
+      const capH = Math.max(4, w*0.16*cam.tilt);
+      ctx.beginPath();
+      ctx.ellipse(lx+w/2, ty, w/2, capH, 0, 0, TAU);
+      ctx.fillStyle=rgb(mix(P.roof,P.accent,0.12));
+      ctx.fill();
+      // edge light
+      ctx.strokeStyle=rgb(P.glow,0.35); ctx.lineWidth=1.5; ctx.stroke();
+    } else {
+      // parapet edge highlight
+      ctx.fillStyle=rgb(mix(P.roof,P.accent,0.22));
+      ctx.fillRect(lx, ty-3*cam.scale, w, 3*cam.scale);
+    }
+    // water tower on tower roofs
+    if(r.kind==='tower') drawWaterTower(r);
+    if(r.id==='proc') drawFireEscape(r);
+  });
+  // bridges
+  BRIDGES.forEach(b=>{
+    const [x0,y0]=project(b.x0,b.y), [x1,y1]=project(b.x1,b.y);
+    ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.3)); ctx.lineWidth=Math.max(4,7*cam.scale); ctx.lineCap='round';
+    ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
+    // rail lights
+    ctx.strokeStyle=rgb(P.glow,0.5); ctx.lineWidth=1.4;
+    ctx.beginPath(); ctx.moveTo(x0,y0-6*cam.scale); ctx.lineTo(x1,y1-6*cam.scale); ctx.stroke();
+  });
+  // laundry lines swaying
+  drawLaundry();
+  // lanterns (glow)
+  LANTERNS.forEach((L,i)=>drawLantern(L,i));
+}
+
+function drawWindows(x,y,w,h,r){
+  const cols=Math.max(2,(w/(16*cam.scale))|0);
+  const rows=Math.max(3,(h/(20))|0);
+  const cw=w/cols, ch=Math.min(26, (h/rows));
+  const wr=mulberry32((r.x*13+r.top*3)|0);
+  const sweep=((S.t*40 + r.x) % (w+220)) - 110; // moving specular
+  for(let c=0;c<cols;c++){
+    for(let rr=0;rr<rows;rr++){
+      const wx=x+c*cw+cw*0.18, wy=y+ch*0.5+rr*ch, ww=cw*0.64, wh=ch*0.6;
+      const lit = wr()>0.55;
+      // reflective glass base
+      const base = lit ? mix(P.glow,[255,255,255],0.15) : mix(P.city,[255,255,255],0.05);
+      ctx.fillStyle=rgb(base, lit?0.6:0.4);
+      ctx.fillRect(wx,wy,ww,wh);
+      // specular sweep highlight
+      const dx=(wx-x)-sweep;
+      if(dx>-30 && dx<30){
+        const a=(1-Math.abs(dx)/30)*0.5;
+        ctx.fillStyle=rgb(P.accent,a); ctx.fillRect(wx,wy,ww,wh);
+      }
+    }
+  }
+}
+
+function drawWaterTower(r){
+  const [cx,ty]=project(r.x, r.top);
+  const s=cam.scale;
+  const tw=34*s, th=40*s, legH=26*s;
+  const bx=cx, by=ty - (cam.tilt>0.05?4:0);
+  // legs
+  ctx.strokeStyle=rgb(mix(P.roof,P.city,0.2)); ctx.lineWidth=2*s;
+  ctx.beginPath();
+  ctx.moveTo(bx-tw*0.4, by); ctx.lineTo(bx-tw*0.3, by-legH);
+  ctx.moveTo(bx+tw*0.4, by); ctx.lineTo(bx+tw*0.3, by-legH);
+  ctx.stroke();
+  // tank
+  const tankY=by-legH;
+  ctx.fillStyle=rgb(mix(P.roof,P.accent,0.16));
+  ctx.beginPath();
+  ctx.moveTo(bx-tw/2, tankY);
+  ctx.lineTo(bx+tw/2, tankY);
+  ctx.lineTo(bx+tw/2, tankY-th);
+  ctx.lineTo(bx, tankY-th-tw*0.35);
+  ctx.lineTo(bx-tw/2, tankY-th);
+  ctx.closePath(); ctx.fill();
+  ctx.strokeStyle=rgb(P.glow,0.4); ctx.lineWidth=1; ctx.stroke();
+}
+
+function drawFireEscape(r){
+  const bx=r.x - r.w/2;
+  const [x0]=project(bx, r.top);
+  const s=cam.scale;
+  ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.35),0.85); ctx.lineWidth=1.4;
+  // zig-zag rails matching the procession path region
+  for(let i=1;i<PROC_PATH.length-2;i++){
+    const [ax,ay]=project(PROC_PATH[i-1][0],PROC_PATH[i-1][1]);
+    const [bx2,by2]=project(PROC_PATH[i][0],PROC_PATH[i][1]);
+    ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx2,by2); ctx.stroke();
+    // rungs
+    ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(ax-5*s,ay); ctx.stroke();
+  }
+}
+
+function drawLaundry(){
+  LAUNDRY.forEach((L,li)=>{
+    const [x0,y0]=project(L.x0,L.y0), [x1,y1]=project(L.x1,L.y1);
+    ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.4),0.5); ctx.lineWidth=1;
+    ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
+    for(let i=0;i<L.items;i++){
+      const t=(i+0.5)/L.items;
+      const px=lerp(x0,x1,t), py=lerp(y0,y1,t);
+      const sway=Math.sin(S.t*1.4 + i*1.1 + li)*0.28;
+      const w=8*cam.scale, h=13*cam.scale;
+      ctx.save(); ctx.translate(px,py); ctx.rotate(sway);
+      ctx.fillStyle=rgb(BODY_COLORS_RGB[(i+li)%BODY_COLORS_RGB.length], 0.62);
+      ctx.fillRect(-w/2,0,w,h);
+      ctx.restore();
+    }
+  });
+}
+const BODY_COLORS_RGB = BODY_COLORS.map(hx);
+
+function drawLantern(L,i){
+  const [x,y]=project(L.x,L.y);
+  const s=cam.scale;
+  const pulse=0.75+0.25*Math.sin(S.t*1.5+i);
+  const rad=14*s*pulse*(0.7+0.6*P.star);
+  const g=ctx.createRadialGradient(x,y,1,x,y,rad*2.6);
+  g.addColorStop(0, rgb(P.lantern, 0.9));
+  g.addColorStop(0.4, rgb(P.lantern, 0.32));
+  g.addColorStop(1, rgb(P.lantern, 0));
+  ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,rad*2.6,0,TAU); ctx.fill();
+  // post
+  ctx.strokeStyle=rgb(mix(P.roof,P.city,0.3)); ctx.lineWidth=1.5*s;
+  ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+16*s); ctx.stroke();
+  ctx.fillStyle=rgb(P.lantern); ctx.beginPath(); ctx.arc(x,y,3.4*s,0,TAU); ctx.fill();
+}
+
+/* ============================ FIGURES ============================ */
+// generic dancer. feet at (x,y). style: 'dance'|'walk'|'spin'
+function figure(x,y,sc,ph,col,style,lean,alpha){
+  const s=sc*cam.scale;
+  if(s<0.6) { // too small: dot
+    ctx.globalAlpha=(alpha==null?1:alpha); ctx.fillStyle=col;
+    ctx.beginPath(); ctx.arc(x,y-6*s,2.4*s,0,TAU); ctx.fill(); ctx.globalAlpha=1; return;
+  }
+  const bob = S.reduced?0:Math.sin(ph)*2*s;
+  const swing = S.reduced?0.25:Math.sin(ph);
+  lean = (lean||0);
+  ctx.save();
+  ctx.globalAlpha=(alpha==null?1:alpha);
+  ctx.translate(x, y-bob);
+  ctx.rotate(lean);
+  ctx.strokeStyle=col; ctx.lineCap='round'; ctx.lineJoin='round';
+  ctx.lineWidth=3.4*s;
+  const hip=-13*s, sh=-27*s, head=-33*s;
+  // legs
+  const legSp = style==='walk'? swing*6*s : Math.abs(swing)*4*s;
+  ctx.beginPath();
+  ctx.moveTo(0,hip); ctx.lineTo(-legSp*0.5, 0);
+  ctx.moveTo(0,hip); ctx.lineTo(legSp*0.5, 0);
+  ctx.stroke();
+  // torso
+  ctx.beginPath(); ctx.moveTo(0,hip); ctx.lineTo(0,sh); ctx.stroke();
+  // arms
+  ctx.beginPath();
+  if(style==='dance'||style==='spin'){
+    const a=0.7+0.5*swing;
+    ctx.moveTo(0,sh); ctx.lineTo(-11*s*a, sh-9*s*a);
+    ctx.moveTo(0,sh); ctx.lineTo(11*s*(1.2-a), sh-9*s*(1.2-a));
+  } else {
+    ctx.moveTo(0,sh); ctx.lineTo(-8*s, sh+8*s*swing);
+    ctx.moveTo(0,sh); ctx.lineTo(8*s, sh-8*s*swing);
+  }
+  ctx.stroke();
+  // head
+  ctx.fillStyle=col; ctx.beginPath(); ctx.arc(0,head,5*s,0,TAU); ctx.fill();
+  ctx.restore();
+  ctx.globalAlpha=1;
+}
+
+/* ---------- Rose light sculpture ---------- */
+function drawRose(){
+  const [x,y]=project(ROSE.x, ROSE.y);
+  const s=cam.scale;
+  const bloom = clamp((S.cfrac-0.4)/0.6, 0, 1); // fuller in later scenes
+  const rot = S.reduced?0.4:S.t*0.35;
+  const layers=5, petalsPer=8;
+  const baseR=ROSE.R*s*(0.42+0.28*bloom);
+  // ground glow
+  const g=ctx.createRadialGradient(x,y,2,x,y,baseR*2.6);
+  g.addColorStop(0, rgb(P.glow,0.85));
+  g.addColorStop(0.5, rgb(P.glow,0.28));
+  g.addColorStop(1, rgb(P.glow,0));
+  ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,baseR*2.6,0,TAU); ctx.fill();
+
+  ctx.save(); ctx.translate(x,y);
+  for(let l=0;l<layers;l++){
+    const lr = baseR*(0.35+ l*0.2);
+    const alpha = 0.5 - l*0.06;
+    const spin = rot*(l%2?-1:1)*(1+l*0.15);
+    for(let p=0;p<petalsPer;p++){
+      const a = spin + (p/petalsPer)*TAU + l*0.3;
+      const px=Math.cos(a)*lr*0.5, py=Math.sin(a)*lr*0.5;
+      ctx.save(); ctx.translate(px,py); ctx.rotate(a);
+      const petalCol = mix(P.glow, P.accent, l/layers);
+      ctx.fillStyle=rgb(petalCol, alpha*(0.7+0.3*bloom));
+      ctx.beginPath();
+      ctx.ellipse(lr*0.5,0, lr*0.6, lr*0.26, 0, 0, TAU);
+      ctx.fill();
+      ctx.restore();
+    }
+  }
+  // bright core
+  const core=ctx.createRadialGradient(0,0,1,0,0,baseR*0.6);
+  core.addColorStop(0,'#ffffff'); core.addColorStop(0.5,rgb(P.accent,0.9)); core.addColorStop(1,rgb(P.glow,0));
+  ctx.fillStyle=core; ctx.beginPath(); ctx.arc(0,0,baseR*0.6,0,TAU); ctx.fill();
+  ctx.restore();
+}
+
+/* ---------- draw the four choreography zones ---------- */
+function show(group){ return S.isolate==='all' || S.isolate===group; }
+function ghostAlpha(group){ return show(group)?1:0.12; }
+
+function drawCircle(){
+  const al=ghostAlpha('circle');
+  if(al<=0.12 && S.isolate!=='all'){} // still draw faint
+  const rot = S.reduced?0.3:S.t*0.32;
+  // sort by depth (back first) using sin of angle for tilt overlap
+  const list = CIRCLE.map(d=>{const a=d.a0+rot; return {d,a,depthY:Math.sin(a)};}).sort((p,q)=>p.depthY-q.depthY);
+  list.forEach(({d,a})=>{
+    const wx=ROSE.x+Math.cos(a)*d.r;
+    const tiltY = cam.tilt* Math.sin(a)* d.r*0.5;
+    const wy=ROSE.y - 4 + Math.sin(a)*d.r*0.14 + tiltY;
+    const [x,y]=project(wx,wy);
+    const lean = Math.cos(a)*0.18; // lean into the turn
+    figure(x,y,d.sz,S.t*3*S.tempo0+d.ph, d.col,'spin',lean, al);
+  });
+}
+
+function drawCouples(){
+  const al=ghostAlpha('bridges');
+  COUPLES.forEach(c=>{
+    const b=BRIDGES[c.b];
+    const tri = 0.5+0.5*Math.sin(S.t*0.7 + c.off*Math.PI); // 0..1 crossing
+    const wx=lerp(b.x0,b.x1, tri);
+    const wy=lerp(b.y0!=null?b.y0:b.y, b.y1!=null?b.y1:b.y, tri) - 8;
+    // partner mirrored across bridge centre
+    const midT = 0.5;
+    for(let m=0;m<2;m++){
+      const t = m===0? tri : (1-tri);
+      const px=lerp(b.x0,b.x1,t);
+      const py=b.y-9;
+      const [x,y]=project(px,py);
+      const lean=(m===0?1:-1)*0.14*Math.sin(S.t*2+c.ph);
+      figure(x,y,0.98,S.t*2.4*S.tempo0+c.ph+m*Math.PI, c.col,'dance',lean, al);
+    }
+  });
+}
+
+function drawFreestyle(){
+  const al=ghostAlpha('freestyle');
+  FREE.forEach(f=>{
+    const a=f.ph + S.t*f.sp*1.4;
+    const wx=f.cx+Math.cos(a)*f.rx;
+    const wy=f.cy+Math.sin(a*1.3)*f.ry - 6;
+    const [x,y]=project(wx,wy);
+    // trail
+    if(S.trails && show('freestyle') && !S.reduced){
+      f.trail.push([x,y]); if(f.trail.length>26) f.trail.shift();
+      ctx.lineCap='round';
+      for(let i=1;i<f.trail.length;i++){
+        const t=i/f.trail.length;
+        ctx.strokeStyle=rgb(hx(f.col), t*0.5*al);
+        ctx.lineWidth=(1+t*3)*cam.scale;
+        ctx.beginPath(); ctx.moveTo(f.trail[i-1][0],f.trail[i-1][1]); ctx.lineTo(f.trail[i][0],f.trail[i][1]); ctx.stroke();
+      }
+    } else if(!S.trails){ f.trail.length=0; }
+    const lean=Math.cos(a)*0.3;
+    figure(x,y,1.0,a*2.2, f.col,'dance',lean, al);
+  });
+}
+
+function drawProcession(){
+  const al=ghostAlpha('procession');
+  const flow = S.reduced?0: (S.t*0.02*S.tempo0);
+  PROC.forEach((p,i)=>{
+    let u=(p.u + flow) % 1;
+    const [wx,wy]=pathAt(u);
+    const [x,y]=project(wx,wy);
+    figure(x,y,0.92, S.t*3+p.ph, p.col,'walk', 0, al);
+  });
+}
+
+/* ============================ ANIMALS ============================ */
+function drawBears(){
+  BEARS.forEach(b=>{
+    const [x,y]=project(b.x,b.y); const s=b.s*cam.scale;
+    if(s<0.5) return;
+    const sway=S.reduced?0:Math.sin(S.t*1.1+b.ph)*0.10;
+    ctx.save(); ctx.translate(x,y); ctx.rotate(sway);
+    const col=mix(P.roof,[60,44,38],0.5);
+    ctx.fillStyle=rgb(col);
+    // body
+    ctx.beginPath(); ctx.ellipse(0,-13*s,13*s,16*s,0,0,TAU); ctx.fill();
+    // head
+    ctx.beginPath(); ctx.arc(0,-32*s,9*s,0,TAU); ctx.fill();
+    // ears
+    ctx.beginPath(); ctx.arc(-6*s,-39*s,3.4*s,0,TAU); ctx.arc(6*s,-39*s,3.4*s,0,TAU); ctx.fill();
+    // legs
+    ctx.fillRect(-10*s,-4*s,6*s,6*s); ctx.fillRect(4*s,-4*s,6*s,6*s);
+    ctx.restore();
+  });
+}
+function drawDogs(){
+  if(!show('circle') && S.isolate!=='all') return;
+  DOGS.forEach(d=>{
+    const t=S.t*d.sp;
+    const wx=ROSE.x + Math.sin(t)*(ROSE.R+40);
+    const wy=ROSE.y+8 + Math.cos(t*1.6)*d.amp*0.3;
+    const [x,y]=project(wx,wy); const s=cam.scale;
+    if(s<0.5) return;
+    const col=BODY_COLORS_RGB[(d.col+2)%BODY_COLORS_RGB.length];
+    ctx.save(); ctx.translate(x,y);
+    const dir=Math.cos(t)>=0?1:-1; ctx.scale(dir,1);
+    ctx.fillStyle=rgb(col);
+    ctx.beginPath(); ctx.ellipse(0,-6*s,9*s,4.6*s,0,0,TAU); ctx.fill();      // body
+    ctx.beginPath(); ctx.arc(8*s,-9*s,3.6*s,0,TAU); ctx.fill();             // head
+    ctx.fillRect(9*s,-12*s,4*s,2*s);                                        // snout
+    // legs
+    const lp=Math.sin(S.t*8*S.tempo0)*2*s;
+    ctx.fillRect(-6*s,-2*s,2*s,4*s+lp); ctx.fillRect(4*s,-2*s,2*s,4*s-lp);
+    // tail wag
+    ctx.strokeStyle=rgb(col); ctx.lineWidth=2*s; ctx.beginPath();
+    ctx.moveTo(-8*s,-8*s); ctx.lineTo(-13*s,-11*s+Math.sin(S.t*12)*2*s); ctx.stroke();
+    ctx.restore();
+  });
+}
+function drawCats(){
+  CATS.forEach(c=>{
+    const t=(Math.sin(S.t*c.sp+c.ph)*0.5+0.5); // 0..1 leap progress
+    const wx=lerp(c.p0[0],c.p1[0],t);
+    const arc=Math.sin(t*Math.PI)*34;
+    const wy=lerp(c.p0[1],c.p1[1],t)-arc-4;
+    const [x,y]=project(wx,wy); const s=cam.scale;
+    if(s<0.5) return;
+    const leaping=arc>6;
+    const col=mix(P.roof,[30,26,40],0.4);
+    ctx.save(); ctx.translate(x,y);
+    const dir=(c.p1[0]-c.p0[0])>=0?1:-1; ctx.scale(dir,1);
+    ctx.fillStyle=rgb(col);
+    if(leaping){
+      ctx.beginPath(); ctx.ellipse(0,0,9*s,3.2*s,-0.2,0,TAU); ctx.fill();
+    } else {
+      ctx.beginPath(); ctx.ellipse(0,-2*s,7*s,3.6*s,0,0,TAU); ctx.fill();
+    }
+    ctx.beginPath(); ctx.arc(7*s,-3*s,2.8*s,0,TAU); ctx.fill();            // head
+    // ears
+    ctx.beginPath(); ctx.moveTo(6*s,-5*s); ctx.lineTo(6.5*s,-8*s); ctx.lineTo(8*s,-5*s); ctx.fill();
+    // tail
+    ctx.strokeStyle=rgb(col); ctx.lineWidth=2*s; ctx.beginPath();
+    ctx.moveTo(-7*s,-2*s); ctx.quadraticCurveTo(-13*s,-6*s,-11*s,-11*s); ctx.stroke();
+    ctx.restore();
+  });
+}
+function drawPigeons(){
+  const t=S.t*0.5*S.tempo0 + PIGEONS.cycle;
+  const phase=(t%8)/8;                // 0..1 launch/rise/settle
+  const rise = phase<0.7? (phase/0.7): (1-(phase-0.7)/0.3);
+  const [hx0,hy0]=PIGEONS.home;
+  PIGEONS.birds.forEach(bd=>{
+    const spiral = t*2 + bd.a;
+    const r = bd.r0 + rise*70 + Math.sin(spiral)*6;
+    const wx = hx0 + Math.cos(spiral)*r*0.9;
+    const wy = hy0 - rise*150 + Math.sin(spiral)*r*0.3;
+    const [x,y]=project(wx,wy); const s=cam.scale;
+    const flap=Math.sin(S.t*14 + bd.ph);
+    ctx.save(); ctx.translate(x,y);
+    ctx.strokeStyle=rgb(mix(P.accent,[240,240,255],0.4), 0.9); ctx.lineWidth=1.6*s; ctx.lineCap='round';
+    const wsp=3.4*s*(0.4+0.6*Math.abs(flap));
+    ctx.beginPath();
+    ctx.moveTo(-wsp, -flap*2*s); ctx.lineTo(0,0); ctx.lineTo(wsp, -flap*2*s);
+    ctx.stroke();
+    ctx.restore();
+  });
+}
+function drawMoths(){
+  MOTHS.forEach(m=>{
+    const L=LANTERNS[m.L];
+    const a=m.a + S.t*m.sp;
+    const wx=L.x + Math.cos(a)*m.r + Math.sin(S.t*3+m.ph)*3;
+    const wy=L.y + Math.sin(a*1.3)*m.r*0.7 + Math.cos(S.t*3.4+m.ph)*3;
+    const [x,y]=project(wx,wy); const s=cam.scale;
+    const flap=Math.sin(S.t*18+m.ph);
+    ctx.save(); ctx.translate(x,y);
+    ctx.fillStyle=rgb(mix(P.lantern,[255,240,210],0.5), 0.85);
+    ctx.beginPath();
+    ctx.ellipse(-1.6*s, 0, 1.8*s, 3*s*Math.abs(flap)+0.6, -0.5,0,TAU);
+    ctx.ellipse( 1.6*s, 0, 1.8*s, 3*s*Math.abs(flap)+0.6,  0.5,0,TAU);
+    ctx.fill();
+    ctx.restore();
+  });
+}
+
+/* ============================ ROUTE DIAGRAMS ============================ */
+function dashedArc(x0,y0,x1,y1,bend,col,label){
+  const mx=(x0+x1)/2, my=(y0+y1)/2 - bend;
+  ctx.save();
+  ctx.setLineDash([6,7]); ctx.lineDashOffset=-S.t*20;
+  ctx.strokeStyle=col; ctx.lineWidth=1.6;
+  ctx.beginPath(); ctx.moveTo(x0,y0); ctx.quadraticCurveTo(mx,my,x1,y1); ctx.stroke();
+  ctx.setLineDash([]);
+  // node dots
+  ctx.fillStyle=col; ctx.beginPath(); ctx.arc(x0,y0,3,0,TAU); ctx.arc(x1,y1,3,0,TAU); ctx.fill();
+  if(label){
+    ctx.font='600 11px ui-sans-serif,system-ui,sans-serif';
+    ctx.fillStyle=rgb(P.accent,0.95);
+    ctx.textAlign='center';
+    ctx.fillText(label, mx, my-4);
+  }
+  ctx.restore();
+}
+function drawDiagrams(){
+  const c=rgb(P.accent,0.6);
+  // procession route
+  ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=-S.t*20;
+  ctx.strokeStyle=rgb(P.accent,0.55); ctx.lineWidth=1.6; ctx.beginPath();
+  for(let i=0;i<PROC_PATH.length;i++){const [x,y]=project(PROC_PATH[i][0],PROC_PATH[i][1]); i?ctx.lineTo(x,y):ctx.moveTo(x,y);} ctx.stroke();
+  ctx.setLineDash([]); ctx.restore();
+  const midP=pathAt(0.5); const [mpx,mpy]=project(midP[0],midP[1]);
+  labelTag('Ascent · procession', mpx, mpy-10);
+  // bridge crossings
+  BRIDGES.forEach((b,i)=>{
+    const [x0,y0]=project(b.x0,b.y-12),[x1,y1]=project(b.x1,b.y-12);
+    dashedArc(x0,y0,x1,y1,26,c, i===0?'Mirrored crossing':'Mirrored crossing');
+  });
+  // freestyle loops near towers
+  [roofById('westTower'),roofById('eastTower')].forEach(tw=>{
+    const [x,y]=project(tw.x, tw.top-16);
+    ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=-S.t*20;
+    ctx.strokeStyle=c; ctx.lineWidth=1.6;
+    ctx.beginPath(); ctx.ellipse(x,y, 42*cam.scale, 24*cam.scale, 0,0,TAU); ctx.stroke();
+    ctx.setLineDash([]); ctx.restore();
+    labelTag('Spiral · freestyle', x, y-30*cam.scale);
+  });
+  // circle
+  const [rx,ry]=project(ROSE.x,ROSE.y);
+  ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=S.t*20;
+  ctx.strokeStyle=c; ctx.lineWidth=1.6;
+  ctx.beginPath(); ctx.ellipse(rx,ry, ROSE.R*cam.scale, ROSE.R*cam.scale*(1-0.5*cam.tilt),0,0,TAU); ctx.stroke();
+  ctx.setLineDash([]); ctx.restore();
+  labelTag('Turning circle · the rose', rx, ry - (ROSE.R*cam.scale*(1-0.5*cam.tilt))-8);
+}
+function labelTag(text,x,y){
+  ctx.save();
+  ctx.font='600 11px ui-sans-serif,system-ui,sans-serif';
+  ctx.textAlign='center';
+  const w=ctx.measureText(text).width+12;
+  ctx.fillStyle='rgba(12,10,26,.72)';
+  roundRect(x-w/2,y-13,w,17,8); ctx.fill();
+  ctx.fillStyle=rgb(P.accent,0.98);
+  ctx.fillText(text,x,y-0.5);
+  ctx.restore();
+}
+function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
+
+/* ============================ NAME LABELS ============================ */
+function drawNames(){
+  const items=[
+    ['Bears', BEARS[0].x, BEARS[0].y-34],
+    ['Cats', CATS[0].p0[0], CATS[0].p0[1]-14],
+    ['Pigeons', PIGEONS.home[0]+30, PIGEONS.home[1]-70],
+    ['Dogs', ROSE.x, ROSE.y+22],
+    ['Moths', LANTERNS[0].x, LANTERNS[0].y-24],
+    ['The Rose', ROSE.x, ROSE.y-ROSE.R-4],
+    ['Water tower', roofById('eastTower').x, roofById('eastTower').top-46],
+  ];
+  items.forEach(([t,wx,wy])=>{ const [x,y]=project(wx,wy); labelTag(t,x,y); });
+}
+
+/* ============================ VIGNETTE ============================ */
+function drawVignette(){
+  const g=ctx.createRadialGradient(VW/2,VH*0.5,Math.min(VW,VH)*0.35, VW/2,VH*0.55, Math.max(VW,VH)*0.75);
+  g.addColorStop(0,'rgba(0,0,0,0)');
+  g.addColorStop(1, 'rgba(6,4,18,0.5)');
+  ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
+}
+
+/* ============================ MASTER RENDER ============================ */
+function render(){
+  drawSky();
+  drawSkylineLayer(FAR, 0.34, mix(P.city,P.skyTop,0.4), 0.7);
+  drawSkylineLayer(MID, 0.6,  mix(P.city,P.skyMid,0.22), 0.9);
+  drawCityGlow();
+  drawRoofs();
+  if(S.diagrams) drawDiagrams();
+  // depth order: procession (behind), bears, rose+circle+dogs, couples, freestyle, cats, pigeons, moths
+  drawProcession();
+  drawBears();
+  drawRose();
+  drawDogs();
+  drawCircle();
+  drawCouples();
+  drawFreestyle();
+  drawCats();
+  drawPigeons();
+  drawMoths();
+  drawVignette();
+  if(S.names) drawNames();
+}
+
+/* ============================ SCENE / PALETTE UPDATE ============================ */
+function lerpArrTo(cur,to,k){ for(let i=0;i<3;i++) cur[i]=lerp(cur[i],to[i],k); }
+function updatePalette(dt){
+  const to=SCENES[S.targetScene];
+  const k=clamp(dt*0.6,0,1);
+  lerpArrTo(P.skyTop,to.skyTop,k); lerpArrTo(P.skyMid,to.skyMid,k); lerpArrTo(P.skyBot,to.skyBot,k);
+  lerpArrTo(P.orb,to.orb,k); lerpArrTo(P.roof,to.roof,k); lerpArrTo(P.city,to.city,k);
+  lerpArrTo(P.glow,to.glow,k); lerpArrTo(P.accent,to.accent,k); lerpArrTo(P.lantern,to.lantern,k);
+  P.sunY=lerp(P.sunY,to.sunY,k); P.star=lerp(P.star,to.star,k);
+  S.cfrac=lerp(S.cfrac,to.complexity,k);
+}
+function updateCamera(dt){
+  const pr=CAM_PRESETS[S.cam];
+  const k=clamp(dt*3,0,1);
+  // gentle idle drift so parallax reads
+  const driftX = S.reduced?0: Math.sin(S.t*0.13)*22*(1.2-cam.tilt);
+  cam.scale=lerp(cam.scale,pr.scale,k);
+  cam.cx=lerp(cam.cx,pr.cx+driftX,k);
+  cam.cy=lerp(cam.cy,pr.cy,k);
+  cam.tilt=lerp(cam.tilt,pr.tilt,k);
+}
+
+/* ============================ LOOP ============================ */
+let last=performance.now();
+S.tempo0=1; // smoothed tempo used inside figure phase math
+function frame(now){
+  const rawdt=Math.min(0.05,(now-last)/1000); last=now;
+  if(!S.paused && !S.reduced){
+    S.tempo0=lerp(S.tempo0,S.tempo,0.1);
+    S.t += rawdt*S.tempo;
+    // scene drifting
+    if(S.autoScene){
+      S.sceneT += rawdt;
+      if(S.sceneT>SCENE_DUR){ S.sceneT=0; S.targetScene=(S.targetScene+1)%SCENES.length; syncSceneUI(); }
+    }
+  }
+  updatePalette(S.reduced?0.001:rawdt);
+  updateCamera(S.reduced?1:rawdt);
+  render();
+  if(!S.paused) rafId=requestAnimationFrame(frame);
+  else rafId=null;
+}
+let rafId=null;
+function start(){ if(!rafId){ last=performance.now(); rafId=requestAnimationFrame(frame);} }
+function stop(){ if(rafId){ cancelAnimationFrame(rafId); rafId=null; } }
+
+/* reduced-motion: draw a single composed still and stop the loop */
+function renderStill(){
+  stop();
+  // compose a calm "Long Light" frame
+  updatePalette(1); updateCamera(1);
+  render();
+}
+
+/* ============================ UI WIRING ============================ */
+const $=s=>document.querySelector(s);
+const sceneNameEl=$('#sceneName'), sceneDotEl=$('#sceneDot');
+
+function syncSceneUI(){
+  $('#sceneSel').value=String(S.targetScene);
+  sceneNameEl.textContent=SCENES[S.targetScene].name;
+  sceneDotEl.style.background=rgb(SCENES[S.targetScene].glow);
+}
+
+// camera segmented control
+$('#camSeg').addEventListener('click',e=>{
+  const b=e.target.closest('button[data-cam]'); if(!b) return;
+  S.cam=b.dataset.cam;
+  [...$('#camSeg').children].forEach(x=>x.setAttribute('aria-pressed', String(x===b)));
+  if(S.reduced) renderStill();
+});
+function setCam(name){
+  const b=$(`#camSeg button[data-cam="${name}"]`); if(b) b.click();
+}
+
+$('#sceneSel').addEventListener('change',e=>{
+  S.targetScene=+e.target.value; S.sceneT=0; syncSceneUI();
+  if(S.reduced) renderStill();
+});
+$('#autoScene').addEventListener('change',e=>{ S.autoScene=e.target.checked; });
+
+$('#tempo').addEventListener('input',e=>{ S.tempo=+e.target.value; $('#tempoVal').textContent=S.tempo.toFixed(2).replace(/0$/,'')+'×'; });
+$('#density').addEventListener('input',e=>{
+  S.density=+e.target.value;
+  $('#densVal').textContent = S.density<0.6?'Sparse':S.density<1.05?'Full':'Teeming';
+});
+$('#density').addEventListener('change',()=>{ rebuildDensity(); if(S.reduced) renderStill(); });
+
+$('#isolate').addEventListener('change',e=>{ S.isolate=e.target.value; if(S.reduced) renderStill(); });
+$('#tgNames').addEventListener('change',e=>{ S.names=e.target.checked; if(S.reduced) renderStill(); });
+$('#tgTrails').addEventListener('change',e=>{ S.trails=e.target.checked; });
+$('#tgDiag').addEventListener('change',e=>{ S.diagrams=e.target.checked; if(S.reduced) renderStill(); });
+
+function setPaused(p){
+  S.paused=p;
+  const b=$('#btnPause'); b.textContent=p?'Play':'Pause'; b.setAttribute('aria-pressed',String(p));
+  if(p) stop(); else if(!S.reduced) start();
+}
+$('#btnPause').addEventListener('click',()=>setPaused(!S.paused));
+$('#btnRestart').addEventListener('click',restart);
+function restart(){
+  rnd=mulberry32(0x50C1CE|0);
+  S.t=0; S.sceneT=0; S.targetScene=0; syncSceneUI();
+  rebuildDensity(); makeAnimals();
+  FREE.forEach(f=>f.trail.length=0);
+  if(S.reduced) renderStill(); else { setPaused(false); }
+}
+
+function setReduced(v){
+  S.reduced=v; $('#tgReduce').checked=v;
+  if(v){ renderStill(); }
+  else if(!S.paused){ start(); }
+}
+$('#tgReduce').addEventListener('change',e=>setReduced(e.target.checked));
+
+// panel show/hide
+const dock=$('#controls'), dockToggle=$('#dockToggle');
+function setDock(open){
+  dock.hidden=!open;
+  dockToggle.setAttribute('aria-expanded',String(open));
+  dockToggle.textContent=open?'Hide panel':'Controls';
+}
+dockToggle.addEventListener('click',()=>setDock(dock.hidden));
+
+// legend
+(function buildLegend(){
+  const items=[['Bears','#7a5a4a'],['Cats','#3a3450'],['Pigeons','#d8d8ff'],['Dogs','#6fbf9b'],['Moths','#ffce8a']];
+  $('#legend').innerHTML=items.map(([n,c])=>`<span class="tag"><i style="background:${c}"></i>${n}</span>`).join('');
+})();
+
+/* ---------- keyboard shortcuts ---------- */
+window.addEventListener('keydown',e=>{
+  if(e.target.matches('input,select,textarea')) { if(e.key!=='Escape') return; }
+  switch(e.key.toLowerCase()){
+    case ' ': e.preventDefault(); setPaused(!S.paused); break;
+    case '1': setCam('skyline'); break;
+    case '2': setCam('rooftop'); break;
+    case '3': setCam('birdseye'); break;
+    case 'r': restart(); break;
+    case 'n': $('#tgNames').checked=!S.names; S.names=!S.names; if(S.reduced)renderStill(); break;
+    case 't': $('#tgTrails').checked=!S.trails; S.trails=!S.trails; break;
+    case 'd': $('#tgDiag').checked=!S.diagrams; S.diagrams=!S.diagrams; if(S.reduced)renderStill(); break;
+    case 'm': setReduced(!S.reduced); break;
+    case 'c': setDock(dock.hidden); break;
+  }
+});
+
+/* ============================ BOOT ============================ */
+resize();
+syncSceneUI();
+$('#densVal').textContent='Full';
+// honor OS reduced-motion preference on load
+const prefersReduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+if(prefersReduce){ setReduced(true); }
+else { start(); }
+
+// keep still frame correct if the tab was hidden then shown
+document.addEventListener('visibilitychange',()=>{ if(!document.hidden && !S.paused && !S.reduced) start(); });
+
+})();
+</script>
+</body>
+</html>

(oldest)  ·  back to Rose City Rooftop Solstice  ·  Add optional live Grateful Dead music streamed from the Inte 632cd63 →