feat(speed): Playback Speed slider replaces Reduce motion toggle

Continuous -2x..2x slider drives ONLY the resting altitude loop video
(#vid-loop); the morph/transition video is scrub-driven and untouched.
Forward = native playbackRate; ~0x = freeze; below 0x = a manual rAF
reverse loop (native negative playbackRate is unreliable). Reverse math
is the pure, node-tested HEFScrub.reverseStep helper.

Reduced-motion accessibility is preserved but now derives live from the
OS prefers-reduced-motion setting (no visible toggle): when active the
loop stays frozen and the slider is disabled. Retires hef.reduceMotion.
i18n: speed.label (en/es/fr/ja); warn.body reworded to point at the OS
setting.

Tests: +4 node unit cases for reverseStep (14/14 green); a11y e2e Task 5
rewritten for the slider (13/13 green); static-build e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 11:19:48 -07:00
parent 539618b1b8
commit df8b245af3
7 changed files with 210 additions and 41 deletions
+60 -10
View File
@@ -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
});
});