From a83ac02a90c8d832e5d3a88f57e3122a1a6ed183 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:47:12 +0100 Subject: [PATCH] feat(learner): module lists and folds open with motion Co-Authored-By: Claude Opus 5.5 --- apps/web/src/motion.e2e.test.ts | 39 +++++++++++++++++++ .../src/web/shell/CourseFrame.tsx | 15 ++++--- packages/feature-learner/src/web/styles.ts | 8 ++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index c49151a..2560e90 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -59,6 +59,10 @@ const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).c const text = (sel: string) => chrome.eval(`document.querySelector(${js(sel)})?.textContent ?? null`); const rect = (sel: string) => chrome.eval(`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(`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("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(`${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(`${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(`(() => { + 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); }); diff --git a/packages/feature-learner/src/web/shell/CourseFrame.tsx b/packages/feature-learner/src/web/shell/CourseFrame.tsx index c3e472a..706e1cf 100644 --- a/packages/feature-learner/src/web/shell/CourseFrame.tsx +++ b/packages/feature-learner/src/web/shell/CourseFrame.tsx @@ -81,7 +81,12 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu .map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons })) .filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]); - const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + // Only a list the user opens rises in; lists open on page load or after a lesson change stay still. + const [opened, setOpened] = useState(null); + const toggle = (id: string) => { + if (!openMods.has(id)) setOpened(id); + setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + }; const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? ""; const errors = course.diagnostics.filter((d) => d.level === "error"); @@ -108,15 +113,15 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu ) : null} {filtered.map(({ m, lessons }) => ( - toggle(m.id)} /> + toggle(m.id)} /> ))} {query && filtered.length === 0 ?

No lessons match “{q.trim()}”.

: null} ); } -function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: { - course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void; +function ModuleBlock({ course, m, lessons, open, enter, current, currentLessonId, onToggle }: { + course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; enter: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void; }) { const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length; const pct = m.lessons.length ? d / m.lessons.length : 0; @@ -131,7 +136,7 @@ function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onTog {course.signedIn ? : } {open ? ( -
    +
      {lessons.map((l) => { const isDone = course.progress[l.id]?.state === "done"; const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" "); diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index da80a03..f9119ba 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -335,6 +335,14 @@ export const learnerCss = String.raw` New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put. With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */ .lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } +/* a module list the user opens */ +.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } +/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */ +@supports selector(::details-content) { + .lr details.fold { interpolate-size: allow-keywords; } + .lr details.fold::details-content { height: 0; opacity: 0; overflow: clip; transition: height var(--t-fold) var(--ease-out), opacity var(--t-fold) var(--ease-out), content-visibility var(--t-fold) allow-discrete; } + .lr details.fold[open]::details-content { height: auto; opacity: 1; } +} /* ---------- responsive ---------- */ @media (max-width: 1100px) {