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 37384d5..6647ba6 100644
--- a/simulator/e2e/tests/a11y.spec.ts
+++ b/simulator/e2e/tests/a11y.spec.ts
@@ -52,23 +52,25 @@ test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () =>
.toBe(false); // experience running → loop plays
}
- 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 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 startExperience(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 }) => {
@@ -81,23 +83,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 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("/");
diff --git a/simulator/static/app.js b/simulator/static/app.js
index 8204688..943a870 100644
--- a/simulator/static/app.js
+++ b/simulator/static/app.js
@@ -1127,65 +1127,38 @@ 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
}
}
function updateSpeedLabel() {
const el = $("play-speed-val");
- if (el) el.textContent = playSpeed.toFixed(2).replace("-", "−") + "×";
+ if (el) el.textContent = playSpeed.toFixed(2) + "×";
}
function initPlaySpeed() {
const slider = $("play-speed");
@@ -1212,7 +1185,6 @@ function syncReducedMotion() {
const reduced = isReduced();
if (slider) slider.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 7340e2d..a795034 100644
--- a/simulator/static/index.html
+++ b/simulator/static/index.html
@@ -55,17 +55,14 @@
0/10