Lessons
The chrome every lesson shares: the bar with its back link and
theme toggle, a numbered rail, unit heads with self-anchors, a
split with a sticky side column, and the footer row. A
stylesheet, a framework-free ES module and a pre-paint theme
script, importable from @dunn.dev/reference/lesson/.
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.