[object Object]

← back to Afterlight Exhibition

Add per-object motion studies (in-page Canvas, $0) — era-toned, reduced-motion-safe, high-contrast aware

637567cb6445cde8243f543df4d34edd6091667e · 2026-09-01 07:18:48 -0700 · Steve Abrams

Replaces the paid Seedance/AI-video idea with local HyperFrames-style in-page motion:
five bespoke Canvas renderers (dust shaft, migrating data points, breathing focus
rings, a stopped clock, a shifting hue-wall), IntersectionObserver-gated, frozen on
reduced-motion, recolored on high-contrast. Keeps the single-file integrity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 637567cb6445cde8243f543df4d34edd6091667e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 07:18:48 2026 -0700

    Add per-object motion studies (in-page Canvas, $0) — era-toned, reduced-motion-safe, high-contrast aware
    
    Replaces the paid Seedance/AI-video idea with local HyperFrames-style in-page motion:
    five bespoke Canvas renderers (dust shaft, migrating data points, breathing focus
    rings, a stopped clock, a shifting hue-wall), IntersectionObserver-gated, frozen on
    reduced-motion, recolored on high-contrast. Keeps the single-file integrity.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 README.md  |   3 +-
 index.html | 198 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 200 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 8b7d4fd..1b60590 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,8 @@ Open `index.html` in any browser. No build step, no dependencies.
 - Reading-progress indicator
 - Reduced-motion behavior (honors OS setting; manual toggle)
 - High-contrast toggle (pure #000/#fff, persisted)
-- Graceful media fallback — transcript is the primary artifact; a player appears only where a recording exists
+- Per-object **motion study** — a live, in-page Canvas composition in the object's era hue (no video files, no paid generation); freezes under reduced-motion
+- Graceful audio fallback — transcript is the primary artifact; a player appears only where a recording exists
 - Reading position is saved and restorable — **fully keyboard operable**
 - 390px: preserves narrative order and comparison utility (stacks, never reorders/hides)
 
diff --git a/index.html b/index.html
index e286673..a97a3ed 100644
--- a/index.html
+++ b/index.html
@@ -271,6 +271,33 @@
     color: var(--ink);
   }
 
+  /* Motion study — a live, in-page canvas composition per object.
+     No video files, no paid generation; restrained era-tinted motion. */
+  .motion {
+    margin: 2.2rem 0 0; max-width: var(--maxw);
+    position: relative;
+    background: #000;
+    border: 1px solid var(--line-soft);
+    border-top: 2px solid var(--accent);
+    overflow: hidden;
+  }
+  .motion canvas { display: block; width: 100%; aspect-ratio: 16 / 7; }
+  .motion figcaption {
+    position: absolute; left: .8rem; bottom: .6rem;
+    font-size: .64rem; letter-spacing: .2em; text-transform: uppercase;
+    color: var(--ink-dim);
+    text-shadow: 0 1px 4px #000, 0 0 2px #000;
+    pointer-events: none;
+  }
+  .motion .paused-note {
+    position: absolute; right: .8rem; bottom: .6rem;
+    font-size: .62rem; letter-spacing: .14em; text-transform: uppercase;
+    color: var(--ink-faint);
+    text-shadow: 0 1px 4px #000; display: none;
+  }
+  html[data-motion="reduce"] .motion .paused-note { display: block; }
+  html[data-contrast="high"] .motion { border: 1px solid #fff; border-top: 2px solid #fff; }
+
   /* Provenance / conservation as editorial fields, not boxed cards */
   .fields {
     margin: 2.8rem 0 0;
@@ -603,12 +630,14 @@
     // The control toggles REDUCED motion; pressed = reduced.
     b.setAttribute("aria-pressed", String(!motionOn));
     b.textContent = motionOn ? "Motion" : "Motion ⏸";
+    if (window.__motionStudioSync) window.__motionStudioSync();
   }
   function applyContrast() {
     root.setAttribute("data-contrast", contrastHigh ? "high" : "normal");
     const b = $("#btnContrast");
     b.setAttribute("aria-pressed", String(contrastHigh));
     b.textContent = contrastHigh ? "Contrast ●" : "Contrast";
+    if (window.__motionStudioSync) window.__motionStudioSync();
   }
   applyMotion();
   applyContrast();
@@ -621,6 +650,14 @@
       <p class="index-tag reveal"><span class="dot"></span> Object ${o.id} of ${OBJECTS.length}</p>
       <p class="year reveal" aria-hidden="true">${o.year}</p>
       <h2 id="obj-${o.id}-title" class="reveal">${o.title}<span class="vh"> (${o.year})</span></h2>
+
+      <figure class="motion reveal" data-obj="${o.id}">
+        <canvas class="motion-canvas" role="img"
+                aria-label="Motion study evoking ${o.title}, ${o.year} — restrained, era-toned."></canvas>
+        <figcaption>Motion Study · ${o.year}</figcaption>
+        <span class="paused-note">Motion paused</span>
+      </figure>
+
       <p class="curator reveal">${o.curator}</p>
 
       <div class="fields reveal">
@@ -879,6 +916,167 @@
     gotoObject(next);
   }
 
+  /* ---------------- MOTION STUDIES (in-page canvas, $0) ---------------- */
+  const MotionStudio = (function () {
+    const ACCENT = { 1:[216,162,74], 2:[90,162,224], 3:[70,192,176], 4:[169,139,216], 5:[143,208,106] };
+    const studies = [];
+    let raf = null, startT = 0;
+
+    function col(s, a) {
+      const hi = root.getAttribute("data-contrast") === "high";
+      const [r,g,b] = hi ? [255,255,255] : s.rgb;
+      return `rgba(${r},${g},${b},${a})`;
+    }
+    const wrap = (v, m) => ((v % m) + m) % m;
+
+    /* ---- renderers: restrained, slow, deterministic in t (seconds) ---- */
+    // 1928 — Echo Bench: dust rising through an amber shaft of light.
+    function echo(ctx, w, h, t, s) {
+      ctx.fillStyle = "#070604"; ctx.fillRect(0,0,w,h);
+      const g = ctx.createLinearGradient(w*0.1,0, w*0.55,h);
+      g.addColorStop(0, col(s,0.10)); g.addColorStop(1, col(s,0));
+      ctx.fillStyle = g;
+      ctx.beginPath(); ctx.moveTo(w*0.12,0); ctx.lineTo(w*0.42,0);
+      ctx.lineTo(w*0.66,h); ctx.lineTo(w*0.30,h); ctx.closePath(); ctx.fill();
+      if (!s.dust) s.dust = Array.from({length:46}, (_,i) => ({
+        x: Math.random()*w, y0: Math.random()*h,
+        r: 0.4 + Math.random()*1.3, sp: 5 + Math.random()*12,
+        ph: Math.random()*6.28, sw: 0.4 + Math.random()*0.8
+      }));
+      s.dust.forEach(p => {
+        const y = wrap(p.y0 - t*p.sp, h);
+        const x = p.x + Math.sin(t*0.3 + p.ph) * 6 * p.sw;
+        const tw = 0.35 + 0.65*Math.abs(Math.sin(t*0.8 + p.ph));
+        ctx.beginPath(); ctx.arc(x, y, p.r, 0, 6.2832);
+        ctx.fillStyle = col(s, 0.55*tw); ctx.fill();
+      });
+      // faint carved initials, static
+      ctx.strokeStyle = col(s, 0.06); ctx.lineWidth = 1.4;
+      for (let i=0;i<7;i++){ const bx = w*(0.2+i*0.09), by = h*0.86;
+        ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(bx+6,by-10); ctx.lineTo(bx+12,by); ctx.stroke(); }
+    }
+
+    // 2003 — Data Veil: signal-blue points migrating along a woven grid.
+    function veil(ctx, w, h, t, s) {
+      ctx.fillStyle = "#03060a"; ctx.fillRect(0,0,w,h);
+      const N = 22;
+      ctx.strokeStyle = col(s, 0.05); ctx.lineWidth = 1;
+      for (let i=0;i<N;i++){ const x = (i+0.5)/N*w;
+        ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
+      if (!s.pts) s.pts = Array.from({length:26}, () => ({
+        col: Math.floor(Math.random()*N), ph: Math.random(),
+        sp: 0.04 + Math.random()*0.10, dir: Math.random()<0.5?1:-1
+      }));
+      s.pts.forEach(p => {
+        const x = (p.col+0.5)/N*w;
+        const y = wrap((p.dir*t*p.sp + p.ph), 1) * h;
+        const rg = ctx.createRadialGradient(x,y,0, x,y,9);
+        rg.addColorStop(0, col(s,0.9)); rg.addColorStop(1, col(s,0));
+        ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(x,y,9,0,6.2832); ctx.fill();
+      });
+    }
+
+    // 2015 — Memory Lens: focus rings breathing around a blur that never resolves.
+    function lens(ctx, w, h, t, s) {
+      ctx.fillStyle = "#03080a"; ctx.fillRect(0,0,w,h);
+      const cx = w*0.5, cy = h*0.5, maxR = Math.min(w,h)*0.62;
+      const core = ctx.createRadialGradient(cx,cy,0, cx,cy, maxR*0.5);
+      core.addColorStop(0, col(s, 0.28 + 0.06*Math.sin(t*0.7)));
+      core.addColorStop(1, col(s, 0));
+      ctx.fillStyle = core; ctx.fillRect(0,0,w,h);
+      for (let k=0;k<4;k++){
+        const ph = wrap(t*0.22 + k/4, 1);
+        const r = ph*maxR, a = (1-ph)*0.38;
+        ctx.strokeStyle = col(s, a); ctx.lineWidth = 1.5;
+        ctx.beginPath(); ctx.arc(cx,cy,r,0,6.2832); ctx.stroke();
+      }
+    }
+
+    // 2022 — Last-Breath Clock: hands fixed at 4:00, a single slow particle.
+    function clock(ctx, w, h, t, s) {
+      ctx.fillStyle = "#06040a"; ctx.fillRect(0,0,w,h);
+      const cx = w*0.5, cy = h*0.5, R = Math.min(w,h)*0.34;
+      ctx.strokeStyle = col(s, 0.14); ctx.lineWidth = 1.4;
+      ctx.beginPath(); ctx.arc(cx,cy,R,0,6.2832); ctx.stroke();
+      for (let i=0;i<12;i++){ const a = i/12*6.2832;
+        const x1 = cx+Math.cos(a)*R*0.9, y1 = cy+Math.sin(a)*R*0.9;
+        const x2 = cx+Math.cos(a)*R, y2 = cy+Math.sin(a)*R;
+        ctx.strokeStyle = col(s, 0.12); ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); }
+      // motionless hands: hour to 4 (120° from 12), minute to 12
+      const hourA = -Math.PI/2 + (4/12)*6.2832, minA = -Math.PI/2;
+      ctx.strokeStyle = col(s, 0.55); ctx.lineWidth = 2.4;
+      ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx+Math.cos(hourA)*R*0.5, cy+Math.sin(hourA)*R*0.5); ctx.stroke();
+      ctx.lineWidth = 1.6;
+      ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx+Math.cos(minA)*R*0.78, cy+Math.sin(minA)*R*0.78); ctx.stroke();
+      // barely-perceptible rim breath
+      ctx.strokeStyle = col(s, 0.06 + 0.06*Math.sin(t*0.5)); ctx.lineWidth = 3;
+      ctx.beginPath(); ctx.arc(cx,cy,R*1.06,0,6.2832); ctx.stroke();
+      // one drifting dust particle
+      const px = wrap(t*0.045, 1)*w, py = cy + Math.sin(t*0.4)*7;
+      ctx.fillStyle = col(s, 0.5); ctx.beginPath(); ctx.arc(px,py,1.3,0,6.2832); ctx.fill();
+    }
+
+    // 2040 — Future Room: a wall of cells slowly shifting hue.
+    function room(ctx, w, h, t, s) {
+      ctx.fillStyle = "#04070a"; ctx.fillRect(0,0,w,h);
+      const cols = 12, rows = 5, cw = w/cols, ch = h/rows;
+      for (let y=0;y<rows;y++) for (let x=0;x<cols;x++){
+        const v = 0.5 + 0.25*Math.sin(x*0.6 + t*0.5) + 0.25*Math.sin(y*0.9 - t*0.35 + x*0.2);
+        ctx.fillStyle = col(s, Math.max(0, v)*0.5);
+        ctx.fillRect(x*cw, y*ch, cw+1, ch+1);
+      }
+    }
+
+    const RENDER = { 1:echo, 2:veil, 3:lens, 4:clock, 5:room };
+
+    function size(s) {
+      const rect = s.canvas.getBoundingClientRect();
+      if (rect.width < 2) return;
+      const dpr = Math.min(2, window.devicePixelRatio || 1);
+      s.w = rect.width; s.h = rect.height;
+      s.canvas.width = Math.round(rect.width*dpr);
+      s.canvas.height = Math.round(rect.height*dpr);
+      s.ctx.setTransform(dpr,0,0,dpr,0,0);
+    }
+    function draw(s, t) { if (s.w) s.render(s.ctx, s.w, s.h, t, s); }
+
+    function frame(now) {
+      const t = (now - startT)/1000;
+      for (const s of studies) if (s.inView) draw(s, t);
+      raf = requestAnimationFrame(frame);
+    }
+    function start() { if (!raf) { startT = performance.now(); raf = requestAnimationFrame(frame); } }
+    function stop() { if (raf) { cancelAnimationFrame(raf); raf = null; } }
+
+    function sync() {
+      const on = root.getAttribute("data-motion") === "animate";
+      if (on) start();
+      else { stop(); studies.forEach(s => draw(s, 2.4)); } // static representative frame
+    }
+
+    function init() {
+      $$(".motion").forEach(fig => {
+        const id = +fig.dataset.obj;
+        const canvas = $(".motion-canvas", fig);
+        const s = { canvas, ctx: canvas.getContext("2d"), render: RENDER[id],
+                    rgb: ACCENT[id], w: 0, h: 0, inView: false };
+        studies.push(s); size(s); draw(s, 2.4);
+      });
+      const io = new IntersectionObserver(ents => {
+        ents.forEach(e => { const s = studies.find(x => x.canvas.closest(".motion") === e.target);
+          if (s) s.inView = e.isIntersecting; });
+      }, { threshold: 0.01 });
+      $$(".motion").forEach(f => io.observe(f));
+      window.addEventListener("resize", () => { studies.forEach(size); if (root.getAttribute("data-motion")!=="animate") studies.forEach(s=>draw(s,2.4)); });
+      sync();
+    }
+    return { init, sync };
+  })();
+
+  MotionStudio.init();
+  window.__motionStudioSync = MotionStudio.sync;
+  MotionStudio.sync();
+
   // Save an initial position marker so Resume is available on revisit
   savePosition();
 })();

← 9295b9d Afterlight exhibition: rebuild + extend Qwen3 14B artifact i  ·  back to Afterlight Exhibition  ·  Wire Seedance motion clips per object (video primary, Canvas 590b749 →