Pick a shell

data-lesson-shell on <html> picks one. article: the document scrolls, a fixed 48px bar holds the crumb, and the h1 opens the page. app: the body is locked to the viewport as a column of bar, main and footer, and the h1 sits in the bar with its dek. Both share the breakpoints: the crumb goes below 720px, the rail and side column below 1024px, and rail labels arrive at 1380px.

Imports

Tokens first, then the stylesheet. theme.js is a classic script in <head>, external so the CSP stays script-src 'self'; it writes the stored choice to data-theme before first paint, under the agd-theme key that andrew.dunn.dev shares. lesson.js builds the rail and anchors and wires the toggle. The package ships no fonts: the tokens name IBM Plex Sans and Plex Mono, and each site serves its own files.

@import '@dunn.dev/reference/tokens/index.css';
@import '@dunn.dev/reference/lesson/lesson.css';
<script src="/lesson-theme.js"></script>  <!-- a copy of lesson/theme.js -->
<script type="module">
  import { lesson } from '@dunn.dev/reference/lesson/lesson.js';
  lesson();
</script>

Skeleton

A unit is any element with data-lesson-unit and an id. Its .lesson-n gives the rail node its number (no digits gives a dot), its .lesson-h gives the label and becomes a self-link. The rail marks units above the middle of the viewport is-visited and the one crossing it aria-current="step".

<html lang="en" data-lesson-shell="article">
<body>
  <a class="lesson-skip" href="#lesson-main">Skip to content</a>
  <header class="lesson-top">
    <a class="lesson-back" href="/lessons/">Lessons</a>
    <nav class="lesson-crumb" aria-label="Breadcrumb">
      <span>Series</span><span aria-current="page">Title</span><span>Part</span>
    </nav>
    <div class="lesson-actions">
      <button type="button" class="lesson-theme" aria-label="Toggle theme"></button>
    </div>
  </header>
  <nav class="lesson-rail" aria-label="Chapters"></nav>
  <main id="lesson-main" class="lesson-split" tabindex="-1">
    <div class="lesson-flow">
      <section id="first" data-lesson-unit data-lever="name">
        <div class="lesson-head">
          <p class="lesson-n">Chapter 1</p>
          <h2 class="lesson-h">First</h2>
          <p class="lesson-sub">One line.</p>
        </div>
      </section>
    </div>
    <aside class="lesson-side"><!-- the lesson's own figure --></aside>
  </main>
  <footer class="lesson-foot"><span>Credit</span><span class="lesson-spacer"></span><span>Date</span></footer>
</body>

An app shell swaps the crumb for <div class="lesson-title"><h1>…</h1><p class="dek">…</p></div>.

Knobs

--lesson-measure (65ch) sets prose width. --lesson-split sets the two columns (55fr 45fr). --lesson-top-position, --lesson-top-height and --lesson-top-narrow-wrap place the bar; --lesson-top-offset is the room a fixed bar takes, and --lesson-side-top and --lesson-side-bottom the side column's clearance. A lesson with levers sets --lesson-lever-<name> for each; lesson.js hands it to every element with that data-lever, and to the unit's rail node, as --lever for the lesson's own CSS to read. The chrome names no lever.

:root {
  --lesson-lever-identity: var(--accent);
  --lesson-lever-memory: var(--callout-warning);
}

CSP

Nothing in the chrome is inline or remote, so a lesson's policy needs only its own origin for scripts and fonts. The theme icons are data: masks.

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data:

What stays in the lesson

The side column's content, the lever vocabulary, widgets, the CSP, the font files, and an Astro site's layout props. An Astro lesson wraps this markup in its own layout; a standalone lesson gets a Worker route.