← 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 →