Contract

Pin @dunn.dev/reference exactly in a package.json, install it, and copy talk/ and tokens/ to dunn.dev/reference/ beside index.html to run it locally; that directory is gitignored. The deploy sets needs_npm_install: true on the cloudflare-worker component, and its build_command copies the same two directories into the output directory, so development and production resolve one URL and the CSP stays at 'self'. The path drops the @, which the asset server would otherwise redirect on every request. The chrome loads no fonts; the talk keeps its own Fraunces and Newsreader link. The talk's own talk.css loads after the chrome and imports the motion tokens, which is what the design-system check reads, so a talk carries no deviation.

npm ci && mkdir -p dunn.dev/reference
cp -r node_modules/@dunn.dev/reference/{talk,tokens} dunn.dev/reference/

<link rel="stylesheet" href="./dunn.dev/reference/talk/talk.css">
<link rel="stylesheet" href="talk.css">
<script type="module" src="talk.js"></script>

/* talk.css */
@import "./dunn.dev/reference/tokens/motion.css";
:root { --sig: #8a4b12; --sig-text: #733e0f; }

The markup is the plate book: .progress-rail, nav.index with an empty ul.lamps, header.cover with an svg.cover-rule, a section.plate with an svg.plate-hatch before each section.chapter, and footer.out ending on a.out-link. Each chapter holds .chapter-grid, with .steps > .step beside figure.stage > .stage-inner, an optional .fig-controls and a figcaption.

Motion

Each chapter sets timeline-scope: --ch and its .steps names view-timeline: --ch, inset by the stage's pin. The chapter animates a registered --raw over contain 0% to 100%, so 0 is the stage pinning and 1 is it letting go; .chapter-grid publishes it as --fig, and every mark reads --fig by inheritance. The names repeat per chapter, so any chapter count works. Where scroll-driven animation is missing, or with ?driver=js, a rAF driver writes the same --raw on each chapter.

Every step rests on a finished drawing. talk.js measures where the clicker lands each step on --raw, centred, or head then foot when the step is taller than the window, and lays a rest schedule over it: --fig ramps between rests and holds across each. By default step k of n rests at k/n, so a figure's windows are in step units; a mount that was drawn to other rests returns them as at.

Steps are sized by their content and one gap. The only allowance sits under the last step, half the stage less half that step, so the last step's middle reaches the reading line as the stage lets go. The plate's head is printed at the pin; its caption and controls switch on at --chrome-a. A mark is class="ink ink-*" with --a where it fires: scroll only decides whether --fig has passed it, and the mark then draws once over --dur-mark-fade (280ms) or, for strokes and growth, --dur-mark-draw (480ms), and draws back from wherever it is if the scroll returns. The chrome ships draw, fade, wipe, enter, out, close, grow, ride and dim. At 860px and under, under reduced motion and in print, one media block shows every plate finished.

API

import { talk } from './dunn.dev/reference/talk/talk.js';

talk({ mounts: { 'ch-i': mountFirst, 'ch-ii': mountSecond } });
// mount(host, ctrl, chapterEl) -> { states, schedule, caps, lit(i), readout(fig), still(), at }

A mount draws its figure into .stage-inner and may return the four lamp names with their windows, their caps (a CSS number per lamp it never lights past, such as clamp(0, var(--gate), 1) to hold one dark until a control opens it), a lit(i) that sets lamp i on or off itself (1, 0, or null to leave it to its window), a readout called with the chapter's --fig, a still for the static treatment and the at its steps rest on. Data a figure needs is the talk's to load before it calls talk(). The module owns the fallback driver, the static mode, the progress bar, the index, the lamps, the cover rule, the plate hatching and the clicker: PageDown and PageUp step through the cover, plates, steps and the way out on the --ease-in-out token. window.__talk reports the driver, each chapter's raw and --fig numbers, its rests and the clicker's stops.

A readout that counts or reports a mark reads the mark, not the scroll: drawn(el) from talk.js is the mark's --e as drawn this frame, so a counter reaches its number as the mark does and runs back with it. A readout, note or lamp that steps changes at the --a its mark switches on, never at the end of the mark's window. The chrome reads a readout for as long as marks draw after a scroll; a control that moves a mark calls moved() to keep it reading.

A .fig-note whose text changes per step reflows the page as it grows and shrinks. Give it every reading up front: const show = hold(note, texts) from talk.js renders one span per text in a single grid cell, sized by the longest, and show(i) moves .on. A plain-text note is untouched.

What a talk keeps

In its own :root, --sig and --sig-text; beside them, the marks only its figures draw and its reader-driven properties, with their finished values in its own print, reduced-motion and narrow block. A deliberate difference in the frame is one :root line: --rail-left, --rail-width, --rail-num, --rail-top, --rail-bottom, --step-gap, --lead (scroll before step One), --step-last, --pin and --chrome-a.

A talk's identity is more of the same, each token defaulting to the chrome's own look: --paper-tooth (the body's two 7px gradients), --f-stroke (1.6; figure line weight, rings 0.2 less), --hatch-w (2; plate hatch weight), --rule-every and --rule-on (12 and 5; the cover rule's signal tick), --rule-rate (.21), --hatch-rate (.23) and --hatch-step (1.9; the ornament phases), --course-max (none; the course line's measure). The rail reverses to ink while a plate passes behind it.