Some Courses run to around a hundred Lessons. The Lesson chrome listed every one of them, so the sidebar of a Passive Lesson, the overlay list of an Active Lesson and the Free Preview’s sidebar all turned into long scrolls. The Lesson you were on sat somewhere in the middle.

Only the current Chapter is open

Every one of those lists now shows the current Lesson’s Chapter in full. Every other Chapter is a single row with its number, title, n/N completed and a chevron. Clicking the row expands the Chapter in place, without navigating, and several can be open at once:

The Passive Lesson sidebar: Ch. 1 Foundations done at 4/4 and expanded by hand, Ch. 2 The Scrum Team collapsed at 2/3, Ch. 3 Events current and open with Sprint Planning highlighted, Ch. 4 Artifacts expanded below

The Active Lessons’ overlay list uses the same rows. A completed Chapter is done (its number turns green), and the current one carries the clay border:

An open Quiz with its Lesson list overlay: Chapters 1, 2 and 4 collapsed, Chapter 3 Events expanded by hand, Chapter 5 “Check yourself” current with “A Quiz — You are here”

The Free Preview has no Enrollment, so it has no Progress to show. Its rows give the Lesson count instead, plus a “Free” badge on any Chapter that holds a Free Preview:

The Free Preview sidebar: The Scrum Team open with Scrum Master marked FREE and the other two Lessons locked; every other Chapter collapsed with its Lesson count

The state belongs to the browser

Which Chapters are open never reaches the server. That follows a rule CLAUDE.md learned the hard way from the Grouping deck: a class the server renders cannot be retired by the client, because the next patch puts it back.

So the server renders only data-start-open on the current Chapter. A ChapterToggle hook reads it once, then owns is-open and aria-expanded. A patch (for example “Mark as complete” re-rendering the sidebar) restores the server’s attributes, and the hook’s updated() puts the Student’s choice back in the same frame.

sequenceDiagram
    participant S as Server render
    participant D as DOM
    participant H as ChapterToggle hook
    S->>D: section#ls-{lesson}-ch-{chapter} [data-start-open]
    D->>H: mounted(): open = has data-start-open
    H->>D: add is-ready, is-open · aria-expanded
    Note over D,H: Student clicks a row
    H->>D: toggle is-open · aria-expanded
    Note over S,D: "Mark as complete" patches the sidebar
    S->>D: morph restores the server's class and aria-expanded
    D->>H: updated()
    H->>D: re-assert open/closed

“Nothing is remembered between Lessons” depends on one detail. Moving to another Lesson is a live navigation into the same LiveView, so handle_params runs and the sidebar is patched, not remounted. Left alone, the hooks would survive and carry the old open state across. The fix is to key every Chapter’s DOM id on the current Lesson (ls-412-ch-37), so a new Lesson gets new elements, new hooks, and only its own Chapter open.

Before the socket connects, CSS shows a data-start-open Chapter open as long as is-ready is absent. The dead render therefore already looks right.

Finishing a Chapter moves you on

The Next Step gained one case. When the Completion just recorded is the one that completes the current Lesson’s Chapter, the forward button reads “Next chapter: {title} →” and leads to that Chapter’s first Lesson without a Completion:

A Text Lesson in The Scrum Team, the last one missing in its Chapter: the primary button reads “Next chapter: Events →”

The interesting case is a middle Lesson completed last. Before this change, its forward button pointed at the following Lesson of the same Chapter, which was already complete. The new case is decided by the Completion, not by position, so it fires from any Lesson of the Chapter.

It fires once. NextStep.completes_chapter?/3 reads the Completions before recording, the same way completes_course? already did, so revisiting a finished Chapter or retaking a Quiz gets the ordinary next Lesson. If the completed Chapter is the last one and something earlier is still pending, the existing “Go to the pending lesson →” applies. If the same Completion also completes the Course, the finish wins.

Active Lessons record their Completion at the Check, so each one now keeps a :chapter_just_completed? assign next to its existing :course_just_completed?. The success modals and the Quiz’s result screen did not change; only the label on their button did.

A name that was already taken

The design-system prototype calls its card .chapter. Prefixing it to mm-chapter, as the house rule requires, collided with something else: the Bibliography page already uses .mm-chapter for its own Chapter headings. The first browser run showed it, and a LiveView test had also passed for the wrong reason. It asserted that .mm-chapter existed on the Bibliography page, and it found the Bibliography’s heading instead of the new component. The component is now mm-chap-*.

What would have caught it earlier: grep the stylesheet for the prefixed name before writing a new component’s CSS. One grep -n "\.mm-chapter" assets/css/app.css would have done it. A test should also assert on something only the new component renders, such as data-start-open or the toggle button, rather than on a class name that something else could own.