diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index ff37c10..7c0ed4a 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -249,4 +249,74 @@ run("navigation motion (headless Chrome)", () => { await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); expect(await exists(".lr-menu")).toBe(false); }, 40_000); + + test("prefers-reduced-motion: no view transition, animation or transition anywhere, and everything still works", async () => { + await setReducedMotion(true); + const still = async (what: string) => { + await chrome.eval(afterFrame); + await Bun.sleep(250); + const r = await m(); + expect([what, r.vt, r.anims, r.trans, await running()]).toEqual([what, 0, [], [], []]); + await reset(); + }; + try { + // library to course + await open("/", 'a[href="/c/sample"]'); + await click('a[href="/c/sample"]'); + await until(() => exists(".lr-main > article h1"), "course page"); + await still("library to course"); + + // course to lesson, then Next: the old lesson is still held while the next loads, then swapped plainly + await click('.lr-les[href="/c/sample/m1-intro"]'); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Introduction", "intro"); + await still("course to lesson"); + await chrome.eval("window.scrollTo(0, 900)"); + await chrome.eval("window.__delay = 400"); + await click(".stc-dock .stc-pill--primary"); + await Bun.sleep(120); + expect(await text(".lr-main .lr-lesson h1.title")).toBe("Introduction"); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Watch the clip", "next lesson"); + await chrome.eval("window.__delay = 0"); + expect(await chrome.eval("scrollY")).toBe(0); + await still("lesson next"); + + // module open + await chrome.eval(`[...document.querySelectorAll(".lr-mod-head")][1].click(), true`); + await still("module open"); + + // fold: straight from closed to open + const heights = await chrome.eval(`(() => { + const d = document.querySelector("details.fold.transcript"); + d.querySelector("summary").click(); + const out = [], t0 = performance.now(); + return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (performance.now() - t0 < 300) requestAnimationFrame(f); else r(out); }; f(); }); + })()`); + expect(new Set(heights).size).toBe(1); + await still("fold"); + + // slide change: flips without animating + await until(() => chrome.eval(`document.querySelector(".stage canvas[data-front]")?.dataset.page === "1"`), "deck"); + await click('.deckbar [aria-label="Next slide"]'); + await until(() => chrome.eval(`document.querySelector(".stage canvas[data-front]").dataset.page === "2"`), "slide 2"); + await still("slide"); + + // mark done (progress fills), menu, toast + await click(".stc-dock button[aria-pressed]"); + await Bun.sleep(400); + await still("progress"); + await click(".lr-switch-btn"); + await until(() => exists(".lr-menu"), "menu"); + await still("menu"); + await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await open("/c/sample/m2-ex-add", ".stc-seg button"); + await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`); + await until(() => exists(".lr-toast"), "toast"); + await still("toast"); + await until(async () => !(await exists(".lr-toast")), "toast removed", 4000); + expect((await m()).anims).toEqual([]); + } finally { + await setReducedMotion(false); + } + }, 60_000); }); diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 398b585..2ef20d3 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -97,4 +97,6 @@ code, pre { font-family: var(--mono); } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } + /* separate rule: browsers without ::details-content drop only this one */ + *::details-content { transition: none !important; } } diff --git a/packages/ui/test/motion-css.test.ts b/packages/ui/test/motion-css.test.ts index 8da11d1..07eefc5 100644 --- a/packages/ui/test/motion-css.test.ts +++ b/packages/ui/test/motion-css.test.ts @@ -88,6 +88,8 @@ test("prefers-reduced-motion turns off every animation, transition and view tran expect(block).toMatch(/::view-transition-group\(\*\)/); expect(block).toMatch(/::view-transition-old\(\*\)/); expect(block).toMatch(/::view-transition-new\(\*\)/); + // `*` does not reach ::details-content (fold heights), so it has its own rule. + expect(block).toMatch(/\*::details-content\s*\{\s*transition:\s*none !important/); }); test("view transitions never take pointer input and keep the header and sidebar still", () => {