diff --git a/docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md b/docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md index 5b87d50..f3d2d05 100644 --- a/docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md +++ b/docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md @@ -1,9 +1,18 @@ # Playback Speed slider (replaces "Reduce motion") — design **Date:** 2026-06-30 -**Status:** Approved (pending written-spec review) +**Status:** Shipped, then revised **Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes. +> **Revision (2026-06-30, post-eyeball):** Reverse playback was **dropped**. In a +> real browser the below-0× reverse loop jittered — the base loop clips aren't +> all-intra, so backward seeking can't stay smooth (exactly the risk flagged +> below). The slider is now **0×–4× forward** (still continuous, `step="any"`). +> `0×` still freezes; OS reduced-motion behavior is unchanged. The +> `HEFScrub.reverseStep` helper and its tests were removed. Sections below +> describing the −2…2 range and the manual reverse loop are superseded by this +> note. + ## Summary Add a continuous **Playback Speed** slider (−2× … 2×) that controls the resting diff --git a/simulator/e2e/tests/a11y.spec.ts b/simulator/e2e/tests/a11y.spec.ts index 533e5f7..5293488 100644 --- a/simulator/e2e/tests/a11y.spec.ts +++ b/simulator/e2e/tests/a11y.spec.ts @@ -49,24 +49,26 @@ test.describe("Task 4 — Photosensitivity notice", () => { }); test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () => { - test("the Reduce motion toggle is gone, replaced by a speed slider", async ({ page }) => { + test("the Reduce motion toggle is gone, replaced by a 0–4x speed slider", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.goto("/"); await enter(page); 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 + await expect(slider).toHaveValue("1"); // default = normal speed + await expect(slider).toHaveAttribute("min", "0"); + await expect(slider).toHaveAttribute("max", "4"); }); - test("forward speed sets the loop video's playbackRate", async ({ page }) => { + test("forward speed sets the loop video's playbackRate (up to 4x)", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.goto("/"); await enter(page); - await page.locator("#play-speed").fill("2"); + await page.locator("#play-speed").fill("4"); await expect .poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate)) - .toBe(2); + .toBe(4); }); test("0x freezes the loop video", async ({ page }) => { @@ -79,23 +81,6 @@ test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () => .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 enter(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("/"); diff --git a/simulator/static/app.js b/simulator/static/app.js index f353e5d..646e701 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1126,60 +1126,33 @@ let devStatsTimer = null; // --- 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) +// override). The visible control is the Playback Speed slider (0x–4x forward), +// which drives ONLY the resting altitude loop video (#vid-loop); the +// morph/transition video (#vid) is scrub-driven and is never speed-controlled. +// 0x freezes the loop on its current frame. (Reverse playback was dropped: base +// loop clips aren't all-intra, so native/manual reverse seeking jitters.) 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. +// syncReducedMotion / playLoop — here we only keep the forward rate sane for +// when it resumes. function applyPlaySpeed() { - stopReverse(); if (isReduced() || !videoEverOn || !$("visual").checked) { - loopVid.playbackRate = Math.max(0.0625, Math.abs(playSpeed) || 1); + loopVid.playbackRate = Math.max(0.0625, 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 + loopVid.pause(); // 0x → freeze on the current frame } } // Both the welcome screen's slider and the panel's drive the same playSpeed; each @@ -1190,7 +1163,7 @@ function syncSpeedSliders() { for (const id of SPEED_SLIDERS) { const s = $(id); if (s && s.value !== String(playSpeed)) s.value = String(playSpeed); } } function updateSpeedLabel() { - const txt = playSpeed.toFixed(2).replace("-", "−") + "×"; + const txt = playSpeed.toFixed(2) + "×"; for (const id of SPEED_VALS) { const el = $(id); if (el) el.textContent = txt; } } function initPlaySpeed() { @@ -1219,7 +1192,6 @@ function syncReducedMotion() { const reduced = isReduced(); for (const id of SPEED_SLIDERS) { const s = $(id); if (s) s.disabled = reduced; } if (reduced) { - stopReverse(); if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; } vid.pause(); loopVid.pause(); diff --git a/simulator/static/index.html b/simulator/static/index.html index 406d9a4..03b0bdf 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -22,7 +22,7 @@ @@ -72,17 +72,14 @@ 0/10 - - - - - - + + +