feat(welcome): reduce-motion toggle for morph transitions; decouple loop from it

Per operator: Speed only controls the resting loop, so reduce-motion is still
needed to turn off the morph/transition videos. Re-add a visible 'Reduce motion'
toggle on BOTH the welcome screen and the panel (mirrored, default from OS
prefers-reduced-motion, remembers the choice). When ON, altitude changes are
instant cuts instead of an animated morph tween (autoScrub's reduced branch);
the resting loop is unaffected — it follows the Speed slider (0x freezes it).

Decouples the loop from reduce-motion (was: main froze both videos + disabled
the slider under OS-reduced). playLoop()/landing/applyPlaySpeed no longer check
isReduced(); isReduced() now only gates the morph tween. Welcome controls are
now Language / Speed / Audio / Reduce motion. i18n: restore rm.label (4 langs),
reword warn.body to point at the on-screen control.

Tests: a11y Task 5 reworked (speed independent of reduce-motion; new instant-cut
behavior test); welcome defaults assert reduce-motion off. node 35, e2e 32,
static 2, pytest 301 (3 pre-existing ffmpeg/cv2 env fails).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 13:27:18 -07:00
parent c176ae7231
commit 5f9ae6b212
5 changed files with 77 additions and 47 deletions
+21 -8
View File
@@ -48,12 +48,11 @@ 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 04x speed slider", async ({ page }) => {
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
test("Speed is a 04x forward slider; the loop follows it (default 1x)", 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
@@ -81,14 +80,28 @@ test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () =>
.toBe(true);
});
test("OS reduced-motion disables the slider and freezes the loop", async ({ page }) => {
test("Reduce motion defaults from the OS setting and does NOT disable Speed", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
// The welcome toggle reflects the OS preference before launch.
await expect(page.locator("#welcome-reduce-motion")).toBeChecked();
await enter(page);
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
await expect(page.locator("#reduce-motion")).toBeChecked(); // panel mirrors it
await expect(page.locator("#play-speed")).toBeEnabled(); // Speed is independent of reduce-motion
});
test("Reduce motion turns altitude changes into instant cuts (no morph tween)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
await page.locator('label[for="reduce-motion"]').click(); // hidden checkbox → toggle via label
await expect(page.locator("#reduce-motion")).toBeChecked();
const start = (await page.locator("#scale-name").textContent())!;
const t0 = await page.evaluate(() => performance.now());
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 }); // descend one altitude
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 5000 });
const dt = (await page.evaluate(() => performance.now())) - t0;
expect(dt).toBeLessThan(600); // instant cut, not the ~1200ms animated morph tween
});
});
+2 -1
View File
@@ -25,12 +25,13 @@ test("welcome screen shows on load with the expected defaults; the panel is hidd
await expect(page.locator("#welcome")).toBeVisible();
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
// Defaults: Language English, Speed 1, Audio 2.
// Defaults: Language English, Speed 1, Audio 2, Reduce motion off (no OS pref).
await expect(page.locator("#welcome-lang")).toHaveValue("en");
await expect(page.locator("#welcome-play-speed")).toHaveValue("1");
await expect(page.locator("#welcome-play-speed-val")).toHaveText("1.00×");
await expect(page.locator("#welcome-audio")).toHaveValue("2");
await expect(page.locator("#welcome-audio-val")).toHaveText("2");
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
// Heads-up + Launch button present; "Loading Universe" progress present.
await expect(page.locator("#welcome-title")).toBeVisible();
+41 -36
View File
@@ -312,8 +312,7 @@ function loadLoop(clip, landFrame) {
}
function playLoop() {
if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop
applyPlaySpeed(); // honor the chosen speed/direction (forward, freeze, or reverse)
applyPlaySpeed(); // honor the chosen speed (forward, or 0x = freeze) — independent of reduce-motion
}
function ensureClipMedia() {
@@ -1023,8 +1022,7 @@ function setPos(next) {
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
if (!isReduced()) playLoop(); // reduced motion holds the landing frame; else run the base loop
else { vid.pause(); loopVid.pause(); }
playLoop(); // run the resting loop at the chosen Speed (0x freezes it) — independent of reduce-motion
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
@@ -1120,31 +1118,22 @@ function onDialKey(e) {
// cache) — no server endpoints. Editing labels stays in /author.html.
const DEV_KEY = "hef.devMode";
const SPEED_KEY = "hef.playSpeed";
const RM_KEY = "hef.reduceMotion";
let devMode = false;
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 (0x4x 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.
// --- Playback speed -----------------------------------------------------------
// The Playback Speed slider (0x4x forward) 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 was
// dropped: base loop clips aren't all-intra, so reverse seeking jitters.)
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
let playSpeed = 1;
const reduceMq = (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)")) || null;
function isReduced() { return !!(reduceMq && reduceMq.matches); }
// 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 keep the forward rate sane for
// when it resumes.
// Apply the chosen slider speed to the resting loop video. When video output is
// off we just keep the forward rate sane for when it resumes.
function applyPlaySpeed() {
if (isReduced() || !videoEverOn || !$("visual").checked) {
if (!videoEverOn || !$("visual").checked) {
loopVid.playbackRate = Math.max(0.0625, playSpeed || 1);
return;
}
@@ -1183,20 +1172,35 @@ function initPlaySpeed() {
});
}
updateSpeedLabel();
syncReducedMotion(); // initial enable/disable + freeze/play state
if (reduceMq && reduceMq.addEventListener) reduceMq.addEventListener("change", syncReducedMotion);
}
// 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 reduced = isReduced();
for (const id of SPEED_SLIDERS) { const s = $(id); if (s) s.disabled = reduced; }
if (reduced) {
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
vid.pause();
loopVid.pause();
} else if (videoEverOn && $("visual").checked) {
applyPlaySpeed(); // resume the held loop at the chosen speed
// --- Reduce motion (morph transitions) ----------------------------------------
// A visible toggle (welcome + panel, mirrored) that turns OFF the morph/transition
// videos: when ON, altitude changes are instant cuts instead of an animated morph
// tween (see autoScrub's reduced branch). It does NOT touch the resting loop —
// that is the Speed slider's job (Speed 0 freezes the loop independently).
// Defaults from the OS `prefers-reduced-motion` setting, then remembers the
// visitor's choice.
let reduceMotion = false;
function isReduced() { return reduceMotion; }
const RM_BOXES = ["welcome-reduce-motion", "reduce-motion"];
function syncReduceMotionBoxes() {
for (const id of RM_BOXES) { const b = $(id); if (b && b.checked !== reduceMotion) b.checked = reduceMotion; }
}
function initReduceMotion() {
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";
syncReduceMotionBoxes();
for (const id of RM_BOXES) {
const box = $(id);
if (!box) continue;
box.addEventListener("change", () => {
reduceMotion = box.checked; // governs FUTURE transitions (cut vs morph)
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
syncReduceMotionBoxes();
});
}
}
@@ -1587,7 +1591,8 @@ 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 both language dropdowns + wire live switching
initPlaySpeed(); // playback-speed sliders (welcome + panel) + OS-derived reduced motion
initPlaySpeed(); // playback-speed sliders (welcome + panel)
initReduceMotion(); // reduce-motion toggles (welcome + panel) — morph transitions off
initWelcome(); // wire the welcome-screen controls + Launch button
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
+2 -1
View File
@@ -32,8 +32,9 @@
"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: "開発モード" },
"speed.label": { en: "Speed", es: "Velocidad", fr: "Vitesse", ja: "再生速度" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"warn.title": { en: "Heads up — motion & flashing", es: "Atención — movimiento y destellos", fr: "Attention — mouvement et flashs", ja: "ご注意 — 動きと点滅" },
"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 launch — the experience honours it automatically.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa el ajuste «Reducir movimiento» de tu dispositivo antes de iniciar — la experiencia lo respeta automáticamente.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez le réglage « Réduire les animations » de votre appareil avant de lancer — lexpérience le respecte automatiquement.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前にデバイスの「視差効果を減らす」設定をオンにしてください — 体験は自動的にそれに従います。" },
"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” below before you launch — altitude changes will cut instantly instead of animating.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa «Reducir movimiento» abajo antes de iniciar — los cambios de altitud serán cortes instantáneos en vez de animarse.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez « Réduire les animations » ci-dessous avant de lancer — les changements daltitude seront des coupes instantanées au lieu de sanimer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください — 高度の変化はアニメーションせず瞬時に切り替わります。" },
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
+11 -1
View File
@@ -15,7 +15,7 @@
<div class="welcome-inner">
<div class="welcome-message">
<h2 id="welcome-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">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 launch — the experience honours it automatically.</p>
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.</p>
</div>
<div class="welcome-controls">
<label class="lang-pick">🌐
@@ -30,6 +30,11 @@
<input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" />
<span id="welcome-audio-val">2</span>/10
</label>
<label class="dev-switch" for="welcome-reduce-motion">
<input type="checkbox" id="welcome-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>
</div>
<button type="button" id="welcome-launch" class="run-sim" data-i18n="welcome.launch">Launch Simulator</button>
<div class="welcome-loading">
@@ -81,6 +86,11 @@
<option value="1.5"></option><option value="2"></option><option value="2.5"></option>
<option value="3"></option><option value="3.5"></option><option value="4"></option>
</datalist>
<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>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>