From 717bf5b08becd66f94796629071f2ff1c83b8b24 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 09:24:00 -0700 Subject: [PATCH] feat(a11y): reduced-motion freeze-to-stills with OS-default toggle Single guard in playLoop() holds a still frame; autoScrub jumps instantly; applyReduceMotion pauses/resumes. Default from prefers-reduced-motion, then the choice persists. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/a11y.spec.ts | 20 +++++++++++++++ simulator/static/app.js | 43 +++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/simulator/e2e/tests/a11y.spec.ts b/simulator/e2e/tests/a11y.spec.ts index 11654b2..d247ec0 100644 --- a/simulator/e2e/tests/a11y.spec.ts +++ b/simulator/e2e/tests/a11y.spec.ts @@ -40,3 +40,23 @@ test.describe("Task 4 — Photosensitivity warning gate", () => { await expect(page.locator("#run-sim")).toBeVisible(); }); }); + +test.describe("Task 5 — Reduced motion", () => { + test("reduce-motion toggle pauses video playback", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off + await page.goto("/"); + await page.locator("#motion-warning-continue").click().catch(() => {}); + const rm = page.locator("#reduce-motion"); + await expect(rm).not.toBeChecked(); // default-off under no-preference + await page.locator("#run-sim").click(); + await page.waitForTimeout(500); + await expect + .poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)) + .toBe(false); // experience running → video plays + await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label + await expect(rm).toBeChecked(); + await expect + .poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)) + .toBe(true); // reduced motion → frozen + }); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 3fdc87a..ded8267 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -258,6 +258,7 @@ function loadLoop(clip) { } function playLoop() { + if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop if (loopVid.paused) loopVid.play().catch(() => {}); } @@ -677,6 +678,11 @@ const PER_ALTITUDE_MS = 1200; let autoRaf = 0; function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) { if (!ring || ring.scales.length < 2) return; + if (isReduced()) { // reduced motion: no autonomous tween — jump to target + lock + if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; } + setPos(targetPos); + return; + } if (autoRaf) cancelAnimationFrame(autoRaf); const fromPos = pos, dist = targetPos - fromPos; if (!dist) { setPos(targetPos); return; } @@ -847,7 +853,8 @@ function setPos(next) { busy = false; delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base) currentClipId = null; // force ensureClipMedia to reload + loop the locked clip - playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload + if (!isReduced()) playLoop(); // reduced motion holds the landing frame; else run the base loop + else { vid.pause(); loopVid.pause(); } showActiveSource(); setNeedle(ringIndex * dialStep()); renderScaleReadout(); @@ -917,9 +924,42 @@ function jumpToScale(idx) { // cache) — no server endpoints. Editing labels stays in /author.html. const DEV_KEY = "hef.devMode"; const WARN_KEY = "hef.motionWarnDismissed"; +const RM_KEY = "hef.reduceMotion"; let devMode = false; let devStatsTimer = null; +// --- Reduced motion (freeze-to-stills) --------------------------------------- +// Default follows the OS `prefers-reduced-motion` until the visitor chooses, then +// their choice persists. When ON: video holds a frame (paused), auto transitions +// jump instantly instead of tweening — knob changes still re-grade the still. +let reduceMotion = false; +function isReduced() { return reduceMotion; } +function initReduceMotion() { + const box = $("reduce-motion"); + let stored = null; + try { stored = localStorage.getItem(RM_KEY); } catch (_) {} + const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + reduceMotion = stored === null ? !!prefers : stored === "1"; + if (box) { + box.checked = reduceMotion; + box.addEventListener("change", () => { + reduceMotion = box.checked; + try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {} + applyReduceMotion(); + }); + } + applyReduceMotion(); +} +function applyReduceMotion() { + if (reduceMotion) { + if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; } + vid.pause(); + loopVid.pause(); + } else if (videoEverOn && $("visual").checked) { + playLoop(); // resume the held loop on opt-out + } +} + // One-time photosensitivity notice shown over the stage before the experience // begins. Independent of media load; gated visually by the loading splash, then // dismissed-once (persisted) so returning visitors go straight to "Run simulation". @@ -1284,6 +1324,7 @@ async function main() { buildDial(); // draw the altitude knob from the ring's scales initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state) initLanguage(); // populate the language dropdown + wire live switching + initReduceMotion(); // reduced-motion state + toggle (default from OS pref) renderScaleReadout(); // Sliders stream on "input"; the toggles fire on "change". for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);