Compare commits

...

2 Commits

Author SHA1 Message Date
BenStullsBets df8b245af3 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>
2026-06-30 12:39:06 -07:00
BenStullsBets 539618b1b8 docs(spec): Playback Speed slider replaces Reduce motion toggle
Continuous -2x..2x speed control for the resting altitude loop video
(#vid-loop); manual rAF reverse loop below 0x; reduced-motion a11y
behavior preserved invisibly via OS prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:39:06 -07:00
8 changed files with 343 additions and 41 deletions
@@ -0,0 +1,133 @@
# Playback Speed slider (replaces "Reduce motion") — design
**Date:** 2026-06-30
**Status:** Approved (pending written-spec review)
**Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes.
## Summary
Add a continuous **Playback Speed** slider (2× … 2×) that controls the resting
**altitude loop video** (`#vid-loop`) in real time as the user drags it. Below
`0×` the loop plays **in reverse**. The slider **replaces** the "Reduce motion"
toggle in the Output panel — but the reduced-motion *accessibility behavior* is
preserved, now derived silently from the OS `prefers-reduced-motion` setting
rather than a visible toggle.
## What it affects (and what it does not)
- **Affects:** the resting **altitude loop video** `#vid-loop` — the clip that
loops while parked at an altitude. The slider sets its play speed/direction.
- **Does NOT affect:** the **morph / transition video** `#vid`. That element is
scrub-driven — the altitude dial sets its `currentTime` directly — so a
playback rate has no meaning there. Speed control never touches it.
## UI
Replace the `reduce-motion` checkbox `<label>` in `index.html` with a range
slider, styled like the existing Audio level control:
```
Speed [ −2 ········●········ 2 ] 1.37×
```
- `<input type="range" id="play-speed" min="-2" max="2" step="any" value="1">`
`step="any"` so dragging yields a **continuous** value, not 0.25 detents.
- A live numeric **readout** (`<span id="play-speed-val">`) shows the current
value formatted to 2 decimals with a `×` suffix (e.g. `1.37×`, `0.75×`,
`0.00×`).
- Reference **tick labels** at the nice increments (`2 1.75 … 0 … 2`) via a
`<datalist>` bound to the input, so the round numbers are visible without
quantizing the actual applied value.
- New i18n key `speed.label` ("Speed") in en/es/fr/ja. The `rm.label` key is
retired.
## Accessibility (preserved, now invisible)
The reduce-motion *toggle* is removed, but its *behavior* is kept. `isReduced()`
now derives **live from the OS setting only**
`matchMedia("(prefers-reduced-motion: reduce)")`, re-read on its `change` event —
with no stored override and no UI. Every existing `isReduced()` call site keeps
working unchanged:
- loop-play gate (`playLoop`),
- instant (no-tween) auto altitude transitions (`autoScrub`),
- the photosensitivity-flash luminance floor.
Consequences:
- The `hef.reduceMotion` localStorage key (`RM_KEY`) is **retired**.
- When OS reduced-motion is **active**: the loop stays frozen and the speed
slider is **disabled** (with a short hint), so a vestibular/photosensitive
visitor never gets motion they did not ask for. If the OS pref toggles at
runtime, the slider enables/disables to match.
- `warn.body` copy (all 4 langs) is reworded: it currently tells users to "turn
on Reduce motion in the panel," which no longer exists. New copy points to the
system reduced-motion setting (now auto-applied).
## Speed behavior (`app.js`)
A new `applyPlaySpeed(speed)` drives `#vid-loop`, with the chosen value persisted
to `localStorage["hef.playSpeed"]` (default `1`). Applied live on the slider's
`input` event:
- **speed > 0** → `loopVid.playbackRate = speed`; native forward play. The
existing duration→`loopStartFrame()` wrap is unchanged. Any active manual
reverse loop is cancelled.
- **|speed| ≈ 0** (within a small epsilon) → pause the element (freeze on the
current frame). Reversible by moving the slider off zero. Cancels any reverse
loop.
- **speed < 0** → native negative `playbackRate` is unreliable (Chrome ignores
it), so a `requestAnimationFrame` loop owns the element: the element is
**paused** and each frame decrements `loopVid.currentTime` by `|speed|·dt`.
When it crosses the bottom boundary it **wraps to the tail** (mirror of the
forward wrap), reusing the direction-aware landing offset. The loop is
cancelled when speed goes ≥ 0, on reduced-motion freeze, when video output is
turned off, or when a new loop clip loads.
Interaction with existing motion:
- The manual reverse loop and the **auto-transition tween** (`autoRaf`, which
drives the *morph* `#vid`) are independent — different elements, different
rAF handles. The reverse loop only owns `#vid-loop`.
- On landing on a new altitude (`loadLoop` + `playLoop`), the current slider
speed is re-applied so the new clip inherits the chosen rate/direction.
## Test seam + tests
Mirrors the existing `scrub.js` / `node --test` pattern:
- A **pure** helper added to `scrub.js`:
`reverseStep(t, absSpeed, dt, loopStart, duration) → { next, wrapped }`
— computes the next `currentTime` and whether a bottom-boundary wrap occurred.
This is the reverse-loop math with no DOM; `app.js` just applies `next` to
`loopVid.currentTime`. Covered by a new `node --test` case (steps, wrap at the
boundary, clamping).
- A **Playwright** e2e added to the existing suite: the slider is present;
forward value sets `#vid-loop.playbackRate`; `0×` pauses it; a negative value
makes `currentTime` decrease over time. Also assert the slider is disabled
when `prefers-reduced-motion` is emulated.
## Known risk (noted, not blocking)
Reverse smoothness depends on the **base loop clips'** keyframe density. The
morphs are all-intra (smooth reverse), but the base altitude clips may not be;
on some browsers reverse seeking could stutter. The manual loop stays *correct*
regardless — smoothness is a visual-polish follow-up (re-encode base clips
all-intra if needed), not a blocker for this change.
## Files touched
- `simulator/static/index.html` — swap toggle → slider + datalist; reword warn.
- `simulator/static/app.js``applyPlaySpeed`, reverse rAF loop, derive
`isReduced()` from OS pref, retire `RM_KEY`, re-apply speed on landing.
- `simulator/static/scrub.js` — add pure `reverseStep` helper.
- `simulator/static/i18n.js` — add `speed.label`, reword `warn.body` (en/es/fr/ja),
retire `rm.label`.
- `simulator/static/styles.css` — slider styling (reuse audio-level pattern).
- Tests: `node --test` for `reverseStep`; Playwright e2e for the slider.
## Out of scope
- Re-encoding base clips for smoother reverse (follow-up if stutter is visible).
- Any speed control over morph/transition videos.
- Per-altitude or audio speed coupling (audio level is its own control).
+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
});
});
+89 -24
View File
@@ -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);
+2 -2
View File
@@ -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 devices “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: "宇宙" },
+12 -4
View File
@@ -54,11 +54,19 @@
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
<label class="dev-switch" for="reduce-motion">
<input type="checkbox" id="reduce-motion" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="play-speed" min="-2" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="play-speed-val" />
<span id="play-speed-val">1.00×</span>
</label>
<datalist id="play-speed-ticks">
<option value="-2"></option><option value="-1.75"></option><option value="-1.5"></option>
<option value="-1.25"></option><option value="-1"></option><option value="-0.75"></option>
<option value="-0.5"></option><option value="-0.25"></option><option value="0"></option>
<option value="0.25"></option><option value="0.5"></option><option value="0.75"></option>
<option value="1"></option><option value="1.25"></option><option value="1.5"></option>
<option value="1.75"></option><option value="2"></option>
</datalist>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>
+18 -1
View File
@@ -41,6 +41,23 @@
return dir < 0 ? 0 : loopTailS;
}
// One frame of REVERSE loop playback for the resting altitude video. Native
// negative playbackRate is unreliable (Chrome ignores it), so app.js drives
// currentTime by hand; this is the pure math. The visible loop region is
// [loopStart, duration] (same frames the forward loop shows). Step back by
// absSpeed*dt; if it crosses below loopStart, wrap around to the tail
// (duration), carrying the overshoot modulo the region length so a big dt
// can't escape the region. Returns the next currentTime and whether a
// bottom-boundary wrap happened (so the caller can re-arm its loop state).
function reverseStep(t, absSpeed, dt, loopStart, duration) {
const L = duration - loopStart;
if (!(L > 0)) return { next: duration, wrapped: false };
const next = t - Math.abs(absSpeed) * dt;
if (next >= loopStart) return { next, wrapped: false };
const overshoot = (loopStart - next) % L;
return { next: duration - overshoot, wrapped: true };
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
@@ -53,5 +70,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, reverseStep, integerCrossings };
});
+4
View File
@@ -164,6 +164,10 @@ input[type=range], select { width: 100%; }
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.audio-level input[type=range] { flex: 1; width: auto; }
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
/* Playback speed (2×…2×) reuses the audio-level row look; wider value field for the sign + decimals. */
#play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; }
.play-speed input[type=range]:disabled { opacity: 0.4; cursor: not-allowed; }
.play-speed:has(input:disabled) { opacity: 0.6; }
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
win, so the panel truly collapses when Dev Mode is off. */
+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)