From 5f9ae6b21287faedff91399c750ea0e5281257f6 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 13:27:18 -0700 Subject: [PATCH] feat(welcome): reduce-motion toggle for morph transitions; decouple loop from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- simulator/e2e/tests/a11y.spec.ts | 29 ++++++++--- simulator/e2e/tests/welcome.spec.ts | 3 +- simulator/static/app.js | 77 +++++++++++++++-------------- simulator/static/i18n.js | 3 +- simulator/static/index.html | 12 ++++- 5 files changed, 77 insertions(+), 47 deletions(-) diff --git a/simulator/e2e/tests/a11y.spec.ts b/simulator/e2e/tests/a11y.spec.ts index 5293488..1fc0a18 100644 --- a/simulator/e2e/tests/a11y.spec.ts +++ b/simulator/e2e/tests/a11y.spec.ts @@ -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 0–4x speed slider", async ({ page }) => { +test.describe("Task 5 — Playback speed slider + Reduce motion", () => { + test("Speed is a 0–4x 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 }); }); diff --git a/simulator/e2e/tests/welcome.spec.ts b/simulator/e2e/tests/welcome.spec.ts index 172aa63..6e67e6a 100644 --- a/simulator/e2e/tests/welcome.spec.ts +++ b/simulator/e2e/tests/welcome.spec.ts @@ -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(); diff --git a/simulator/static/app.js b/simulator/static/app.js index 646e701..e47af7b 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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 (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. +// --- Playback speed ----------------------------------------------------------- +// The Playback Speed slider (0x–4x 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". diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index c131e86..06e01a0 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -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 device’s “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 — l’expé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 d’altitude seront des coupes instantanées au lieu de s’animer.", 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: "軌道" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index 03b0bdf..220ff8a 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -15,7 +15,7 @@

Heads up — motion & flashing

-

This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on your device’s “Reduce motion” setting before you launch — the experience honours it automatically.

+

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.

+
@@ -81,6 +86,11 @@ +