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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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
@@ -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<string | null>(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
</div>
) : null}
{filtered.map(({ m, lessons }) => (
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} enter={opened === m.id} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
))}
{query && filtered.length === 0 ? <p className="lr-nohit">No lessons match “{q.trim()}”.</p> : 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
</button>
{course.signedIn ? <ProgressBar value={pct} dim={pct === 1 || !current} label={`${m.title} progress`} /> : <span style={{ display: "block", height: 4 }} />}
{open ? (
<ol className="lr-lessons">
<ol className="lr-lessons" data-enter={enter ? "" : undefined}>
{lessons.map((l) => {
const isDone = course.progress[l.id]?.state === "done";
const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
@@ -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) {