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
-