Skip to content

Add a Table Of Contents for lessons - #132

Merged
ebouchut merged 11 commits into
devfrom
feat/lesson-toc
Aug 3, 2026
Merged

Add a Table Of Contents for lessons#132
ebouchut merged 11 commits into
devfrom
feat/lesson-toc

Conversation

@ebouchut

@ebouchut ebouchut commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Long lessons had no way to see their structure or jump to a section,
and headings had no anchors to deep-link to.

This PR adds a sticky table of contents for lesson pages.

See ADR-0018.
Fixes #131

It is hybrid (both server and client side):

  • Server: the renderer mints slugified anchor ids on rendered
    h2..h4 (post-sanitization, so author-supplied ids still die and
    the allowlist is unchanged) and returns the TOC with the HTML.
  • Template/CSS: a details/nav contents panel, pinned as a
    sidebar on wide screens and as a slim collapsed bar on mobile;
    works without JavaScript, shown from 2 headings up.
  • Client: lesson-toc.js auto-opens the panel on wide viewports,
    marks the current section with aria-current while scrolling
    (IntersectionObserver), and on mobile collapses the panel when a
    section is picked, then drives the jump from the settled layout.
  • Design files: stylesheets, prototype pages, and wireframes gain
    the same header, lesson content, and TOC elements.

Verification

  • 81 tests and Checkstyle green
  • axe (WCAG 2.1 A/AA) clean in both themes
  • Table Of Contents (TOC) pinning, anchor jumps, scroll-spy, and the mobile collapse verified in-browser at mobile and desktop viewports, alongside the Mermaid and highlighting scripts.

ebouchut added 11 commits August 3, 2026 11:58
The HTML prototype reuses the application's stylesheet files verbatim;
copying the current base.css and the two theme files brings the
prototype's visual language back in line with the app in one move.

Refs #131
Every prototype page header gains the chevron-book logo mark next to
the wordmark, and authenticated pages move Log out out of the nav into
the account group (divider, signed-in username, Log out button), with
a visually hidden prefix for screen readers, mirroring the shipped
header structure. The wireframes carry the same header anatomy: the
brand cluster on all four frames, the account group on the
authenticated one, with the register wireframe export joining the set.

Refs #131
The prototype's lesson article now wraps its body in the
lesson-content block and shows the shipped content styles working on
real markup: a note alert and a tip alert with a custom title (the
exact div/p structure the alerts extension emits), and a code block
carrying highlight.js token spans so the token-mapped colors render.
All styling comes from the refreshed stylesheets; the markup mirrors
what the application serves.

Refs #131
The prototype's lesson page adopts the lesson-layout grid with the
sticky Contents panel: a details/nav rail beside the content on wide
screens, a pinned collapsed bar on small ones, entries with level
indents, an aria-current mark on the active section, and anchors that
jump to real headings. Two wireframes join the set: the desktop lesson
frame with the pinned TOC rail and a mobile lesson frame with the
collapsed sticky bar.

Refs #131
Lesson pages have no table of contents. The plan records the hybrid
architecture: the renderer mints heading anchors and TOC entries after
sanitization (author ids keep dying, the allowlist is unchanged), the
template renders a sticky details/nav panel that works without
JavaScript, and lesson-toc.js adds wide-screen auto-open plus an
IntersectionObserver scroll-spy. Includes the CSS design appendix
agreed after the wireframe review.

Refs #131
ADR-0018 chooses server-minted heading anchors and TOC entries (a
post-sanitization pass, so author ids keep dying and the ADR-0013
allowlist stays untouched) combined with a client-side scroll-spy
(IntersectionObserver writing aria-current). Server-only, client-only,
and the heading-anchor extension through the sanitizer are the
rejected alternatives; the sticky always-viewable behavior is pure
CSS, so the no-JavaScript floor holds.

Refs #131
The renderer now returns a RenderedMarkdown record: the sanitized HTML
plus TocEntry records for every rendered h2..h4. Anchor ids are
stamped in the post-sanitization pass, sharing one jsoup parse with
the alert class narrowing: the sanitizer strips any author-supplied id
first, so raw HTML can never clobber page anchors like the #main
skip-link target, and the allowlist itself stays id-free (ADR-0018).

Slugs fold accents (NFD), lowercase, hyphenate, and deduplicate with
numeric suffixes; a heading whose text reduces to nothing falls back
to a generic slug. The content-addressed cache key is unchanged, the
cached value widens to the record. Callers and existing tests updated
mechanically; new tests cover the id minting, accent folding,
deduplication, and the skip-link spoof case.

Refs #131
The lesson page becomes a two-column grid on wide screens: content
plus a 16rem rail where the TOC pins below the viewport top and
scrolls internally when longer than the screen. On small screens the
closed details summary is a slim bar pinned to the viewport top, so
the TOC stays one tap away at any scroll position. Everything here is
HTML and CSS: the no-JavaScript floor of ADR-0018, pinned by the
journey test asserting the nav and its anchors in the server HTML,
and the absence of a TOC on lessons with fewer than two headings.

The TOC list precedes the content in the DOM so assistive tech meets
the navigation aid first; the grid places it visually right. Indent
levels ride a data attribute because BEM's double underscore collides
with Thymeleaf's preprocessing syntax when composed dynamically.

Refs #131
lesson-toc.js adds the two things the server cannot know: it opens
the TOC panel on wide viewports (the markup ships closed, which is
the right mobile floor) and tracks the reading position with an
IntersectionObserver, marking the current section's TOC link with
aria-current. The CSS keys the highlight off the attribute alone, the
same pattern as the header nav, so visual and assistive state cannot
drift apart.

Anchor jumps get scroll-margin-top so headings never land glued to
the viewport edge or under the pinned mobile bar, and smooth
scrolling applies only under prefers-reduced-motion: no-preference,
consistent with the site's motion policy. Without JavaScript the TOC
remains sticky and expandable; this commit is pure enhancement.

Fixes #131
The first layout never actually pinned: a sticky element can only
travel inside its parent box, and both breakpoints gave it a parent
exactly as tall as itself (a start-aligned grid item on desktop, an
auto-sized grid row on mobile), so there was no runway and the TOC
scrolled away with the page.

Each breakpoint now pins the element whose parent spans the lesson.
On small screens the aside itself pins at the viewport top: the
layout is normal flow there, so the aside's containing block includes
the whole article. From 46rem the grid rail returns, the aside keeps
the default stretch to full row height, and the panel inside it pins
below the viewport top. Verified pinned at top, middle, and bottom
scroll positions on both presets.

Refs #131
Field-tested on an iPhone SE viewport: tapping a TOC link left the
expanded panel pinned over the top of the viewport, hiding the very
section the reader jumped to; the mobile scroll-margin only budgets
for the collapsed bar.

On narrow viewports the click handler now collapses the panel and
then drives the jump itself (pushState for the URL hash, then
scrollIntoView from the settled layout). The ordering matters: the
collapse shifts layout and the browser's native anchor scroll loses
that race, which reproduced as a jump that never arrived. The desktop
rail keeps the default behavior since it never overlaps content, and
without JavaScript the panel still closes by tapping the bar.

Refs #131
@ebouchut ebouchut self-assigned this Aug 3, 2026
@ebouchut ebouchut moved this to In Progress in learn-dev-project Aug 3, 2026
@ebouchut ebouchut moved this from In Progress to In Review in learn-dev-project Aug 3, 2026
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.54%. Comparing base (f9984ef) to head (c4960b2).

Files with missing lines Patch % Lines
.../ericbouchut/learndev/course/MarkdownRenderer.java 96.29% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##                dev     #132      +/-   ##
============================================
+ Coverage     81.71%   82.54%   +0.83%     
- Complexity      202      211       +9     
============================================
  Files            38       38              
  Lines           864      888      +24     
  Branches         53       55       +2     
============================================
+ Hits            706      733      +27     
  Misses          124      124              
+ Partials         34       31       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ebouchut
ebouchut merged commit 6ea2096 into dev Aug 3, 2026
7 checks passed
@ebouchut
ebouchut deleted the feat/lesson-toc branch August 3, 2026 12:55
@github-project-automation github-project-automation Bot moved this from In Review to Done in learn-dev-project Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add a sticky table of contents to lesson pages

1 participant