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
+25
View File
@@ -59,6 +59,31 @@ test("integerCrossings: multiple crossings in travel order", () => {
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
});
test("reverseStep: decrements within the loop region without wrapping", () => {
// region [loopStart=3, duration=8]; step back absSpeed*dt = 0.5
assert.deepEqual(S.reverseStep(5, 1, 0.5, 3, 8), { next: 4.5, wrapped: false });
// landing exactly on the bottom boundary is the region edge, not a wrap
assert.deepEqual(S.reverseStep(3.5, 1, 0.5, 3, 8), { next: 3, wrapped: false });
});
test("reverseStep: wraps past the bottom boundary back to the tail (duration)", () => {
// 3.2 - 0.5 = 2.7 < 3 -> overshoot 0.3 -> 8 - 0.3 = 7.7
const r = S.reverseStep(3.2, 1, 0.5, 3, 8);
assert.equal(r.wrapped, true);
assert.ok(Math.abs(r.next - 7.7) < 1e-9, `next=${r.next}`);
});
test("reverseStep: overshoot larger than the region length wraps modulo", () => {
// region length L = 5; 3 - 7 = -4 -> overshoot 7 -> 7 % 5 = 2 -> 8 - 2 = 6
const r = S.reverseStep(3, 1, 7, 3, 8);
assert.equal(r.wrapped, true);
assert.ok(Math.abs(r.next - 6) < 1e-9, `next=${r.next}`);
});
test("reverseStep: degenerate region (duration <= loopStart) holds at duration", () => {
assert.deepEqual(S.reverseStep(3, 1, 0.5, 3, 3), { next: 3, wrapped: false });
});
test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => {
// A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands
// on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0)