diff --git a/simulator/e2e/tests/a11y.spec.ts b/simulator/e2e/tests/a11y.spec.ts index add613c..37384d5 100644 --- a/simulator/e2e/tests/a11y.spec.ts +++ b/simulator/e2e/tests/a11y.spec.ts @@ -41,23 +41,73 @@ test.describe("Task 4 — Photosensitivity warning gate", () => { }); }); -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("/"); +test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () => { + // Helper: start the experience so #vid-loop is playing. + async function startExperience(page: import("@playwright/test").Page) { 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(); + .toBe(false); // experience running → loop plays + } + + test("the Reduce motion toggle is gone, replaced by a speed slider", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/"); + await expect(page.locator("#reduce-motion")).toHaveCount(0); + const slider = page.locator("#play-speed"); + await expect(slider).toBeVisible(); + await expect(slider).toHaveValue("1"); // default = normal speed + }); + + test("forward speed sets the loop video's playbackRate", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/"); + await startExperience(page); + await page.locator("#play-speed").fill("2"); + await expect + .poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate)) + .toBe(2); + }); + + test("0x freezes the loop video", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/"); + await startExperience(page); + await page.locator("#play-speed").fill("0"); await expect .poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)) - .toBe(true); // reduced motion → frozen + .toBe(true); + }); + + test("negative speed plays the loop in reverse (manual frame-stepping)", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/"); + await startExperience(page); + await page.locator("#play-speed").fill("-1.5"); + await page.waitForTimeout(150); + const a = await page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.currentTime); + const paused = await page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused); + await page.waitForTimeout(250); + const b = await page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.currentTime); + // The reverse loop pauses the element and drives currentTime by hand — so the + // element reads paused, yet currentTime keeps moving (≠ forward play, ≠ freeze). + // (It may wrap loopStart→tail, so we assert movement, not a strict decrease.) + expect(paused).toBe(true); + expect(b).not.toBe(a); + }); + + test("OS reduced-motion disables the slider and freezes the loop", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await page.locator("#motion-warning-continue").click().catch(() => {}); + await page.locator("#run-sim").click(); + await page.waitForTimeout(500); + await expect(page.locator("#play-speed")).toBeDisabled(); + await expect + .poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)) + .toBe(true); // OS prefers-reduced-motion → frozen regardless of slider }); }); diff --git a/simulator/static/app.js b/simulator/static/app.js index 3fbb19a..8204688 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -313,7 +313,7 @@ function loadLoop(clip, landFrame) { function playLoop() { if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop - if (loopVid.paused) loopVid.play().catch(() => {}); + applyPlaySpeed(); // honor the chosen speed/direction (forward, freeze, or reverse) } function ensureClipMedia() { @@ -1120,39 +1120,104 @@ function onDialKey(e) { // 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"; +const SPEED_KEY = "hef.playSpeed"; 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"); +// --- Playback speed + reduced motion ----------------------------------------- +// "Reduce motion" is no longer a visible toggle — its accessibility behavior is +// derived LIVE from the OS `prefers-reduced-motion` setting (no persisted +// override). The visible control is the Playback Speed slider, which drives ONLY +// the resting altitude loop video (#vid-loop); the morph/transition video (#vid) +// is scrub-driven and is never speed-controlled. Below 0x the loop plays in +// reverse via a manual rAF loop (native negative playbackRate is unreliable — +// Chrome ignores it). When the OS asks for reduced motion the loop stays frozen +// and the slider is disabled, so a vestibular/photosensitive visitor never gets +// motion they did not ask for. +const SPEED_EPS = 0.02; // |speed| below this = freeze (treat as 0) +let playSpeed = 1; +let revRaf = 0; // rAF handle for the manual reverse loop +let revLast = 0; // last rAF timestamp (ms) for dt +const reduceMq = (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)")) || null; +function isReduced() { return !!(reduceMq && reduceMq.matches); } + +function stopReverse() { + if (revRaf) { cancelAnimationFrame(revRaf); revRaf = 0; } +} +// One reverse-loop frame: step #vid-loop's currentTime backward via the pure +// HEFScrub.reverseStep math, then schedule the next. dt is capped so a stalled +// tab can't make a giant jump. +function reverseTick(now) { + if (!revRaf) return; + const dt = revLast ? Math.min((now - revLast) / 1000, 0.1) : 0; + revLast = now; + const dur = loopVid.duration; + if (Number.isFinite(dur) && dur > 0 && dt > 0) { + const r = HEFScrub.reverseStep(loopVid.currentTime, Math.abs(playSpeed), dt, loopStartFrame(), dur); + try { loopVid.currentTime = r.next; } catch (_) { /* not seekable yet */ } + } + revRaf = requestAnimationFrame(reverseTick); +} +function startReverse() { + if (revRaf) return; + loopVid.pause(); // the rAF owns the element while reversing + revLast = 0; + revRaf = requestAnimationFrame(reverseTick); +} +// Apply the chosen slider speed to the resting loop video. When reduced motion +// holds or video output is off, the freeze/play decision belongs to +// syncReducedMotion / playLoop — here we only make sure no reverse loop is left +// running and the forward rate is sane for when it resumes. +function applyPlaySpeed() { + stopReverse(); + if (isReduced() || !videoEverOn || !$("visual").checked) { + loopVid.playbackRate = Math.max(0.0625, Math.abs(playSpeed) || 1); + return; + } + if (playSpeed > SPEED_EPS) { + loopVid.playbackRate = playSpeed; + if (loopVid.paused) loopVid.play().catch(() => {}); + } else if (playSpeed < -SPEED_EPS) { + startReverse(); + } else { + loopVid.pause(); // ~0x → freeze on the current frame + } +} +function updateSpeedLabel() { + const el = $("play-speed-val"); + if (el) el.textContent = playSpeed.toFixed(2).replace("-", "−") + "×"; +} +function initPlaySpeed() { + const slider = $("play-speed"); 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(); + try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {} + playSpeed = stored === null ? 1 : (parseFloat(stored) || 0); + if (slider) { + slider.value = String(playSpeed); + slider.addEventListener("input", () => { + playSpeed = parseFloat(slider.value) || 0; + try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {} + updateSpeedLabel(); + applyPlaySpeed(); }); } - applyReduceMotion(); + updateSpeedLabel(); + syncReducedMotion(); // initial enable/disable + freeze/play state + if (reduceMq && reduceMq.addEventListener) reduceMq.addEventListener("change", syncReducedMotion); } -function applyReduceMotion() { - if (reduceMotion) { +// Reconcile to the OS prefers-reduced-motion setting: when reduced, freeze both +// videos and disable the speed slider; otherwise (re)apply the chosen speed. +function syncReducedMotion() { + const slider = $("play-speed"); + const reduced = isReduced(); + if (slider) slider.disabled = reduced; + if (reduced) { + stopReverse(); if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; } vid.pause(); loopVid.pause(); } else if (videoEverOn && $("visual").checked) { - playLoop(); // resume the held loop on opt-out + applyPlaySpeed(); // resume the held loop at the chosen speed } } @@ -1523,7 +1588,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) + initPlaySpeed(); // playback-speed slider + OS-derived reduced motion renderScaleReadout(); // Sliders stream on "input"; the toggles fire on "change". for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced); diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index 497e276..b0ea524 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -31,9 +31,9 @@ "knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" }, "knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" }, "devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" }, - "rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" }, + "speed.label": { en: "Speed", es: "Velocidad", fr: "Vitesse", ja: "再生速度" }, "warn.title": { en: "Heads up — motion & flashing" }, - "warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin." }, + "warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on your device’s “Reduce motion” setting before you begin — the experience honours it automatically." }, "warn.continue": { en: "Continue" }, "about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" }, "scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index 4ad8c95..7340e2d 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -54,11 +54,19 @@ 0/10 -