feat(learner): module lists and folds open with motion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 20:47:12 +01:00
co-authored by Claude Opus 5.5
parent a953e35178
commit a83ac02a90
3 changed files with 57 additions and 5 deletions
+39
View File
@@ -59,6 +59,10 @@ const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).c
const text = (sel: string) => chrome.eval<string | null>(`document.querySelector(${js(sel)})?.textContent ?? null`);
const rect = (sel: string) => chrome.eval<string>(`JSON.stringify(document.querySelector(${js(sel)}).getBoundingClientRect())`);
/** The animations and transitions running now, as "name@class::pseudo" (some pseudo-elements send no events). */
const running = () => chrome.eval<string[]>(`document.getAnimations().map((a) => (a.transitionProperty || a.animationName) + "@" + (a.effect.target.getAttribute?.("class") || a.effect.target.tagName?.toLowerCase()) + (a.effect.pseudoElement || ""))`);
const afterFrame = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))";
/** Full page load of `path`; waits for `ready` to exist. */
async function open(path: string, ready: string) {
await chrome.goto(`${base}${path}`, 50);
@@ -150,4 +154,39 @@ run("navigation motion (headless Chrome)", () => {
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 2000);
expect(await rect(".lr-side")).toBe(side);
}, 30_000);
test("a module opened by the user rises in; lists do not animate on page load or lesson change", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
await click('.lr-les[href="/c/sample/m1-code"]');
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Code along", "code lesson");
await Bun.sleep(300);
expect((await m()).anims.filter((a) => a.includes("lr-lessons"))).toEqual([]);
await reset();
const head = '[...document.querySelectorAll(".lr-mod-head")][1]';
expect(await chrome.eval<string>(`${head}.getAttribute("aria-expanded")`)).toBe("false");
await chrome.eval(`${head}.click(), true`);
// Open at once: the list is there and focusable in the same task as the click.
expect(await chrome.eval<number>(`${head}.parentElement.querySelectorAll(".lr-lessons a").length`)).toBe(2);
await Bun.sleep(300);
expect((await m()).anims).toContain("stc-enter@lr-lessons");
}, 30_000);
test("folds open and close by animating their height", async () => {
// Chrome animates ::details-content without listing it in getAnimations() or sending events, so sample its height.
const sample = (open: boolean) => chrome.eval<number[]>(`(() => {
const d = document.querySelector("details.fold.transcript");
d.querySelector("summary").click();
if (d.open !== ${open}) return [-1];
const out = [], t0 = performance.now();
return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (performance.now() - t0 < 400) requestAnimationFrame(f); else r(out); }; f(); });
})()`);
await open("/c/sample/m1-video", "details.fold.transcript");
const opening = await sample(true);
const full = opening.at(-1)!;
expect(full).toBeGreaterThan(20);
expect(opening.some((h) => h > 1 && h < full - 1)).toBe(true); // passed through in-between heights
const closing = await sample(false);
expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true);
expect(closing.at(-1)).toBe(0);
}, 30_000);
});