<!-- ImuTechz skill: Scroll Motion Graphics v1.0.0 (MIT)
     https://www.imutechz.in/skills/scroll-motion-graphics
     Single-file edition: SKILL.md, then every reference and file it points at. -->

# Scroll motion graphics

A canvas timeline driven by scroll position: a pinned stage, reels that are pure functions of progress, and captions the drawing is never allowed to touch. This method runs the story drum on the ImuTechz home page, the R&D drum and the Founder path film. It also powers the five-reel demo in [assets/demo.html](assets/demo.html).

The whole technique fits in one sentence. **Scroll position is the only clock: map it to p ∈ [0, 1], and make every pixel, caption and rotation a function of p.**

## What good looks like

- **It scrubs like a video.** Scroll forward and the film plays. Scroll back and it rewinds, frame for frame. Stopping halfway leaves a frame that makes sense.
- **The page is never hijacked.** Wheel, trackpad, touch and keys move the page exactly as they do everywhere else. The section is just tall.
- **The words are always readable.** Captions are real DOM text on a card. The drawing lays itself out in the space the captions leave, and the canvas is clipped around them as a safety net.
- **It is a control, not just a show.** A story drum's faces are links. Its rail, arrow buttons, arrow keys and mouse drag all seek *by scrolling the page*, so the scroll and the drum can never disagree.
- **An idle page costs nothing.** No requestAnimationFrame runs once p settles or after the section leaves the viewport.
- **Reduced motion is respected.** No smoothing, no in-between drum roll and no caption entrance animation. All of the content is still there.
- **It holds up from 360 to 1920 px wide**, in light and dark, at DPR 1 and 2.

## Hard rules

1. **p comes from the scroll position, never from a clock.** Do not use `performance.now()`, `Date`, CSS animations or `setInterval` inside a reel. Smoothing (a lerp toward the target p) is allowed because it converges to the same frame.
2. **A reel is a pure function** `draw(ctx, t, env)`. It keeps no state between frames and never calls `Math.random()`; use a seeded `hash(i)` instead. The same (t, env) must paint the same pixels.
3. **Pin with CSS, not JS.** Use a tall track (`position: relative; height: N×vh`) holding a `position: sticky; top: 0; height: 100svh` stage. Never set `position: fixed` on the stage, never lock the body, and never put `overflow: hidden|auto|clip` on an ancestor of the track.
4. **Never capture input.** Use no `preventDefault` on wheel or touch, no `overflow: hidden` on html or body, and no scroll snapping on the track. Only a focused drum may handle arrow keys, and it does so by scrolling the page.
5. **Seek by scrolling.** Every button, rail item, key and anchor changes `window.scrollY`. Never set p or a rotation directly. Use `behavior: "instant"` for programmatic jumps that must not animate, because `"auto"` obeys a page's CSS `scroll-behavior: smooth`.
6. **Text lives in the DOM.** Captions, titles and links are HTML. Canvas text is limited to decorative labels such as years, axis ticks and step names.
7. **Measure captions every frame and keep the drawing off them.** Each caption and HUD element is a keep-out (`data-keep`). Reels lay out inside `env.free`, and the engine clips the keep-outs with an even-odd clip.
8. **Never show two captions at once.** Caption windows sit inside their reel with a gap at each boundary, and reels crossfade inside that gap.
9. **The loop stops when idle.** Start rAF on scroll or resize only while the section is near the viewport (IntersectionObserver), and stop it when p equals the target.
10. **Canvas is sized to the stage at `min(devicePixelRatio, 2)`.** Call `setTransform(dpr, …)` once per frame and clear it every frame.
11. **Colours come from CSS custom properties.** Do not hard-code a light-mode palette into a reel. Re-read the palette on `prefers-color-scheme` change.
12. **Honour `prefers-reduced-motion: reduce`.** Use no smoothing, have the drum snap face to face, and turn off caption entrance animations. Frames still follow the scroll, because the user drives them.

## Build order

Do it in this order. Each step is testable before the next.

1. **Content first.** Write the chapters: a kicker, a title, one or two sentences and an optional link for each. Choose the number of reels (3 to 7) and give each one roughly 80 to 100 svh of scroll.
2. **Track and stage.** Add the tall section with a sticky 100svh stage inside it, and check that it pins with no JS at all (see [layout-and-captions](references/layout-and-captions.md)).
3. **The scrub engine.** Wire `ScrollMotion.scrub` or `film` (see [engine](references/engine.md)) and log p. Check that it reads 0 at pin start and 1 at pin end, and that it moves both ways.
4. **Captions and HUD in the DOM.** Place them per breakpoint, mark them `data-keep`, and let the engine drive their opacity from p.
5. **Reels, one at a time.** Write `draw(ctx, t, env)` inside `env.free` (see [drawing](references/drawing.md)), and scrub it back and forth before writing the next one.
6. **Motion grammar.** Add enter, hold and exit envelopes, staggers, dwell and crossfades (see [motion-grammar](references/motion-grammar.md)).
7. **Interactivity, if it is a drum.** Add face links, the rail, prev and next buttons, keys and drag, all seeking by scroll.
8. **Performance and accessibility pass.** Cover the idle loop, DPR, reduced motion, focus order, screen reader text and dark mode (see [performance-and-a11y](references/performance-and-a11y.md)).
9. **Verify** at 360, 390, 414, 768, 1024, 1280, 1440 and 1920 with the headless script (see [verification](references/verification.md)). Do not call it done until every check passes.

## Decision checklists

**Canvas film or DOM/CSS transforms?**
- Many small moving parts, lines that draw, particles, or charts → **canvas film** (`ScrollMotion.film`).
- A handful of cards, images or text panels → **DOM transforms** driven by `scrub` (the drum is one).
- Real 3D meshes or shaders → this skill is not enough. Use WebGL, but keep rules 1 to 5 and 9 to 12.

**Pin or view mode?**
- The section should hold the screen while its story plays → `mode: "pin"` (tall track and sticky stage).
- The effect rides along as content passes, as with marquees or parallax → `mode: "view"`, with no pinning.

**How tall is the track?**
- Use `n × 80–100svh + 100svh` for a film and `n × 70vh + 100vh` for a drum. If a test reader says it goes "too fast", add height. Never slow it down by capturing the wheel.

**Where do captions go?**
- Desktop (≥ 760 px): beside the drawing (left or right card) or as a wide bottom card. Phones: always a bottom sheet, with the drawing above it.
- With a fixed site header, set `--sm-nav-h` so the HUD and drum clear it.

**Does it need the drum?**
- Use the drum when the chapters are things a user may want to *go to* (pages, products, sections). Otherwise use the film alone.

## Failure modes and fixes

| Symptom | Cause | Fix |
| --- | --- | --- |
| Stage scrolls away instead of pinning | An ancestor has `overflow: hidden/auto/clip`, or the stage has no `top` | Remove the overflow (use `overflow-x: clip` on body if needed) and set `top: 0` |
| Animation only plays forwards, or jumps on scroll-up | State accumulated across frames, or a clock or `Math.random` in a reel | Make the reel a pure function of t; use `hash(i)` for randomness |
| Drawing runs under the caption | Reel laid out on the full stage | Lay out in `env.free`; keep `data-keep` on the caption; let the engine clip |
| Caption box measured in the wrong place mid-fade | Used `getBoundingClientRect` (includes the transform) | Measure layout boxes (`offsetLeft/Top/Width/Height`, see `boxIn`) |
| Two captions overlap at a boundary | Caption windows touch | Fade captions out at `1 − cross` and in at `cross`; reels crossfade in the gap |
| Fans spin on an idle page | rAF loop never stops | Stop when `cur === target`; gate on IntersectionObserver |
| Blurry canvas on phones / slow on 4K | Canvas at CSS size, or uncapped DPR | Use `width = W × min(dpr, 2)` and `setTransform(dpr, 0, 0, dpr, 0, 0)` |
| Drum and page disagree after clicking the rail | The control set the rotation directly | Every control calls `scrollToProgress`; rotation only ever comes from p |
| Drag jitters, then snaps back | Page has `scroll-behavior: smooth`, so `scrollTo(x, y)` animates | Use `scrollTo({ top, behavior: "instant" })` while dragging |
| Clicking a face after a drag opens its link | The click that ends a drag fires | Swallow one capture-phase click after a drag that moved > 4 px |
| Mobile 100vh jump when the URL bar hides | `vh` on the stage | Use `100svh` for the stage; compute p with the stage's own height |
| Dark mode shows a light-mode drawing | Hard-coded colours | Read `--sm-*` tokens via `readPalette`; re-read on scheme change |
| Back faces of the drum show through | Missing `backface-visibility: hidden` | Set it, with the `-webkit-` prefix, on every face |
| Drum face thumbnails drawn at the wrong size | Used the projected rect of a 3D-transformed face | Size by `clientWidth/clientHeight` |

## Files

- [references/engine.md](references/engine.md): p, the scrub loop, `film()`, seeking, the React hook.
- [references/drawing.md](references/drawing.md): the reel contract, canvas setup, routes, pop-ins, particles and palettes.
- [references/layout-and-captions.md](references/layout-and-captions.md): track and stage CSS, keep-outs, the free rect, the clip, the drum layout.
- [references/motion-grammar.md](references/motion-grammar.md): envelopes, staggers, dwell, crossfades, pacing and easing.
- [references/performance-and-a11y.md](references/performance-and-a11y.md): the idle loop, layout cost, reduced motion, keyboard, focus and screen readers.
- [references/verification.md](references/verification.md): the eight widths, the headless Brave/Chrome script and the manual pass.
- [assets/demo.html](assets/demo.html): standalone, zero dependencies, double-click to run. Five reels and the story drum.
- [assets/scroll-motion.js](assets/scroll-motion.js): the engine (`scrub`, `film`, `drum`, `scrollToProgress` and helpers), UMD.
- [assets/reels.js](assets/reels.js): five example reels (orbit, path, stack, wave, close) and `still()` for thumbnails.
- [assets/scroll-motion.css](assets/scroll-motion.css): film and drum styles, light and dark tokens.
- [assets/ScrollFilm.jsx](assets/ScrollFilm.jsx): the React wrapper for the film, plus the `useScrub` hook.
- [assets/StoryDrum.jsx](assets/StoryDrum.jsx): the React story drum, plus a self-rolling `DrumPreview`.
- `assets/preview-orbit.jpg`, `assets/preview-path.jpg`, `assets/preview-close.jpg`: stills of the demo at 1440 × 900.

Start from the demo: open it, view source and replace the content. The wiring at the bottom of the file is about 60 lines.


---


## references/engine.md


# Engine: from scroll position to p

Everything in this skill hangs off one number: **p ∈ [0, 1]**, how far the reader has scrolled through a section. The engine measures it, optionally smooths it, and calls your frame function only while it changes. It never touches input.

The full source is [assets/scroll-motion.js](../assets/scroll-motion.js). This document explains the parts you must get right if you write your own.

## The two modes

```text
mode "pin"                                  mode "view"

 ┌──────────── track (tall) ───┐            viewport
 │ ┌── stage (sticky, 100svh) ┐│   p = 0     ┌──────────┐
 │ │                          ││  ← track    │          │  ← el's top enters
 │ │   canvas + captions      ││    top at   │          │     at the bottom: p = 0
 │ │                          ││    viewport └──────────┘
 │ └──────────────────────────┘│    top          ...
 │            ...              │              el's bottom leaves
 │                             │   p = 1      at the top: p = 1
 └─────────────────────────────┘  ← track bottom at viewport bottom
```

```js
function progressOf(el, mode, stage) {
  const r = el.getBoundingClientRect();
  const vh = window.innerHeight || 1;
  if (mode === "pin") {
    // the scrollable run is the track minus the stage it pins
    const span = el.offsetHeight - (stage ? stage.offsetHeight : vh);
    return span > 4 ? clamp01(-r.top / span) : clamp01(-r.top / vh);
  }
  return clamp01((vh - r.top) / (vh + r.height));
}
```

Use the **stage's own height** (not `innerHeight`) as the pinned part when the stage is `100svh`. On mobile, `innerHeight` grows when the URL bar hides, but `svh` does not, and mixing the two makes p overshoot.

## The scrub loop

```js
function scrub(el, onFrame, { mode = "pin", smooth = 0.18, stage = null } = {}) {
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  let target = 0, cur = -1, raf = 0, on = false;

  const measure = () => { target = progressOf(el, mode, stage); };
  function tick() {
    raf = 0;
    if (!on) return;
    measure();
    const d = target - cur;
    // snap when close (so the loop can end), when reduced, or on the first frame
    cur = cur < 0 || reduce || Math.abs(d) < 0.0005 ? target : cur + d * smooth;
    onFrame(cur);
    if (cur !== target) raf = requestAnimationFrame(tick);   // settled → loop ends
  }
  const kick = () => { if (on && !raf) raf = requestAnimationFrame(tick); };

  // only run near the viewport
  const io = new IntersectionObserver((es) => {
    const vis = es[es.length - 1].isIntersecting;
    if (vis && !on) { on = true; kick(); }
    else if (!vis && on) { on = false; cancelAnimationFrame(raf); raf = 0; }
  }, { rootMargin: "25% 0px" });
  io.observe(el);

  addEventListener("scroll", kick, { passive: true });   // passive: never blocks scrolling
  addEventListener("resize", kick);
  measure(); cur = target; onFrame(cur);                 // paint the right frame on load

  return { stop() { on = false; cancelAnimationFrame(raf); io.disconnect();
                    removeEventListener("scroll", kick); removeEventListener("resize", kick); } };
}
```

Why it is shaped like this:

- **Scroll events only kick the loop.** They never draw. The rAF tick reads layout once and writes once.
- **The snap threshold** (`0.0005`) is what lets `cur === target` become true, so the loop ends. Without it, a lerp approaches forever and the page never idles.
- **Smoothing is a lerp toward the scroll target**, not a timeline. Because it converges to the same p, the frame you land on is the same frame you would get without smoothing. That is why "pure function of p" survives it. Use `smooth` 0.12 to 0.2. Higher is snappier, and 1 means none.
- **On the first frame `cur = target`**, so a page reloaded mid-section shows the correct frame instead of animating from 0.
- **IntersectionObserver with a margin** means a page with five scroll sections runs, at most, the loop of the one on screen.

## The film: reels on a shared timeline

`ScrollMotion.film()` is the scrub loop plus a canvas, a timeline split into equal reel slices, captions and keep-outs.

```js
const player = ScrollMotion.film({
  track: document.querySelector(".smf"),           // tall section
  stage: document.querySelector(".smf-stage"),     // sticky child
  canvas: document.querySelector(".smf-canvas"),
  reels: ScrollReels.all,                          // [{ name, draw(ctx, t, env) }]
  captions: [...document.querySelectorAll(".smf-cap")], // one per reel, in order
  smooth: 0.14,      // lerp factor
  cross: 0.12,       // reel crossfade half-width, in reel units
  capFade: 0.1,      // caption fade length, in reel units
  onChange(i, reel) { /* the dominant reel changed: analytics, aria-live, etc. */ },
});
```

### The timeline math

With `n` reels, reel `i` owns `p ∈ [i/n, (i+1)/n]`. Inside it, the local coordinate is `u = p·n − i` (unclamped) and the reel receives `t = clamp01(u)`.

```js
const local = (p, i) => p * n - i;

function reelAlpha(p, i) {                      // reels crossfade across each boundary
  const u = local(p, i);
  const fin  = i === 0     ? 1 : smoothstep((u + cross) / (2 * cross));
  const fout = i === n - 1 ? 1 : smoothstep((1 + cross - u) / (2 * cross));
  return Math.min(fin, fout);
}

function capAlpha(p, i) {                       // captions fade inside their reel, leaving a gap
  const u = local(p, i);
  const fin  = i === 0     ? 1 : seg(u, cross, cross + capFade);
  const fout = i === n - 1 ? 1 : 1 - seg(u, 1 - cross - capFade, 1 - cross);
  return Math.min(fin, fout);
}
```

The first reel never fades in and the last never fades out, so the film opens and ends on a full frame. Captions leave a gap of `2 × cross` around each boundary, which is exactly where the reels crossfade. Two captions are therefore never visible together, and the keep-outs never jump while a reel is drawing.

### The frame

Each painted frame does three things, in this order:

```js
function paint(p) {
  // 1. WRITE: caption opacity/transform, HUD counter and bar, all from p
  // 2. READ:  measure every keep-out (captions + [data-keep]) → rects
  // 3. DRAW:  clear, clip out the keep-outs (evenodd), draw each reel whose alpha > 0
  //           with ctx.globalAlpha = alpha and env.free = freeRect(stage − keep-outs)
}
```

Writes happen before reads, so there is at most one layout per frame. The keep-outs are measured with `offsetLeft/Top/Width/Height` walked up to the stage (`boxIn`). These ignore transforms, so a caption that is mid-slide still reserves its resting box. See [layout-and-captions](layout-and-captions.md).

### The returned handle

| Member | Use |
| --- | --- |
| `p` | the current (smoothed) progress |
| `keeps` | the keep-out rects of the last painted frame (tests use this) |
| `renderAt(p)` | paint any p without scrolling: posters, tests and purity checks |
| `seek(p)` / `seekReel(i, at)` | scroll the page to a p, or to a point inside reel i |
| `retheme()` | re-read the palette and repaint (after a manual theme toggle) |
| `destroy()` | remove every listener and observer |

## Seeking: always by scrolling

```js
function scrollToProgress(el, p, { stage = null, smooth = !prefersReduced(), nudge = 0 } = {}) {
  const top = el.getBoundingClientRect().top + window.scrollY;
  const run = el.offsetHeight - (stage ? stage.offsetHeight : window.innerHeight);
  window.scrollTo({ top: Math.round(top + run * clamp01(p)) + nudge,
                    behavior: smooth ? "smooth" : "instant" });
}
```

- Every control (drum rail, prev and next, keys, a "jump to chapter" link, drag release) calls this. The scroll position stays the single source of truth, so the scrollbar, browser history (`#anchor`), the drum and the film all agree.
- Use `"instant"`, never `"auto"`, for a non-animated jump. `"auto"` means "whatever CSS says", and many sites set `html { scroll-behavior: smooth }`.
- A `nudge` of 1 px past an exact chapter boundary avoids landing a hair before it on fractional scroll positions.
- **Plain anchors work too.** The demo puts zero-size anchors inside the track at `top = (at / n) × span` (recomputed on resize), so `<a href="#reel-path">` seeks the film with no JS on the link.

## The drum engine

`ScrollMotion.drum(root)` is `scrub` plus a rotation:

```js
const f = clamp01(p) * (n - 1);            // which face, fractional
const base = Math.floor(f), t = f - base;
const turn = reduce ? Math.round(f)        // reduced motion: snap face to face
                    : base + t * t * (3 - 2 * t);   // dwell: smoothstep inside each step
ring.style.transform = `translateZ(calc(var(--r) * -1)) rotateX(${turn * 360 / n}deg)`;
const idx = Math.min(n - 1, Math.round(f));         // the face you see
```

The smoothstep inside each step makes the drum *park* on a face for a while, then roll quickly to the next. That is what makes it readable. A linear rotation is always half-way between two faces.

## React

[assets/ScrollFilm.jsx](../assets/ScrollFilm.jsx) exports `useScrub` and `ScrollFilm`. [assets/StoryDrum.jsx](../assets/StoryDrum.jsx) is the drum.

```jsx
export function useScrub(ref, onFrame, { mode = "pin", smooth = 0.18, stageRef = null } = {}) {
  const cb = useRef(onFrame);
  cb.current = onFrame;                       // latest closure, without restarting the loop
  useEffect(() => {
    const s = ScrollMotion.scrub(ref.current, (p) => cb.current(p),
                                 { mode, smooth, stage: stageRef?.current ?? null });
    return () => s.stop();
  }, [ref, mode, smooth, stageRef]);
}
```

React rules:

- **Never `setState` per frame.** Write styles through refs inside `onFrame`, and set state only when a discrete index changes (the face or reel).
- `reels` must be referentially stable (module scope or `useMemo`), or the film is rebuilt.
- In Next.js these are client components (`"use client"`). The engine is safe to import during SSR, because nothing runs until an effect does.
- `scroll-motion.js` is UMD. Webpack and Next hand you `module.exports`. Vite serves it as ESM with no exports, so it registers `globalThis.ScrollMotion`. `ScrollFilm.jsx` resolves either. Import `ScrollFilm.jsx` before `reels.js`.

## Writing your own engine instead

If you port this to another framework, keep these invariants and you have kept the method:

1. One measured target per frame, from `getBoundingClientRect` of the track.
2. A lerp toward it with a snap threshold, so it can settle.
3. rAF scheduled only by scroll, resize or visibility, and only while not settled.
4. Passive listeners, and no `preventDefault` anywhere near scrolling.
5. Every control seeks with `scrollTo`.


---


## references/drawing.md


# Drawing: reels that are pure functions of t

A reel is an object with a name and a draw function:

```js
const myReel = {
  name: "path",
  draw(ctx, t, env) {
    // t   ∈ [0, 1]  local progress through this reel
    // env = { W, H, dpr, mobile, colors, keep, free, alpha, p, index, reduce }
  },
};
```

The engine calls `draw` once per painted frame for every reel whose crossfade alpha is above zero. Before it does, it has already called `setTransform(dpr, …)`, cleared the canvas, clipped out the keep-outs and set `ctx.globalAlpha = env.alpha` inside a `save`/`restore`.

## The contract

| Do | Do not |
| --- | --- |
| Derive every position, size, colour and alpha from `t` and `env` | Keep variables that carry over between frames |
| Use `hash(i, seed)` for variety | Call `Math.random()` |
| Lay out inside `env.free` | Lay out on `0..W, 0..H` |
| Read colours from `env.colors` | Hard-code `#fff` or `#000` for content |
| Multiply alpha (`rgba(c, a)`) | Assign `ctx.globalAlpha = …` (it overwrites the crossfade) |
| Draw decorative labels (years, steps, ticks) | Draw sentences: those are DOM captions |
| Return early if `env.free` is too small | Throw, or draw outside it "just a bit" |

The test of purity is mechanical: `renderAt(0.3)`, then `renderAt(0.9)`, then `renderAt(0.3)` must produce identical pixels. [verification](verification.md) checks this at every width.

Precompute nothing that depends on `t`. It is fine to cache geometry that depends only on `env.free` and the content (for example, route samples) as long as you rebuild it when `free` changes. In practice a 7-node route takes microseconds to sample, so the reference reels simply recompute it each frame.

## env

| Field | Meaning |
| --- | --- |
| `W`, `H` | stage size in CSS px (the canvas is `W × dpr` by `H × dpr`) |
| `free` | `{x, y, w, h}`: the largest rectangle missing every keep-out, already inset by a margin. **Lay out here.** |
| `keep` | the keep-out rects (this reel's caption, the HUD, any visible caption), for `blocked(keep, x, y, r)` checks |
| `mobile` | `W < 760`: use thinner lines, fewer items and smaller labels |
| `colors` | `{ink, dim, bg, surface, accent, accent2, cool}` as `[r, g, b]`, read from CSS tokens |
| `alpha` | this reel's crossfade alpha (already applied to `globalAlpha`) |
| `p`, `index` | global progress and reel index, for readouts |
| `reduce` | prefers-reduced-motion. Frames still follow scroll, but drop decorative wobble |

## Canvas setup (the engine does this, and so must you if you roll your own)

```js
function layout() {
  const r = stage.getBoundingClientRect();
  W = r.width; H = r.height;
  dpr = Math.min(window.devicePixelRatio || 1, 2);    // cap: 3× phones and 4K gain nothing visible
  canvas.width = Math.round(W * dpr);
  canvas.height = Math.round(H * dpr);
  canvas.style.width = W + "px"; canvas.style.height = H + "px";
}
// per frame
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // draw in CSS px
ctx.clearRect(0, 0, W, H);
```

Re-run `layout` from a `ResizeObserver` on the stage, not a window resize. Then repaint immediately at the current p, because resizing clears the canvas.

## Helpers ([assets/scroll-motion.js](../assets/scroll-motion.js))

```js
const { clamp01, lerp, seg, smoothstep, ease, stagger, hash, rgba, mix, route, strokeTo } = ScrollMotion;

seg(t, 0.2, 0.6)        // 0 before 0.2, 1 after 0.6, linear between: a window inside the reel
ease.outCubic(x)        // land, don't stop
ease.outBack(x)         // pop with a small overshoot (nodes, dots)
ease.inOutCubic(x)      // travel between two places
stagger(t, i, n, 0.4)   // item i's own 0..1, each item taking 40% of the reel, starts spread evenly
hash(i, seed)           // deterministic 0..1
rgba([r, g, b], a)      // canvas colour string
mix(c1, c2, 0.3)        // blend two palette colours
```

## Pattern: a route that draws with the scroll

This is the PathFilm technique. Nodes go through a Catmull-Rom spline sampled **by arc length**, so the line grows at a constant speed regardless of curvature.

```js
draw(ctx, t, env) {
  const { free: F, colors: C } = env;
  const nodes = STEPS.map((_, i, a) => {
    const u = i / (a.length - 1);
    return [F.x + F.w * (0.04 + u * 0.92), F.y + 30 + (F.h - 74) * (0.5 + Math.sin(u * Math.PI * 2.4 + 0.4) * 0.5)];
  });
  const { samples, at, total } = route(nodes);      // samples: [x, y, cumulativeLength]
  const L = seg(t, 0.04, 0.92) * total;              // lead-in and a hold at the end

  // 1. the road ahead: faint, dotted, full length
  ctx.setLineDash([2, 9]); ctx.strokeStyle = rgba(C.ink, 0.18); ctx.lineWidth = 2;
  ctx.beginPath(); samples.forEach(([x, y], k) => (k ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke();
  ctx.setLineDash([]);

  // 2. the road travelled: solid, gradient, exactly L long; strokeTo returns the head
  ctx.strokeStyle = rgba(C.accent2); ctx.lineWidth = env.mobile ? 3 : 4;
  const head = L > 0 ? strokeTo(ctx, samples, L) : samples[0];

  // 3. milestones pop as the head reaches them
  nodes.forEach(([x, y], i) => {
    const reach = clamp01((L - at[i] + total * 0.012) / (total * 0.04));
    const r = 11 * ease.outBack(reach);
    /* reach === 0: hollow ring; reach > 0: halo + filled dot of radius r */
  });

  // 4. a glow at the head (no pulse: a time-based pulse would break purity)
}
```

Choose the layout from the free rect's aspect: a horizontal sine when `F.w > F.h`, and a vertical zigzag on phones.

## Pattern: staggered items that land

```js
const build = seg(t, 0, 0.72);                       // the build uses the first 72% of the reel
for (let i = 0; i < n; i++) {
  const k = ease.outCubic(stagger(build, i, n, 0.32));
  const h = maxH * value[i] * k;
  /* draw bar i with height h */
}
const line = seg(t, 0.62, 0.96);                     // the next layer waits for the build
```

Overlapping windows (`span` > `1/n`) look fluid. Disjoint windows look mechanical.

## Pattern: particles that resolve onto a mark

```js
for (let i = 0; i < M; i++) {
  const [sx, sy] = [F.x + hash(i, 1) * F.w, F.y + hash(i, 2) * F.h];   // scattered start (stable)
  const [tx, ty] = target(i);                                         // a point on the mark
  const e = ease.inOutCubic(stagger(seg(t, 0, 0.8), i % 40, 40, 0.6));
  const bend = Math.sin(e * Math.PI) * 40 * (hash(i, 5) - 0.5);       // curved, not straight, paths
  const x = lerp(sx, tx, e) + bend, y = lerp(sy, ty, e) - bend;
  /* dot at (x, y): dim while travelling, solid once e > 0.98 */
}
```

Keep the particle count modest (100 to 200) and draw plain `arc` fills. Do not use `shadowBlur` per particle.

## Pattern: rhythm without a clock

A wave's phase comes from `t` (`phase = t × 2π × 1.5`), so scrolling *is* the oscillator. Inset rows by the largest amplitude so no crest leaves `free`:

```js
const ampMax = (F.h / rows) * 1.1;
const rowY = (r) => F.y + ampMax + (F.h - 2 * ampMax) * (r / (rows - 1));
```

## Palette

```js
// CSS (scroll-motion.css): tokens on :root, redefined for dark mode
// JS: env.colors comes from readPalette(stage)
ctx.strokeStyle = rgba(C.ink, 0.18);   // structure
ctx.fillStyle   = rgba(C.accent2);     // the thing that matters
ctx.fillStyle   = rgba(C.surface);     // "holes" in dots (not #fff, which glows in dark mode)
```

Two accents and an ink at several alphas carry every reel in the demo. Glows are radial gradients from `accent` at 0.35 to 0.55 alpha down to 0.

## Cost guide

- `createLinearGradient` and `createRadialGradient` per frame are fine (a few per reel).
- `shadowBlur`, `filter` and `getImageData` in the frame loop are not.
- Thousands of path segments are fine. Thousands of `fill()` calls on a 4K canvas are not. Batch dots into one path if you need more than a few hundred.
- Text: set `ctx.font` once per batch, and use a monospace stack for labels so widths stay predictable.

## Thumbnails and posters

The same reel draws a still for a drum face or a poster: call it at a fixed `t` with an empty `keep` and a padded `free`. Size by `clientWidth/clientHeight`, not `getBoundingClientRect`, because a 3D-transformed face reports its projected box.

```js
ScrollReels.still(faceCanvas, ScrollReels.path, 0.86, ScrollMotion.readPalette(drumEl), 12);
```


---


## references/layout-and-captions.md


# Layout and captions

The drawing and the words share one screen. The rule is simple: **the words win.** Captions are DOM, measured every frame, and the canvas lays out in whatever space they leave. Anything that would still touch them is clipped away.

## The track and the stage

```html
<section class="smf" style="--smf-n: 5" aria-label="A film in five reels">
  <div class="smf-stage">
    <canvas class="smf-canvas" aria-hidden="true"></canvas>
    <div class="smf-frame">
      <div class="smf-hud" data-keep>…counter, progress bar…</div>
      <article class="smf-cap" data-place="left">…</article>   <!-- one per reel -->
      …
      <p class="smf-hint" data-keep>Scroll back: it rewinds</p>
    </div>
  </div>
</section>
```

```css
.smf       { position: relative; height: calc(var(--smf-n) * 90svh + 100svh); }
.smf-stage { position: sticky; top: 0; height: 100svh; overflow: hidden; }
.smf-canvas{ position: absolute; inset: 0; display: block; pointer-events: none; }
.smf-frame { position: absolute; inset: calc(var(--sm-nav-h) + 16px) clamp(16px, 4vw, 48px) 20px; pointer-events: none; }
.smf-frame > * { pointer-events: auto; }
```

- The **track** is the timeline. Its height sets the pace (see [motion-grammar](motion-grammar.md)).
- The **stage** is sticky, so it pins with zero JS. `overflow: hidden` goes on the stage, never on an ancestor of the track.
- The **frame** is the safe area: side gutters (16 px minimum on phones) and a top inset for a fixed site header (`--sm-nav-h`).
- The canvas has `pointer-events: none`, so links in captions stay clickable and text stays selectable.

Sticky checklist when "it doesn't pin":

1. Is any ancestor `overflow: hidden | auto | scroll | clip`? Remove it, or use `overflow-x: clip` only on `body` (clip does not create a scroll container in modern engines, but test it).
2. Does the stage have `top: 0`?
3. Is the track taller than the stage? Sticky needs room to travel.
4. Is the stage a flex or grid child stretched to the track's height? Give it an explicit `height: 100svh`.

## Keep-outs: measured every frame

Every element the drawing must avoid carries `data-keep`. Captions are keep-outs too, and the engine adds them per reel.

```js
function boxIn(el, stage) {          // layout box relative to the stage, transform-free
  let x = 0, y = 0, n = el;
  while (n && n !== stage) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent; }
  return { x, y, w: el.offsetWidth, h: el.offsetHeight };
}
```

Why `offset*` rather than `getBoundingClientRect`:

- A caption mid-entrance has `translateY(14px)`. Its *resting* box is what should be reserved, otherwise the free rect wobbles while it slides.
- `offset*` returns layout values that the browser has already computed. After the frame's style writes, reading them costs one layout at most, and nothing when nothing changed.
- The stage is `position: sticky`, which makes it an `offsetParent`, so the walk stops there.

Elements with `display: none` (for example a hint hidden on phones) have `offsetParent === null` and are skipped.

Each rect is padded (default 14 px) before use.

## The free rect

`freeRect(W, H, keeps, margin)` returns the largest rectangle of the stage that misses every keep-out. It is greedy: for each keep-out (largest first) that intersects the current free area, take the best of the four sides of it.

```js
function freeRect(W, H, keeps, margin = 0) {
  let F = { x: 0, y: 0, w: W, h: H };
  // area, with thin slivers penalised: a 1400×60 strip is not a good canvas
  const score = (r) => r.w <= 0 || r.h <= 0 ? -1
    : r.w * r.h * Math.pow(Math.min(r.w, r.h) / Math.max(r.w, r.h), 0.35);
  for (const k of [...keeps].sort((a, b) => b.w * b.h - a.w * a.h)) {
    if (!hits(F, k)) continue;
    const cands = [
      { x: F.x,       y: F.y,       w: k.x - F.x,             h: F.h },             // left
      { x: k.x + k.w, y: F.y,       w: F.x + F.w - k.x - k.w, h: F.h },             // right
      { x: F.x,       y: F.y,       w: F.w,                   h: k.y - F.y },       // above
      { x: F.x,       y: k.y + k.h, w: F.w,                   h: F.y + F.h - k.y - k.h }, // below
    ];
    F = cands.reduce((best, c) => (score(c) > score(best) ? c : best));
  }
  return { x: F.x + margin, y: F.y + margin, w: F.w - 2 * margin, h: F.h - 2 * margin };
}
```

What it gives you in practice:

| Layout | Free rect |
| --- | --- |
| HUD strip on top, caption card at the left middle | right of the card, below the HUD |
| Wide caption at the bottom (desktop) | above the caption, full width |
| Caption at the right | left of it |
| Phone: HUD on top, caption as a bottom sheet | the band between them |

Reels receive this as `env.free` and lay out inside it. The engine passes each reel a keep list of **its own caption** (visible or not), the fixed keep-outs and any caption currently visible. A reel therefore lays out around the caption it will share the screen with, and does so from its first faded-in frame, so nothing jumps when its caption arrives.

## The clip: a safety net

Layout in `free` is the design. The clip is the guarantee:

```js
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, H);                          // the whole stage…
for (const k of visibleKeeps) ctx.rect(k.x, k.y, k.w, k.h);  // …minus every visible keep-out
ctx.clip("evenodd");
/* draw reels */
ctx.restore();
```

Even-odd fill makes each inner rect a hole. A glow radius or label that spills past `free` is cut at the caption's padded edge instead of running under the text. If you ever see a visibly cut drawing, the reel's layout is wrong. Fix the layout, not the clip.

The verification script reads the canvas pixels inside every visible keep-out and requires an alpha of exactly 0.

## Captions

```html
<article class="smf-cap" data-place="left" data-keep>
  <p class="smf-cap-k"><span>01 · Orbit</span><span>Pin a stage</span></p>
  <h3 class="smf-cap-h">One timeline, many moving parts.</h3>
  <p class="smf-cap-d">One or two sentences. No more.</p>
</article>
```

```css
.smf-cap { position: absolute; width: min(400px, 100%); padding: 22px 24px 24px; border-radius: 20px;
  background: var(--sm-card); border: 1px solid var(--sm-card-line);
  backdrop-filter: blur(14px) saturate(1.4); box-shadow: 0 18px 50px -26px var(--sm-shadow);
  opacity: 0; visibility: hidden; }
.smf-cap[data-place="left"]   { left: 0;  top: 50%; margin-top: -110px; }
.smf-cap[data-place="right"]  { right: 0; top: 50%; margin-top: -110px; }
.smf-cap[data-place="bottom"] { left: 0;  bottom: 0; width: min(560px, 100%); }
.smf-cap[data-place="top"]    { left: 0;  top: 44px; }
@media (max-width: 759px) {    /* phones: always a bottom sheet */
  .smf-cap, .smf-cap[data-place] { left: 0; right: 0; top: auto; bottom: 0; width: auto; margin: 0; }
}
```

- The engine writes `opacity`, `transform` and `visibility` from p every frame. CSS never animates them, so they scrub backwards too.
- Vary `data-place` between reels on desktop. It keeps the composition moving, and the free rect follows automatically.
- Use a frosted card rather than bare text over a drawing. Contrast must hold even if a glow sits near the edge.
- Keep the copy short, because phone bottom sheets must not eat more than about 40% of the stage. At 360 × 740 a kicker, a two-line title and three lines of body is the limit.
- **No two captions at once** (see the timeline math in [engine](engine.md)).

## The HUD

A thin strip across the top: a title, a counter (`[data-sm-count]`) and a progress bar (`[data-sm-bar]`, `scaleX(p)`). It tells the reader that this is a sequence and how far through it they are. It is a keep-out.

## The story drum layout

```html
<section class="sd" style="height: calc(5 * 70vh + 100vh)">
  <div class="sd-stage">                       <!-- sticky grid: copy | drum | rail -->
    <div class="sd-copy">kicker, title, one .sd-cap per chapter (hidden), ‹ › buttons</div>
    <div class="sd-viewport" tabindex="0">     <!-- perspective, mask, focus target -->
      <div class="sd-ring">                    <!-- preserve-3d, rotated by p -->
        <a class="sd-face" href="…">art + meta</a> × n
      </div>
    </div>
    <ol class="sd-rail"><i><i data-sd-bar></i></i><li><button data-sd-go="0">…</button></li>…</ol>
  </div>
</section>
```

```css
.sd-stage    { position: sticky; top: 0; height: 100svh; display: grid;
               grid-template-columns: minmax(0, 1fr) minmax(320px, 1.1fr) 180px; align-items: center; }
.sd-viewport { --r: clamp(180px, 19vw, 260px); height: min(74vh, 600px); perspective: 1300px;
               display: grid; place-items: center; touch-action: pan-y;
               mask-image: linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent); }
.sd-ring     { position: relative; width: 100%; height: calc(var(--r) * 1.12); transform-style: preserve-3d; }
.sd-face     { position: absolute; inset: 0; backface-visibility: hidden; }
/* each face: rotateX(-i·360/n deg) translateZ(var(--r)); ring: translateZ(-r) rotateX(turn·360/n deg) */
@media (max-width: 1000px) {
  .sd-stage { grid-template-columns: minmax(0, 1fr); }
  .sd-rail, .sd-title, .sd-tip, .sd-d { display: none; }
  .sd-viewport { --r: clamp(150px, 40vw, 220px); height: min(52vh, 440px); }
  .sd-copy { order: 2; }
  .sd-nav button { width: 44px; height: 44px; }
}
```

- `--r` is the cylinder radius. The ring's `translateZ(-r)` pulls the front face back to the screen plane, so it renders at 1:1.
- The vertical mask fades the neighbour faces at the top and bottom edges. You see that it is a drum without the neighbours competing.
- `minmax(0, 1fr)` (not `1fr`) on phones stops long words from forcing horizontal overflow.
- Give `.sd-cap` a `min-height`, so the buttons below do not jump between chapters of different lengths.

## Breakpoints that matter

| Width | What changes |
| --- | --- |
| < 760 | film captions become bottom sheets; `env.mobile` true; HUD extras and hint hidden |
| < 1000 | drum stacks (drum on top, copy below); rail, title and body copy hidden; 44 px buttons |
| ≥ 1000 | drum three-column grid |
| ≥ 1440 | nothing new: let `clamp()` carry it, cap the drum stage at 1320 px |


---


## references/motion-grammar.md


# Motion grammar

Scroll-driven motion has one property that timed animation does not: **the reader controls the speed and the direction.** Everything here follows from that. A sequence must read well at any speed, forwards or backwards, and when stopped at any point.

## Every reel has three acts

```text
 t: 0 ─────── 0.25 ─────────────── 0.75 ─────── 1
    │  enter  │        hold         │   exit    │
    build-up    the readable state    settle / hand-off
```

- **Enter** (about the first 25%): things arrive. Lines start drawing, items stagger in, the core grows.
- **Hold** (the middle): the frame is complete enough to read *while the caption is fully visible*. Keep secondary motion here, such as slow rotation, the head travelling or a phase shift, so it does not feel frozen. Nothing new should demand attention.
- **Exit** (the last 25%): resolve, then hand off. The crossfade to the next reel happens around the boundary, and the reel should look finished going into it.

Map this with windows:

```js
const grow   = ease.outCubic(seg(t, 0.00, 0.30));   // enter
const spokes =               seg(t, 0.45, 0.80);    // a second layer, in the hold
const fin    = ease.outCubic(seg(t, 0.78, 1.00));   // resolve
```

## The timeline across reels

| Constant | Default | Meaning |
| --- | --- | --- |
| `cross` | 0.12 | reels crossfade over ±12% of a reel around each boundary |
| `capFade` | 0.10 | a caption fades over 10% of a reel |
| caption visible | `u ∈ [cross + capFade, 1 − cross − capFade]` | about 56% of each reel at full opacity |

The first reel starts fully drawn with its caption up, so the first frame of the pin is never empty. The last reel never fades out, so the film ends on its resolved frame and the page scrolls on.

## Pacing: how tall is the track?

| Content | Scroll per unit |
| --- | --- |
| Film reel | 80 svh (phone) to 90 svh (desktop) per reel, plus 100 svh for the pin |
| Drum chapter | 70 vh per chapter, plus 100 vh |
| Route with N milestones | 40 to 46 vh per milestone, plus 100 svh |
| `view` mode effect (marquee, parallax) | no track: it rides the content |

Too short and a flick of the trackpad skips a whole reel. Too long and readers think the page is stuck. If unsure, use 90 svh per reel and test with a real trackpad and a real phone.

## Easing vocabulary

| Easing | Use it for | Avoid it for |
| --- | --- | --- |
| `smoothstep` (in-out) | the drum's dwell inside each step, crossfades | things that should *land* |
| `outCubic` | items arriving and settling: bars, growth | travel between two places |
| `outBack` | small things popping: nodes, dots, a core | large shapes (the overshoot looks like a glitch) |
| `inOutCubic` | travel from A to B: particles, camera moves | arrivals |
| linear | the travelled length of a line, a playhead, a progress bar | anything with mass |

Linear is right for *progress*. The reader should feel the line advancing in step with their scroll, so the head of a route moves linearly in arc length. Easing goes on the *things it reaches*.

## Stagger

```js
const k = stagger(t, i, n, span);   // item i's local 0..1; starts spread over (1 - span)
```

- `span` ≈ 0.3 to 0.5 gives overlapping arrivals that read as one gesture.
- `span` < `1/n` gives one-at-a-time arrivals, which read as a list. Use these deliberately (milestones, steps).
- Stagger along the reading direction: left to right, top to bottom, or inner ring to outer.

## Dwell: the drum's signature

```js
const f = p * (n - 1), base = Math.floor(f), t = f - base;
const turn = base + t * t * (3 - 2 * t);       // smoothstep inside each step
```

Linear rotation means the drum is almost always between faces. With a smoothstep per step, the derivative is zero at every face, so the drum *parks*: a comfortable stretch of scroll in which a face sits square to the reader, then a quick roll. The site's drums use exactly this, and so does the self-rolling preview, written as CSS keyframes that hold for 18% and roll for 7%.

## Direction symmetry

Scrubbing backwards plays every act in reverse. Check each reel backwards on purpose:

- A route un-draws. That is good.
- Particles fly back out to their scattered positions. That works only because the starting positions come from `hash(i)`, not `Math.random()`.
- One-shot effects (a flash when a node is reached) must be functions of distance-to-node, not events, or they never "un-flash".
- Caption entrances scrub too, because opacity and transform are written from p. That is why caption CSS must not animate them.

## Never on a clock

Do not use a pulsing glow driven by `time`, a CSS `animation` on a reel element, or an idle wobble. All three break purity (the same p gives a different frame), keep the loop alive and drain batteries. If you want life in the hold, derive it from `t`: rotation by `t × π`, phase by `t × 2π`. The one exception is a decorative, aria-hidden preview card such as `DrumPreview`, which is a CSS loop that stops under reduced motion.

## Velocity, with restraint

The R&D page's word roller skews rows by scroll *velocity* (`d = p − lastP`). That is acceptable for a decorative `view`-mode marquee, because it settles to zero skew when scrolling stops. Do not use velocity inside a film reel, because it makes the frame depend on how you arrived at p.

## Reduced motion

| Normal | Reduced |
| --- | --- |
| lerp smoothing toward the scroll target | none: p equals the scroll position |
| drum rolls between faces with dwell | drum snaps face to face (`Math.round(f)`) |
| caption cards slide 14 px as they fade | fade only, no transform |
| `.sd-cap` CSS entrance | `animation: none` |
| smooth `scrollTo` for controls | instant |
| self-rolling preview drum | still, front face |

The content, the order and every control stay the same. Only the in-between movement goes.


---


## references/performance-and-a11y.md


# Performance and accessibility

A scroll film is a long-running piece of UI on the main thread, in the one interaction (scrolling) that users notice jank in most. It also hides its content in a canvas. Both need deliberate handling.

## Performance

### The loop runs only when something changes

```js
const kick = () => { if (on && !raf) raf = requestAnimationFrame(tick); };
addEventListener("scroll", kick, { passive: true });
addEventListener("resize", kick);
new IntersectionObserver(([e]) => { on = e.isIntersecting; if (on) kick(); else cancelAnimationFrame(raf); },
                         { rootMargin: "40% 0px" }).observe(track);

function tick() {
  raf = 0; if (!on) return;
  measure();
  cur = Math.abs(target - cur) < 0.0005 ? target : cur + (target - cur) * smooth;
  if (cur !== painted) { painted = cur; paint(cur); raf = requestAnimationFrame(tick); }
}
```

- **Settled means zero rAF.** The verification script counts `requestAnimationFrame` calls over one idle second and requires 0.
- **Off-screen means zero rAF.** The observer stops the loop when the section is more than 40% of a viewport away.
- **Passive listeners** let the compositor scroll without waiting for JS.
- Scroll handlers do nothing but schedule work. They never read layout or draw.

### One layout per frame, at most

Order every frame as **write, then read, then draw**:

1. Write caption opacity and transform, plus the HUD, from p (style writes).
2. Read keep-out boxes via `offset*` (one layout if the writes dirtied it, otherwise free).
3. Draw on the canvas (no layout).

Never interleave reads and writes across several elements. That forces a layout per element.

### Canvas budget

- DPR is capped at 2. A 1920 × 1080 stage at DPR 2 is 8.3 MP, which is fine for 2D. At DPR 3 it is 18.7 MP, which is not.
- Do not use `shadowBlur`, `filter` or `getImageData` in the frame. Glows are radial gradients.
- Reels with alpha 0 are skipped entirely.
- Resize through a `ResizeObserver` on the stage, with one `layout()` and an immediate repaint.
- Do not paint offscreen drum faces every frame. Paint their stills once, then again on resize or theme change. In React, render art only for the visible face ±1.

### CSS

- `will-change: transform` on the drum ring and `will-change: opacity, transform` on captions, and nowhere else.
- `backdrop-filter` on caption cards is the most expensive thing on the page. Keep it to the one visible card (`visibility: hidden` on the others removes them from painting).
- Do not put `overflow: hidden | auto | scroll` on an *ancestor* of the track. It becomes the scroll container sticky attaches to, and the stage stops pinning. If you add `contain` to an ancestor for performance, re-test the pin.

## Never pin input

These are hard requirements, not preferences:

- No `wheel` or `touchmove` listeners with `preventDefault`.
- No `overflow: hidden` on `html` or `body`, and no `position: fixed` on the body to "lock" it.
- No CSS `scroll-snap` on the track. Snapping fights the reader's own speed.
- The drum's arrow keys work **only when the drum has focus**, and they call `scrollTo`. Arrow keys anywhere else scroll the page as usual.
- Drag is mouse and pen only (`pointerType !== "touch"`). Touch already scrolls, and `touch-action: pan-y` on the viewport keeps it that way.
- After a drag that moved more than 4 px, swallow exactly one click (capture phase, removed after 50 ms), so releasing over a face does not follow its link.

## Reduced motion

```js
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
```

| What | With reduce |
| --- | --- |
| scroll smoothing | off: p is the scroll position |
| drum rotation | whole faces only |
| caption transform | none (opacity still follows p) |
| `.sd-cap` entrance animation | `animation: none` |
| programmatic seeks | instant |
| decorative self-running loops | stopped |

Frames still follow the scroll, because the user is driving them and nothing moves on its own. The content, the order and the controls are identical.

## Keyboard and focus

- The drum viewport is `tabindex="0"` with an `aria-label` that says the arrow keys change the chapter. `:focus-visible` outlines the front face.
- Only the visible face is tabbable (`tabIndex = 0`). The others are `-1` and `aria-hidden="true"`, so Tab does not walk around the back of a cylinder.
- Keys: ← ↑ for the previous chapter, → ↓ for the next, plus Home and End. Each one `preventDefault`s only the key it handles.
- The prev and next buttons are real `<button>`s with `aria-label`s, disabled at the ends. Rail items are buttons with `aria-current="step"` on the active one.
- Buttons are at least 44 × 44 px under 1000 px wide.
- Focus rings use the accent colour with a 3 to 4 px offset. Never remove outlines without a replacement.

## Screen readers

- The canvas is `aria-hidden="true"`. Nothing important may live only in it.
- Captions are real headings and paragraphs, present in the DOM in reading order the whole time (opacity, not removal), so a screen reader reads the full story top to bottom.
- Where the visible text is abbreviated (for example the PathFilm caption shows only the active milestone), add a `.sr-only` ordered list with every item.
- The section has an `aria-label` that names the sequence ("A film in five reels").
- Drum captions that are not current are `hidden`. The rail and buttons expose the full chapter list, and every face is a link with a visible name.

## Colour and contrast

- All colours are CSS custom properties on `:root`, redefined under `@media (prefers-color-scheme: dark)` (guarded by `:root:not([data-theme="light"])`) and under `:root[data-theme="dark"]` for a manual toggle.
- The canvas reads the same tokens (`readPalette`) and re-reads them on a scheme change.
- Caption text sits on a frosted card, not on the drawing, so contrast does not depend on what the reel is doing. Check body text at ≥ 4.5:1 against the card in both themes.
- Use `--sm-surface` (not `#fff`) for the "holes" in dots, or they glow in dark mode.

## Mobile specifics

- `100svh` for stages, so the URL bar showing or hiding does not resize the pin.
- `env.mobile` (stage < 760 px) means fewer items, thinner lines and smaller labels.
- Captions become bottom sheets, and the drawing sits in the band above.
- Test with a real phone flick: if one flick skips a reel, the track is too short.


---


## references/verification.md


# Verification

"It looks fine on my laptop" is not verification. A scroll film has to hold at every width, in both directions, in both themes and with reduced motion. Run the automated pass, then the manual one. Do not call it done until both are clean.

## The widths

| Width × height | Stands for |
| --- | --- |
| 360 × 740 | small Android |
| 390 × 844 @2x | iPhone 12 to 15 |
| 414 × 896 | large iPhone |
| 768 × 1024 | tablet portrait (film is mobile-layout, drum is stacked) |
| 1024 × 768 | tablet landscape / small laptop (drum goes three-column) |
| 1280 × 800 | laptop |
| 1440 × 900 | desktop (the preview stills are taken here) |
| 1920 × 1080 | large desktop |

## What to check at every width

1. **No console errors or warnings**, and no uncaught exceptions.
2. **No horizontal overflow**: `document.documentElement.scrollWidth <= innerWidth`.
3. **Each reel shows at its position.** Scroll to p = (i + 0.6) / n: the HUD says reel i + 1, caption i is at opacity 1, and the canvas actually drew something.
4. **Reels are distinct.** The canvas hashes at the n positions are all different.
5. **Nothing under the captions.** Inside every visible keep-out rect (inset 2 px), the canvas alpha is 0.
6. **It seeks both ways.** The canvas hash at p = 0.33 is identical after scrolling 0.33, then 0.88, then 0.33.
7. **Idle is idle.** Once settled, zero `requestAnimationFrame` calls in one second.
8. **The drum is a control.** ArrowDown on the focused viewport advances it one chapter, and the next button advances it again. On desktop, a rail button jumps to its chapter and a mouse drag rolls the drum back. Every face is a link with art painted on it, and exactly one face is tabbable.
9. **Anchors seek.** `#reel-stack` scrolls the film to reel 3.

Then once more with `prefers-reduced-motion: reduce` (the drum rotation is a whole multiple of 360/n, and caption animation is `none`) and once with `prefers-color-scheme: dark` (screenshot it and look).

## The script (puppeteer-core + an installed Chrome or Brave)

```js
// verify.mjs: node verify.mjs  (puppeteer-core only; no browser download)
import puppeteer from "puppeteer-core";
import { pathToFileURL } from "node:url";
import crypto from "node:crypto";

const BROWSER = process.env.BROWSER || "C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe";
const URL_ = pathToFileURL(process.argv[2] || "assets/demo.html").href;
const SIZES = [[360, 740], [390, 844, 2], [414, 896], [768, 1024], [1024, 768], [1280, 800], [1440, 900], [1920, 1080]];
const fails = [];
const ok = (c, m) => c || fails.push(m);

const browser = await puppeteer.launch({
  executablePath: BROWSER, headless: "new",
  args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--hide-scrollbars"],
});

async function open(w, h, dpr = 1, media = []) {
  const page = await browser.newPage(), errors = [];
  page.on("console", (m) => ["error", "warning"].includes(m.type()) && errors.push(m.text()));
  page.on("pageerror", (e) => errors.push(e.message));
  await page.evaluateOnNewDocument(() => {            // count rAF calls for the idle check
    window.__raf = 0; const r = requestAnimationFrame.bind(window);
    window.requestAnimationFrame = (cb) => (window.__raf++, r(cb));
  });
  await page.setViewport({ width: w, height: h, deviceScaleFactor: dpr });
  if (media.length) await page.emulateMediaFeatures(media);
  await page.goto(URL_, { waitUntil: "load" });
  return { page, errors };
}

async function filmTo(page, p) {                       // scroll, then wait for the lerp to settle
  await page.evaluate((p) => {
    const f = document.getElementById("film"), s = f.querySelector(".smf-stage");
    scrollTo({ top: f.getBoundingClientRect().top + scrollY + p * (f.offsetHeight - s.offsetHeight), behavior: "instant" });
  }, p);
  await page.waitForFunction(() => {
    const f = document.getElementById("film"), s = f.querySelector(".smf-stage");
    return __demo.player.p === ScrollMotion.progressOf(f, "pin", s);
  });
}
const hash = (page) => page.evaluate(() => document.querySelector(".smf-canvas").toDataURL())
  .then((d) => crypto.createHash("sha1").update(d).digest("hex"));

const alphaUnderKeeps = (page) => page.evaluate(() => {
  const cv = document.querySelector(".smf-canvas"), ctx = cv.getContext("2d"), dpr = cv.width / cv.clientWidth;
  let max = 0;
  for (const k of __demo.player.keeps) {
    const x = Math.max(0, Math.ceil((k.x + 2) * dpr)), y = Math.max(0, Math.ceil((k.y + 2) * dpr));
    const w = Math.min(cv.width, Math.floor((k.x + k.w - 2) * dpr)) - x;
    const h = Math.min(cv.height, Math.floor((k.y + k.h - 2) * dpr)) - y;
    if (w <= 0 || h <= 0) continue;
    const d = ctx.getImageData(x, y, w, h).data;
    for (let i = 3; i < d.length; i += 4) max = Math.max(max, d[i]);
  }
  return max;
});

for (const [w, h, dpr] of SIZES) {
  const { page, errors } = await open(w, h, dpr);
  ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${w}: overflow`);
  const hashes = [];
  for (let i = 0; i < 5; i++) {
    await filmTo(page, i === 4 ? 0.97 : (i + 0.6) / 5);
    const hud = await page.$eval("[data-sm-count]", (e) => e.textContent);
    ok(hud.startsWith(String(i + 1).padStart(2, "0")), `${w}: reel ${i} HUD ${hud}`);
    ok((await alphaUnderKeeps(page)) === 0, `${w}: reel ${i} drew under a caption`);
    hashes.push(await hash(page));
    if (w === 390 || w === 1440) await page.screenshot({ path: `shots/w${w}-reel${i + 1}.jpg`, quality: 80 });
  }
  ok(new Set(hashes).size === 5, `${w}: reels not distinct`);
  await filmTo(page, 0.33); const a = await hash(page);
  await filmTo(page, 0.88); await filmTo(page, 0.33);
  ok(a === (await hash(page)), `${w}: forward/back differ`);
  const r0 = await page.evaluate(() => __raf);
  await new Promise((r) => setTimeout(r, 1000));
  ok((await page.evaluate(() => __raf)) === r0, `${w}: rAF while idle`);
  // …drum: focus .sd-viewport, press ArrowDown, expect __demo.drum.index === 1, etc.
  ok(errors.length === 0, `${w}: console: ${errors.join(" | ")}`);
  await page.close();
}
await browser.close();
console.log(fails.length ? "FAIL\n- " + fails.join("\n- ") : "ALL CHECKS PASSED");
process.exit(fails.length ? 1 : 0);
```

Notes on the script:

- **Use `behavior: "instant"`** in test scrolls. The demo sets `scroll-behavior: smooth` on `html`, and `"auto"` would animate.
- **Wait for the settle, not a fixed timeout.** Compare the player's `p` to `progressOf()`. They are exactly equal once the lerp snaps.
- The SwiftShader flags make headless rendering deterministic on machines without a GPU. They are needed for WebGL and harmless for Canvas 2D.
- Expose a debug handle (`window.__demo = { player, drum }`) from your page wiring. The tests rely on `player.p`, `player.keeps` and `drum.index`.
- For your own page, swap the selectors (`#film`, `.smf-stage`, `[data-sm-count]`) for yours, and keep the checks.

## Preview stills

Take them at 1440 × 900, DPR 1, JPEG quality 80, at a p inside each reel's hold (u ≈ 0.65):

```js
await filmTo(page, 0.13);  await page.screenshot({ path: "assets/preview-orbit.jpg", type: "jpeg", quality: 80 });
await filmTo(page, 0.33);  await page.screenshot({ path: "assets/preview-path.jpg",  type: "jpeg", quality: 80 });
await filmTo(page, 0.985); await page.screenshot({ path: "assets/preview-close.jpg", type: "jpeg", quality: 80 });
```

Each should be under 150 KB. Flat backgrounds and line art compress well, and the demo's stills are about 40 to 50 KB.

## The manual pass (10 minutes, every time)

- [ ] **Trackpad**: scroll slowly through, then fling. Every reel should get a readable hold and nothing should skip.
- [ ] **Scroll backwards** through the whole film. Does every reel rewind meaningfully?
- [ ] **Stop mid-crossfade.** Is the frame acceptable as a still?
- [ ] **Real phone**: flick through. Does the stage stay pinned when the URL bar hides? Do the bottom-sheet captions leave room for the drawing?
- [ ] **Keyboard only**: Tab to the drum, use the arrows, Enter on a face, Tab to the rail. Is the focus always visible?
- [ ] **Screen reader** (VoiceOver or NVDA): read the section. Is the whole story there in order, without the canvas?
- [ ] **Reduced motion** at the OS level: the drum snaps, nothing slides, and the content is complete.
- [ ] **Dark mode** at the OS level: no light-mode leftovers in the canvas, and caption contrast holds.
- [ ] **Zoom to 200%**: captions still fit, and there is no horizontal scroll.
- [ ] **Idle**: leave the tab open on the film for a minute. The performance panel should show no frames being produced.


---


## assets/ScrollFilm.jsx


````jsx
"use client";
/*! ScrollFilm.jsx v1.0.0 · ImuTechz · MIT
 *
 * React wrapper for ScrollMotion.film(): a pinned canvas film whose
 * timeline is the scroll position. React renders the DOM (track, stage,
 * HUD, captions); the engine owns the canvas and writes caption
 * opacity/transform every frame. React never re-renders per frame.
 *
 *   import ScrollFilm, { useScrub } from "./ScrollFilm";
 *   import Reels from "./reels";            // or your own reels
 *   import "./scroll-motion.css";
 *
 *   <ScrollFilm
 *     label="How it works"
 *     reels={Reels.all}
 *     captions={[{ kicker: "01 · Orbit", tag: "Pin a stage", title: "…", body: "…", place: "left" }, …]}
 *   />
 *
 * Rules:
 *   - `reels` must be stable (module-level or useMemo): a new array
 *     rebuilds the film.
 *   - one caption per reel; place = "left" | "right" | "bottom" | "top"
 *     (phones always use bottom). Captions are real DOM text.
 *   - never wrap the section in an ancestor with overflow: hidden/auto
 *     (position: sticky stops working).
 *   - Next.js: this is a client component ("use client" above).
 */
import { useEffect, useRef, useState } from "react";
import * as engine from "./scroll-motion.js";

/* scroll-motion.js is UMD. Webpack/Next give us module.exports (as
   .default); Vite serves it as native ESM with no exports, and the file
   registers globalThis.ScrollMotion instead. Accept either. */
export const ScrollMotion =
  (engine && engine.default && engine.default.film && engine.default) ||
  (engine && engine.film && engine) ||
  globalThis.ScrollMotion;

/* useScrub(ref, onFrame, { mode: "pin" | "view", smooth }) — the hook
   behind every scroll-driven section. onFrame(p) runs only while p
   changes; keep it cheap and write styles through refs, not state. */
export function useScrub(ref, onFrame, { mode = "pin", smooth = 0.18, stageRef = null } = {}) {
  const cb = useRef(onFrame);
  cb.current = onFrame; // always call the latest closure, without restarting the loop
  useEffect(() => {
    const el = ref.current;
    if (!el) return undefined;
    const s = ScrollMotion.scrub(el, (p) => cb.current(p), { mode, smooth, stage: stageRef ? stageRef.current : null });
    return () => s.stop();
  }, [ref, mode, smooth, stageRef]);
}

export default function ScrollFilm({
  reels,
  captions = [],
  label = "Scroll-driven film",
  title = "",
  hint = "Scroll back: it rewinds",
  vhPerReel = 90,
  className = "",
  onReel,
}) {
  const track = useRef(null);
  const stage = useRef(null);
  const canvas = useRef(null);
  const caps = useRef([]);
  const player = useRef(null);
  const [active, setActive] = useState(0);
  const onReelRef = useRef(onReel);
  onReelRef.current = onReel;
  const n = reels.length;

  useEffect(() => {
    if (!track.current || !stage.current || !canvas.current) return undefined;
    const f = ScrollMotion.film({
      track: track.current,
      stage: stage.current,
      canvas: canvas.current,
      reels,
      captions: reels.map((_, i) => caps.current[i] || null),
      onChange: (i, reel) => {
        setActive(i); // a state change only when the reel changes, never per frame
        if (onReelRef.current) onReelRef.current(i, reel);
      },
    });
    player.current = f;
    return () => { f.destroy(); player.current = null; };
  }, [reels]);

  return (
    <section
      className={`smf ${className}`.trim()}
      ref={track}
      style={{ "--smf-n": n, height: `calc(${n} * ${vhPerReel}svh + 100svh)` }}
      aria-label={label}
    >
      <div className="smf-stage" ref={stage}>
        <canvas className="smf-canvas" ref={canvas} aria-hidden="true" />
        <div className="smf-frame">
          <div className="smf-hud" data-keep="">
            {title ? <span className="smf-hud-t">{title}</span> : null}
            {/* the engine writes the counter text; React leaves it empty */}
            <span data-sm-count="" aria-hidden="true" />
            <span className="smf-hud-bar" aria-hidden="true"><i data-sm-bar="" /></span>
          </div>
          {captions.slice(0, n).map((c, i) => (
            <article
              key={i}
              className="smf-cap"
              data-place={c.place || "left"}
              data-keep=""
              ref={(el) => { caps.current[i] = el; }}
              aria-current={i === active ? "step" : undefined}
            >
              <p className="smf-cap-k"><span>{c.kicker}</span>{c.tag ? <span>{c.tag}</span> : null}</p>
              <h3 className="smf-cap-h">{c.title}</h3>
              {c.body ? <p className="smf-cap-d">{c.body}</p> : null}
            </article>
          ))}
          {hint ? <p className="smf-hint" data-keep="" aria-hidden="true">{hint}</p> : null}
        </div>
      </div>
    </section>
  );
}
````


---


## assets/StoryDrum.jsx


````jsx
"use client";
/*! StoryDrum.jsx v1.0.0 · ImuTechz · MIT
 *
 * A story told on the faces of a 3D drum that rolls with the scroll.
 * The React port of ScrollMotion.drum(); same markup and CSS (.sd-*).
 *
 * It is a control, not just an animation:
 *   - every face and every caption CTA is a real link
 *   - the rail and the ‹ › buttons jump to a chapter by scrolling the
 *     page, so the scroll and the drum never disagree
 *   - ← → ↑ ↓ Home End step chapters while the drum has focus
 *   - dragging with a mouse or pen rolls it and lets go on the nearest
 *     face (touch is left to native scrolling)
 * Rotation is a pure function of scroll; nothing pins input.
 *
 *   <StoryDrum
 *     title="The story, on a drum."
 *     chapters={[{ n: "Pin a stage", t: "…", d: "…", cta: "Read more", href: "/docs", art: <img … /> }, …]}
 *     LinkComponent={Link}   // optional: next/link or your router's link
 *   />
 */
import { useEffect, useRef, useState } from "react";
import { ScrollMotion, useScrub } from "./ScrollFilm.jsx";

const { clamp01, scrollToProgress, prefersReduced } = ScrollMotion;
const pad2 = (i) => String(i).padStart(2, "0");

export default function StoryDrum({
  chapters,
  title = "The story, on a drum.",
  kicker = "Roll it",
  vhPerChapter = 70,
  LinkComponent = "a",
  onChapter,
}) {
  const ref = useRef(null);
  const stageRef = useRef(null);
  const vpRef = useRef(null);
  const ring = useRef(null);
  const bar = useRef(null);
  const drag = useRef(null);
  const shown = useRef(0);
  const [idx, setIdx] = useState(0);
  const n = chapters.length;
  const step = 360 / n;
  const Go = LinkComponent;

  useScrub(ref, (p) => {
    const f = clamp01(p) * (n - 1);
    const base = Math.floor(f), t = f - base;
    // dwell: a smoothstep inside each step parks the drum on a face, then rolls
    const turn = prefersReduced() ? Math.round(f) : base + t * t * (3 - 2 * t);
    if (ring.current) ring.current.style.transform = `translateZ(calc(var(--r) * -1)) rotateX(${turn * step}deg)`;
    const i = Math.min(n - 1, Math.round(f));
    if (i !== shown.current) { shown.current = i; setIdx(i); if (onChapter) onChapter(i); }
    if (bar.current) bar.current.style.transform = `scaleY(${clamp01(p)})`;
  }, { stageRef });

  // every control seeks by scrolling the page
  const go = (i) => {
    if (!ref.current) return;
    const k = Math.max(0, Math.min(n - 1, i));
    scrollToProgress(ref.current, n > 1 ? k / (n - 1) : 0, { stage: stageRef.current, nudge: 1 });
  };
  const goRef = useRef(go);
  goRef.current = go;

  // drag the drum: a vertical mouse/pen drag scrolls the pinned track
  useEffect(() => {
    const vp = vpRef.current;
    if (!vp) return undefined;
    const swallow = (ev) => { ev.preventDefault(); ev.stopPropagation(); };
    const down = (e) => {
      if (e.button !== 0 || e.pointerType === "touch") return;
      drag.current = { y: e.clientY, s: window.scrollY, moved: false };
    };
    const move = (e) => {
      const d = drag.current;
      if (!d) return;
      const dy = e.clientY - d.y;
      if (Math.abs(dy) > 4) { d.moved = true; vp.classList.add("dragging"); }
      if (d.moved) window.scrollTo({ top: d.s - dy * 3.2, behavior: "instant" });
    };
    const up = () => {
      const d = drag.current;
      drag.current = null;
      vp.classList.remove("dragging");
      if (d && d.moved) {
        const p = ScrollMotion.progressOf(ref.current, "pin", stageRef.current);
        goRef.current(Math.round(p * (n - 1)));
        vp.addEventListener("click", swallow, { capture: true, once: true }); // the click that ends a drag is not a click
        setTimeout(() => vp.removeEventListener("click", swallow, { capture: true }), 50);
      }
    };
    vp.addEventListener("pointerdown", down);
    window.addEventListener("pointermove", move, { passive: true });
    window.addEventListener("pointerup", up);
    return () => {
      vp.removeEventListener("pointerdown", down);
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
    };
  }, [n]);

  const onKey = (e) => {
    const k = e.key;
    if (k === "ArrowDown" || k === "ArrowRight") { e.preventDefault(); go(idx + 1); }
    else if (k === "ArrowUp" || k === "ArrowLeft") { e.preventDefault(); go(idx - 1); }
    else if (k === "Home") { e.preventDefault(); go(0); }
    else if (k === "End") { e.preventDefault(); go(n - 1); }
  };

  const c = chapters[idx];
  return (
    <section className="sd" ref={ref} style={{ height: `calc(${n} * ${vhPerChapter}vh + 100vh)` }} aria-label={title}>
      <div className="sd-stage" ref={stageRef}>
        <div className="sd-copy">
          <p className="sd-k">{kicker} · {pad2(idx + 1)} / {pad2(n)}</p>
          <h2 className="sd-title">{title}</h2>
          {/* key={idx} remounts the caption so its CSS entrance plays per chapter */}
          <div className="sd-cap" key={idx}>
            <p className="sd-n">{c.n}</p>
            <h3 className="sd-h">{c.t}</h3>
            {c.d ? <p className="sd-d">{c.d}</p> : null}
            {c.href ? <Go className="sd-cta" href={c.href}>{c.cta || "Open"} <span aria-hidden="true">→</span></Go> : null}
          </div>
          <div className="sd-nav">
            <button type="button" onClick={() => go(idx - 1)} disabled={idx === 0} aria-label="Previous chapter">‹</button>
            <button type="button" onClick={() => go(idx + 1)} disabled={idx === n - 1} aria-label="Next chapter">›</button>
            <span className="sd-tip">scroll, drag or use the arrow keys</span>
          </div>
        </div>
        <div className="sd-viewport" ref={vpRef} tabIndex={0} onKeyDown={onKey} aria-label={`${title} Arrow keys change chapter.`}>
          <div className="sd-ring" ref={ring}>
            {chapters.map((ch, i) => (
              <Go
                key={ch.n}
                href={ch.href || "#"}
                className={`sd-face${i === idx ? " on" : ""}`}
                style={{ transform: `rotateX(${-i * step}deg) translateZ(var(--r))` }}
                tabIndex={i === idx ? 0 : -1}
                aria-hidden={i !== idx}
                draggable={false}
              >
                {/* only the faces next to the one you see carry their art */}
                <span className="sd-face-art">{Math.abs(i - idx) <= 1 ? ch.art || null : null}</span>
                <span className="sd-face-meta">
                  <span className="sd-face-n">{pad2(i + 1)}</span>
                  <span className="sd-face-t">{ch.n}</span>
                  <span className="sd-face-go" aria-hidden="true">↗</span>
                </span>
              </Go>
            ))}
          </div>
        </div>
        <ol className="sd-rail" aria-label="Chapters">
          <i aria-hidden="true"><i ref={bar} /></i>
          {chapters.map((ch, i) => (
            <li key={ch.n} className={i === idx ? "on" : i < idx ? "done" : ""}>
              <button type="button" onClick={() => go(i)} aria-current={i === idx ? "step" : undefined}>{ch.n}</button>
            </li>
          ))}
        </ol>
      </div>
    </section>
  );
}

/* A small drum that rolls on its own (CSS keyframes) for a preview card.
   Purely decorative: aria-hidden, and still under reduced motion. Uses
   the four-face .sdp rules in scroll-motion.css. */
export function DrumPreview({ faces }) {
  const step = 360 / faces.length;
  return (
    <span className="sdp" aria-hidden="true">
      <span className="sdp-ring">
        {faces.map((art, i) => (
          <span className="sdp-face" key={i} style={{ transform: `rotateX(${-i * step}deg) translateZ(var(--r))` }}>{art}</span>
        ))}
      </span>
    </span>
  );
}
````


---


## assets/demo.html


````html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll Motion Demo</title>
<meta name="description" content="Five canvas reels scrubbed by scroll, captions the drawing never touches, and an interactive story drum. Zero dependencies. ImuTechz, MIT.">
<!--
  scroll-motion-graphics · demo.html · ImuTechz · MIT

  One file, zero dependencies: double-click it. Everything the page
  needs is inlined below, in this order:
    1. scroll-motion.css   (the film + drum styles, light/dark tokens)
    2. page styles         (intro, interlude, outro)
    3. scroll-motion.js    (the engine: scrub, film, drum, helpers)
    4. reels.js            (the five reels: orbit, path, stack, wave, close)
    5. page wiring         (≈60 lines at the bottom: read this first)
  The inlined copies are identical to the files next to this one.
-->
<style>
/*! scroll-motion.css v1.0.0 · ImuTechz · MIT
   The film (.smf) and the story drum (.sd). Colours are tokens on :root;
   the canvas reads the same tokens (ScrollMotion.readPalette), so light
   and dark mode are this one stylesheet. */

:root {
  --sm-bg: #f3ede2;
  --sm-surface: #fbf8f2;
  --sm-ink: #1c1914;
  --sm-muted: #5c554b;
  --sm-dim: #8a8277;
  --sm-line: rgba(28, 25, 20, 0.12);
  --sm-line-strong: rgba(28, 25, 20, 0.22);
  --sm-accent: #f2661a;
  --sm-accent-2: #d9481e;
  --sm-accent-soft: rgba(242, 102, 26, 0.18);
  --sm-cool: #2e6c96;
  --sm-card: rgba(255, 255, 255, 0.74);
  --sm-card-line: rgba(255, 255, 255, 0.9);
  --sm-shadow: rgba(28, 25, 20, 0.4);
  --sm-on-ink: #fbf8f2;
  --sm-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --sm-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
  --sm-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --sm-nav-h: 0px; /* height of a fixed site header, if the page has one */
  color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --sm-bg: #0d0c0b; --sm-surface: #171513; --sm-ink: #f4efe7; --sm-muted: #b9b0a3; --sm-dim: #8a8277;
    --sm-line: rgba(244, 239, 231, 0.12); --sm-line-strong: rgba(244, 239, 231, 0.24);
    --sm-accent: #ff7a33; --sm-accent-2: #f2661a; --sm-accent-soft: rgba(255, 122, 51, 0.22); --sm-cool: #6fb2dc;
    --sm-card: rgba(24, 22, 20, 0.78); --sm-card-line: rgba(255, 255, 255, 0.12); --sm-shadow: rgba(0, 0, 0, 0.9); --sm-on-ink: #0d0c0b;
  }
}
:root[data-theme="dark"] {
  --sm-bg: #0d0c0b; --sm-surface: #171513; --sm-ink: #f4efe7; --sm-muted: #b9b0a3; --sm-dim: #8a8277;
  --sm-line: rgba(244, 239, 231, 0.12); --sm-line-strong: rgba(244, 239, 231, 0.24);
  --sm-accent: #ff7a33; --sm-accent-2: #f2661a; --sm-accent-soft: rgba(255, 122, 51, 0.22); --sm-cool: #6fb2dc;
  --sm-card: rgba(24, 22, 20, 0.78); --sm-card-line: rgba(255, 255, 255, 0.12); --sm-shadow: rgba(0, 0, 0, 0.9); --sm-on-ink: #0d0c0b;
}

.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

/* ───────────── the film ───────────── */

/* the track: tall, so scrolling through it is the timeline. Never put
   overflow: hidden/auto on an ancestor of the track, or sticky breaks. */
.smf { position: relative; height: calc(var(--smf-n, 5) * 90svh + 100svh); }
.smf-stage { position: sticky; top: 0; height: 100svh; overflow: hidden; color: var(--sm-ink); }
.smf-canvas { position: absolute; inset: 0; display: block; pointer-events: none; }
.smf-frame { position: absolute; inset: calc(var(--sm-nav-h) + 16px) clamp(16px, 4vw, 48px) 20px; pointer-events: none; }
.smf-frame > * { pointer-events: auto; }

.smf-hud { position: absolute; left: 0; right: 0; top: 0; display: flex; align-items: center; gap: 16px; font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--sm-dim); }
.smf-hud-t { color: var(--sm-ink); }
.smf-hud-bar { flex: 1; height: 1.5px; background: var(--sm-line-strong); position: relative; overflow: hidden; }
.smf-hud-bar > i { position: absolute; inset: 0; background: var(--sm-accent); transform-origin: left; transform: scaleX(0); }

.smf-cap {
  position: absolute; width: min(400px, 100%); padding: 22px 24px 24px; border-radius: 20px;
  background: var(--sm-card); border: 1px solid var(--sm-card-line);
  -webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4);
  box-shadow: 0 18px 50px -26px var(--sm-shadow);
  opacity: 0; visibility: hidden; will-change: opacity, transform;
}
.smf-cap[data-place="left"] { left: 0; top: 50%; margin-top: -110px; }
.smf-cap[data-place="right"] { right: 0; top: 50%; margin-top: -110px; }
.smf-cap[data-place="bottom"] { left: 0; bottom: 0; width: min(560px, 100%); }
.smf-cap[data-place="top"] { left: 0; top: 44px; }
.smf-cap-k { display: flex; justify-content: space-between; gap: 12px; margin: 0 0 12px; font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--sm-accent-2); }
.smf-cap-h { margin: 0 0 10px; font-size: clamp(21px, 2.2vw, 28px); line-height: 1.12; letter-spacing: -0.02em; font-weight: 600; }
.smf-cap-d { margin: 0; font-size: 14.5px; line-height: 1.55; color: var(--sm-muted); }
.smf-hint { position: absolute; right: 0; bottom: 0; margin: 0; font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--sm-dim); }

@media (max-width: 759px) {
  .smf { height: calc(var(--smf-n, 5) * 80svh + 100svh); }
  .smf-frame { inset: calc(var(--sm-nav-h) + 12px) 16px 16px; }
  .smf-cap, .smf-cap[data-place] { left: 0; right: 0; top: auto; bottom: 0; width: auto; margin: 0; padding: 16px 18px 18px; }
  .smf-cap-d { font-size: 14px; }
  .smf-hint { display: none; }
  .smf-hud-x { display: none; }
}

/* ───────────── the story drum ───────────── */

.sd { position: relative; }
.sd-stage {
  position: sticky; top: 0; height: 100svh; max-width: 1320px; margin: 0 auto;
  padding: var(--sm-nav-h) clamp(16px, 4vw, 40px) 0;
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1.1fr) 180px; align-items: center; gap: clamp(20px, 3.5vw, 52px);
  color: var(--sm-ink);
}
.sd-k { margin: 0 0 12px; font-family: var(--sm-mono); font-size: 11.5px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--sm-dim); }
.sd-title { margin: 0 0 26px; font-size: clamp(30px, 3.6vw, 50px); line-height: 1.02; letter-spacing: -0.03em; }
.sd-cap { min-height: 250px; animation: sd-in 0.55s var(--sm-ease) both; }
.sd-cap[hidden] { display: none; }
@keyframes sd-in { from { opacity: 0; transform: translateY(18px); filter: blur(5px); } }
.sd-n { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: var(--sm-accent-2); }
.sd-h { margin: 0 0 12px; font-size: clamp(22px, 2.2vw, 30px); line-height: 1.15; letter-spacing: -0.02em; }
.sd-d { margin: 0 0 18px; max-width: 42ch; font-size: 15.5px; line-height: 1.6; color: var(--sm-muted); }
.sd-cta { display: inline-flex; gap: 8px; padding: 11px 18px; border-radius: 999px; background: var(--sm-ink); color: var(--sm-on-ink); font-weight: 600; font-size: 14px; text-decoration: none; transition: transform 0.3s; }
.sd-cta:hover { transform: translateY(-2px); }
.sd-cta:focus-visible, .sd-nav button:focus-visible, .sd-rail button:focus-visible { outline: 2px solid var(--sm-accent); outline-offset: 3px; }
.sd-nav { display: flex; align-items: center; gap: 8px; margin-top: 26px; }
.sd-nav button { width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--sm-line-strong); background: var(--sm-surface); color: var(--sm-ink); font-size: 20px; line-height: 1; cursor: pointer; transition: background 0.2s, color 0.2s; }
.sd-nav button:hover:not(:disabled) { background: var(--sm-ink); color: var(--sm-on-ink); }
.sd-nav button:disabled { opacity: 0.35; cursor: default; }
.sd-tip { margin-left: 8px; font-size: 12px; color: var(--sm-dim); }

.sd-viewport {
  --r: clamp(180px, 19vw, 260px);
  position: relative; height: min(74vh, 600px); perspective: 1300px; display: grid; place-items: center;
  cursor: grab; outline: none; touch-action: pan-y; /* touch scrolls the page natively */
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent);
}
.sd-viewport.dragging { cursor: grabbing; }
.sd-viewport:focus-visible .sd-face.on { outline: 2px solid var(--sm-accent); outline-offset: 4px; }
.sd-ring { position: relative; width: 100%; height: calc(var(--r) * 1.12); transform-style: preserve-3d; will-change: transform; }
.sd-face {
  position: absolute; inset: 0; display: grid; grid-template-rows: 1fr auto; padding: 10px; border-radius: 22px;
  text-decoration: none; color: var(--sm-ink); background: var(--sm-surface); border: 1px solid var(--sm-line-strong);
  backface-visibility: hidden; -webkit-backface-visibility: hidden; user-select: none; -webkit-user-drag: none;
  transition: border-color 0.4s, box-shadow 0.4s;
}
.sd-face.on { border-color: var(--sm-accent); box-shadow: 0 30px 70px -40px var(--sm-shadow), 0 0 0 4px var(--sm-accent-soft); }
.sd-face-art { position: relative; border-radius: 14px; overflow: hidden; background: var(--sm-bg); }
.sd-face-art canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.sd-face-meta { display: flex; align-items: center; gap: 12px; padding: 12px 6px 4px; }
.sd-face-n { font-family: var(--sm-mono); font-size: 11px; color: var(--sm-dim); }
.sd-face-t { flex: 1; font-weight: 600; font-size: 16px; }
.sd-face-go { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: var(--sm-ink); color: var(--sm-on-ink); font-size: 14px; transition: transform 0.3s; }
.sd-face.on:hover .sd-face-go { transform: rotate(45deg); }

.sd-rail { position: relative; list-style: none; margin: 0; padding: 0 0 0 22px; display: grid; gap: 14px; }
.sd-rail > i { position: absolute; left: 0; top: 6px; bottom: 6px; width: 1.5px; background: var(--sm-line-strong); }
.sd-rail > i > i { position: absolute; inset: 0; background: var(--sm-accent); transform-origin: top; transform: scaleY(0); }
.sd-rail button { border: 0; background: none; padding: 2px 0; font: inherit; font-family: var(--sm-mono); font-size: 11.5px; letter-spacing: 0.12em; text-transform: uppercase; text-align: left; color: var(--sm-dim); cursor: pointer; transition: color 0.3s, transform 0.3s; }
.sd-rail li.done button { color: var(--sm-muted); }
.sd-rail li.on button { color: var(--sm-ink); transform: translateX(4px); }
.sd-rail button:hover { color: var(--sm-accent-2); }

@media (max-width: 1000px) {
  .sd-stage { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto; align-content: center; gap: 18px; padding-top: calc(var(--sm-nav-h) + 8px); }
  .sd-rail, .sd-title, .sd-tip { display: none; }
  .sd-d { display: none; }
  .sd-nav { margin-top: 12px; }
  .sd-nav button { width: 44px; height: 44px; } /* touch targets */
  .sd-viewport { --r: clamp(150px, 40vw, 220px); height: min(52vh, 440px); }
  .sd-copy { order: 2; }
  .sd-cap { min-height: 150px; }
  .sd-h { font-size: 22px; }
}

@media (prefers-reduced-motion: reduce) {
  .sd-cap { animation: none; }
  .sd-face, .sd-rail button, .sd-cta, .sd-face-go { transition: none; }
}

/* ───────────── a self-rolling preview drum (decorative) ───────────── */

.sdp { --r: clamp(90px, 11vw, 150px); --sdp-n: 4; position: absolute; inset: 0; display: grid; place-items: center; perspective: 900px;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent); mask-image: linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent); }
.sdp-ring { position: relative; width: 70%; height: calc(var(--r) * 1.12); transform-style: preserve-3d; animation: sdp-roll 12s cubic-bezier(0.7, 0, 0.3, 1) infinite; }
.sdp-face { position: absolute; inset: 0; border-radius: 16px; overflow: hidden; background: var(--sm-surface); border: 1px solid var(--sm-line-strong); backface-visibility: hidden; -webkit-backface-visibility: hidden; }
/* four faces: dwell 18% of the loop on each, roll in 7% */
@keyframes sdp-roll {
  0%, 18% { transform: translateZ(calc(var(--r) * -1)) rotateX(0deg); }
  25%, 43% { transform: translateZ(calc(var(--r) * -1)) rotateX(90deg); }
  50%, 68% { transform: translateZ(calc(var(--r) * -1)) rotateX(180deg); }
  75%, 93% { transform: translateZ(calc(var(--r) * -1)) rotateX(270deg); }
  100% { transform: translateZ(calc(var(--r) * -1)) rotateX(360deg); }
}
@media (prefers-reduced-motion: reduce) { .sdp-ring { animation: none; transform: translateZ(calc(var(--r) * -1)); } }
</style>
<style>
/* ── page ── */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
body { margin: 0; background: var(--sm-bg); color: var(--sm-ink); font-family: var(--sm-font); line-height: 1.5; }
a { color: inherit; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 48px); }
.intro { min-height: 92svh; display: grid; align-content: center; padding: 64px 0 48px; }
.eyebrow { margin: 0 0 18px; font-family: var(--sm-mono); font-size: 11.5px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--sm-dim); }
.intro h1 { margin: 0 0 20px; font-size: clamp(40px, 7.4vw, 104px); line-height: 0.96; letter-spacing: -0.045em; font-weight: 650; }
.intro h1 b { color: var(--sm-accent); font-weight: inherit; }
.lede { max-width: 58ch; margin: 0 0 28px; font-size: clamp(16px, 1.5vw, 19px); color: var(--sm-muted); }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 32px; padding: 0; list-style: none; }
.chips li { padding: 6px 12px; border: 1px solid var(--sm-line-strong); border-radius: 999px; font-size: 13px; color: var(--sm-muted); }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; }
.btn { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 18px; border-radius: 999px; border: 1px solid var(--sm-line-strong); background: var(--sm-surface); color: var(--sm-ink); font: 600 14px var(--sm-font); text-decoration: none; cursor: pointer; }
.btn-solid { background: var(--sm-ink); color: var(--sm-on-ink); border-color: transparent; }
.btn:focus-visible { outline: 2px solid var(--sm-accent); outline-offset: 3px; }
.down { font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--sm-dim); }
.mid { padding: clamp(80px, 14vh, 160px) 0; }
.mid h2, .outro h2 { margin: 0 0 16px; font-size: clamp(28px, 4vw, 52px); line-height: 1.04; letter-spacing: -0.03em; }
.mid p { max-width: 60ch; color: var(--sm-muted); font-size: 17px; margin: 0; }
.outro { padding: clamp(80px, 14vh, 160px) 0 64px; }
.rules { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 14px; margin: 28px 0 40px; padding: 0; list-style: none; counter-reset: r; }
.rules li { counter-increment: r; padding: 20px 20px 22px; border: 1px solid var(--sm-line); border-radius: 18px; background: var(--sm-surface); font-size: 14.5px; color: var(--sm-muted); }
.rules li::before { content: counter(r, decimal-leading-zero); display: block; margin-bottom: 10px; font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.16em; color: var(--sm-accent-2); }
.rules b { display: block; margin-bottom: 4px; color: var(--sm-ink); font-size: 15.5px; }
footer { padding: 28px 0 40px; border-top: 1px solid var(--sm-line); font-size: 13px; color: var(--sm-dim); }
.smf-anchor { position: absolute; left: 0; width: 1px; height: 1px; }
</style>
</head>
<body>

<header class="wrap intro">
  <p class="eyebrow">ImuTechz skill · scroll-motion-graphics · live demo</p>
  <h1>Scroll motion<br>graphics<b>.</b></h1>
  <p class="lede">A canvas film whose only clock is your scroll position. Five reels, each a pure function of progress, so it seeks both ways like a video. Captions are measured every frame and the drawing never runs under them. Then the same timeline becomes a control: a story drum you can scroll, drag, click or step with the arrow keys.</p>
  <ul class="chips" aria-label="Properties">
    <li>Canvas 2D</li><li>Zero dependencies</li><li>Pure functions of p</li><li>Never pins input</li><li>Reduced motion</li><li>360 → 1920</li>
  </ul>
  <div class="row">
    <a class="btn btn-solid" href="#reel-orbit">Start the film</a>
    <a class="btn" href="#drum">Jump to the drum</a>
    <button class="btn" type="button" id="theme" aria-live="polite">Theme: auto</button>
    <span class="down" aria-hidden="true">Scroll ↓</span>
  </div>
</header>

<!-- ───────── the film: a tall track, a sticky stage, one canvas ───────── -->
<section class="smf" id="film" style="--smf-n: 5" aria-label="A film in five reels, scrubbed by scroll">
  <a class="smf-anchor" id="reel-orbit" data-at="0.5" tabindex="-1" aria-hidden="true"></a>
  <a class="smf-anchor" id="reel-path" data-at="1.55" tabindex="-1" aria-hidden="true"></a>
  <a class="smf-anchor" id="reel-stack" data-at="2.55" tabindex="-1" aria-hidden="true"></a>
  <a class="smf-anchor" id="reel-wave" data-at="3.55" tabindex="-1" aria-hidden="true"></a>
  <a class="smf-anchor" id="reel-close" data-at="4.7" tabindex="-1" aria-hidden="true"></a>
  <div class="smf-stage">
    <canvas class="smf-canvas" aria-hidden="true"></canvas>
    <div class="smf-frame">
      <div class="smf-hud" data-keep>
        <span class="smf-hud-t">Five reels</span>
        <span data-sm-count>01 / 05</span>
        <span class="smf-hud-bar" aria-hidden="true"><i data-sm-bar></i></span>
        <span class="smf-hud-x">scroll to scrub</span>
      </div>
      <article class="smf-cap" data-place="left" data-reel="orbit">
        <p class="smf-cap-k"><span>01 · Orbit</span><span>Pin a stage</span></p>
        <h3 class="smf-cap-h">One timeline, many moving parts.</h3>
        <p class="smf-cap-d">The section is a tall track and the stage inside it is sticky. How far you have scrolled through the track is p, from 0 to 1, and it is the only clock this film has.</p>
      </article>
      <article class="smf-cap" data-place="bottom" data-reel="path">
        <p class="smf-cap-k"><span>02 · Path</span><span>Scrub, don't play</span></p>
        <h3 class="smf-cap-h">Every frame is a function of p.</h3>
        <p class="smf-cap-d">Scroll back and the route un-draws. The same scroll position always paints the same frame, so it seeks both ways like a video.</p>
      </article>
      <article class="smf-cap" data-place="right" data-reel="stack">
        <p class="smf-cap-k"><span>03 · Stack</span><span>Stagger</span></p>
        <h3 class="smf-cap-h">Build in order, land in place.</h3>
        <p class="smf-cap-d">Each bar owns a window inside the reel, and an ease-out makes it land rather than stop. The trend line waits until the bars have settled.</p>
      </article>
      <article class="smf-cap" data-place="top" data-reel="wave">
        <p class="smf-cap-k"><span>04 · Wave</span><span>No clock</span></p>
        <h3 class="smf-cap-h">Rhythm without a timer.</h3>
        <p class="smf-cap-d">The phase comes from p, not from time. When you stop scrolling the film stops drawing, so an idle page costs nothing.</p>
      </article>
      <article class="smf-cap" data-place="left" data-reel="close">
        <p class="smf-cap-k"><span>05 · Close</span><span>Resolve</span></p>
        <h3 class="smf-cap-h">Everything lands on one mark.</h3>
        <p class="smf-cap-d">Captions are measured every frame. Reels lay out in the space they leave, and the canvas is clipped around them, so the drawing never runs under the words.</p>
      </article>
      <p class="smf-hint" data-keep>Scroll back: it rewinds</p>
    </div>
  </div>
</section>

<section class="wrap mid">
  <p class="eyebrow">Interlude</p>
  <h2>Now the timeline is a control.</h2>
  <p>The drum below is rolled by the same scroll engine. Every face is a link to its reel in the film above. The rail and the arrow buttons scroll the page to a chapter, so the scroll position and the drum can never disagree. Drag it with a mouse, or focus it and use the arrow keys.</p>
</section>

<!-- ───────── the story drum ───────── -->
<section class="sd" id="drum" style="height: calc(5 * 70vh + 100vh)" aria-label="Story drum: the five reels">
  <div class="sd-stage">
    <div class="sd-copy">
      <p class="sd-k">Roll it · <span data-sd-count>01 / 05</span></p>
      <h2 class="sd-title">Roll the drum.</h2>
      <div class="sd-cap" data-sd-cap>
        <p class="sd-n">Orbit</p>
        <h3 class="sd-h">A tall track, a sticky stage.</h3>
        <p class="sd-d">The section is several screens tall and the stage inside it is position: sticky. Progress through the track is the timeline.</p>
        <a class="sd-cta" href="#reel-orbit">Watch the reel <span aria-hidden="true">→</span></a>
      </div>
      <div class="sd-cap" data-sd-cap hidden>
        <p class="sd-n">Path</p>
        <h3 class="sd-h">Scrub, don't play.</h3>
        <p class="sd-d">Nothing runs on a clock. The same scroll position always draws the same frame, forwards or backwards.</p>
        <a class="sd-cta" href="#reel-path">Watch the reel <span aria-hidden="true">→</span></a>
      </div>
      <div class="sd-cap" data-sd-cap hidden>
        <p class="sd-n">Stack</p>
        <h3 class="sd-h">Faces on a cylinder, rotateX by p.</h3>
        <p class="sd-d">A smoothstep inside each step makes the drum dwell on a face, then roll to the next one.</p>
        <a class="sd-cta" href="#reel-stack">Watch the reel <span aria-hidden="true">→</span></a>
      </div>
      <div class="sd-cap" data-sd-cap hidden>
        <p class="sd-n">Wave</p>
        <h3 class="sd-h">Links, buttons, keys and drag.</h3>
        <p class="sd-d">Faces are links. The rail and the arrows jump by scrolling the page, so the scroll and the drum never disagree.</p>
        <a class="sd-cta" href="#reel-wave">Watch the reel <span aria-hidden="true">→</span></a>
      </div>
      <div class="sd-cap" data-sd-cap hidden>
        <p class="sd-n">Close</p>
        <h3 class="sd-h">Never pin input. Honour reduced motion.</h3>
        <p class="sd-d">The page always scrolls normally. The loop idles when nothing moves, and reduced motion gets each face without the roll.</p>
        <a class="sd-cta" href="#reel-close">Watch the reel <span aria-hidden="true">→</span></a>
      </div>
      <div class="sd-nav">
        <button type="button" data-sd-prev aria-label="Previous chapter">‹</button>
        <button type="button" data-sd-next aria-label="Next chapter">›</button>
        <span class="sd-tip">scroll, drag or use the arrow keys</span>
      </div>
    </div>
    <div class="sd-viewport" tabindex="0" aria-label="Story drum. Arrow keys change chapter.">
      <div class="sd-ring">
        <a class="sd-face" href="#reel-orbit" data-reel="orbit"><span class="sd-face-art"><canvas aria-hidden="true"></canvas></span><span class="sd-face-meta"><span class="sd-face-n">01</span><span class="sd-face-t">Orbit</span><span class="sd-face-go" aria-hidden="true">↗</span></span></a>
        <a class="sd-face" href="#reel-path" data-reel="path"><span class="sd-face-art"><canvas aria-hidden="true"></canvas></span><span class="sd-face-meta"><span class="sd-face-n">02</span><span class="sd-face-t">Path</span><span class="sd-face-go" aria-hidden="true">↗</span></span></a>
        <a class="sd-face" href="#reel-stack" data-reel="stack"><span class="sd-face-art"><canvas aria-hidden="true"></canvas></span><span class="sd-face-meta"><span class="sd-face-n">03</span><span class="sd-face-t">Stack</span><span class="sd-face-go" aria-hidden="true">↗</span></span></a>
        <a class="sd-face" href="#reel-wave" data-reel="wave"><span class="sd-face-art"><canvas aria-hidden="true"></canvas></span><span class="sd-face-meta"><span class="sd-face-n">04</span><span class="sd-face-t">Wave</span><span class="sd-face-go" aria-hidden="true">↗</span></span></a>
        <a class="sd-face" href="#reel-close" data-reel="close"><span class="sd-face-art"><canvas aria-hidden="true"></canvas></span><span class="sd-face-meta"><span class="sd-face-n">05</span><span class="sd-face-t">Close</span><span class="sd-face-go" aria-hidden="true">↗</span></span></a>
      </div>
    </div>
    <ol class="sd-rail" aria-label="Chapters">
      <i aria-hidden="true"><i data-sd-bar></i></i>
      <li><button type="button" data-sd-go="0">Orbit</button></li>
      <li><button type="button" data-sd-go="1">Path</button></li>
      <li><button type="button" data-sd-go="2">Stack</button></li>
      <li><button type="button" data-sd-go="3">Wave</button></li>
      <li><button type="button" data-sd-go="4">Close</button></li>
    </ol>
  </div>
</section>

<section class="wrap outro">
  <p class="eyebrow">The contract</p>
  <h2>Six rules this page keeps.</h2>
  <ul class="rules">
    <li><b>Scroll is the clock.</b>p in [0, 1] comes from the pinned track. Nothing animates on a timer.</li>
    <li><b>Reels are pure.</b>draw(ctx, t, env) has no state and no randomness, so it scrubs both ways.</li>
    <li><b>Words live in the DOM.</b>Captions are real text, measured every frame; the canvas lays out around them and is clipped as a safety net.</li>
    <li><b>Never pin input.</b>No wheel, touch or key capture. Controls seek by scrolling the page.</li>
    <li><b>Idle costs nothing.</b>The loop stops when p settles and when the section leaves the viewport.</li>
    <li><b>Reduced motion.</b>No smoothing, no in-between drum roll, no caption animation. The content is all still there.</li>
  </ul>
  <div class="row">
    <a class="btn btn-solid" href="#film">Watch it again</a>
    <a class="btn" href="#">Back to the top</a>
  </div>
</section>

<footer class="wrap">scroll-motion-graphics v1.0.0 · ImuTechz · MIT. View source: everything is in this one file.</footer>

<script>
/*! scroll-motion.js v1.0.0 · ImuTechz · MIT
 *
 * The engine behind scroll motion graphics: scroll position becomes a
 * progress value p in [0, 1], and every frame is a pure function of p.
 *
 *   ScrollMotion.scrub(el, onFrame, opts)   p for one element (useScrub, vanilla)
 *   ScrollMotion.film(opts)                 a pinned canvas film made of reels
 *   ScrollMotion.drum(root, opts)           an interactive 3D story drum
 *   ScrollMotion.scrollToProgress(el, p)    seek by scrolling the page
 *   ScrollMotion.*                          small math + drawing helpers
 *
 * Rules the engine keeps for you:
 *   - it never captures wheel, touch or keys and never pins the body;
 *     normal scrolling always moves the page
 *   - the rAF loop runs only while p is changing and the element is near
 *     the viewport; an idle page costs nothing
 *   - prefers-reduced-motion: no smoothing, no in-between drum rotation
 *
 * Zero dependencies. Works as a classic <script> (window.ScrollMotion),
 * as CommonJS, or through a bundler's CommonJS interop (import X from).
 */
(function (root, factory) {
  if (typeof module === "object" && module.exports) module.exports = factory();
  else root.ScrollMotion = factory();
})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function () {
  "use strict";

  /* ───────────────────────── math ───────────────────────── */

  const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
  const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
  const lerp = (a, b, t) => a + (b - a) * t;
  /* local progress of p inside the window [a, b] */
  const seg = (p, a, b) => clamp01((p - a) / (b - a || 1));
  const smoothstep = (t) => { t = clamp01(t); return t * t * (3 - 2 * t); };
  const ease = {
    linear: (t) => t,
    inOut: smoothstep,
    outCubic: (t) => 1 - Math.pow(1 - clamp01(t), 3),
    inOutCubic: (t) => { t = clamp01(t); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; },
    outBack: (t) => { t = clamp01(t); const c = 1.70158, c3 = c + 1; return 1 + c3 * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); },
    outExpo: (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * clamp01(t))),
  };
  /* staggered local progress for item i of n: item i starts at i*gap */
  const stagger = (t, i, n, span = 0.5) => {
    const gap = n > 1 ? (1 - span) / (n - 1) : 0;
    return clamp01((t - i * gap) / span);
  };
  /* a deterministic pseudo-random number in [0, 1) — never Math.random in a reel */
  const hash = (i, s = 0) => { const x = Math.sin(i * 127.1 + s * 311.7) * 43758.5453; return x - Math.floor(x); };
  const prefersReduced = () =>
    typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;

  /* ─────────────────────── colours ──────────────────────── */

  /* "#f2661a" | "rgb(…)" → [r, g, b] */
  function parseColor(c, fallback = [28, 25, 20]) {
    c = String(c || "").trim();
    let m = /^#([0-9a-f]{3})$/i.exec(c);
    if (m) return m[1].split("").map((h) => parseInt(h + h, 16));
    m = /^#([0-9a-f]{6})$/i.exec(c);
    if (m) return [0, 2, 4].map((k) => parseInt(m[1].slice(k, k + 2), 16));
    m = /^rgba?\(([^)]+)\)$/i.exec(c);
    if (m) return m[1].split(/[ ,/]+/).slice(0, 3).map(Number);
    return fallback;
  }
  const rgba = (c, a = 1) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
  const mix = (c1, c2, t) => [0, 1, 2].map((k) => Math.round(lerp(c1[k], c2[k], t)));

  /* the canvas palette comes from CSS custom properties on `el`, so light
     and dark mode are one stylesheet change, never a second code path */
  function readPalette(el) {
    const cs = getComputedStyle(el);
    const v = (name, fb) => parseColor(cs.getPropertyValue(name), fb);
    return {
      ink: v("--sm-ink", [28, 25, 20]),
      dim: v("--sm-dim", [138, 130, 119]),
      bg: v("--sm-bg", [243, 237, 226]),
      surface: v("--sm-surface", [251, 248, 242]),
      accent: v("--sm-accent", [242, 102, 26]),
      accent2: v("--sm-accent-2", [217, 72, 30]),
      cool: v("--sm-cool", [46, 108, 150]),
    };
  }

  /* ─────────────────────── geometry ─────────────────────── */

  /* Catmull-Rom through `pts`, sampled by arc length:
     returns { samples: [[x, y, len]], at: [len of each node], total } */
  function route(pts, perSeg = 40) {
    const n = pts.length, samples = [], at = [];
    let total = 0;
    if (!n) return { samples, at, total };
    const P = (i) => pts[clamp(i, 0, n - 1)];
    for (let i = 0; i < n - 1; i++) {
      const p0 = P(i - 1), p1 = P(i), p2 = P(i + 1), p3 = P(i + 2);
      for (let s = 0; s < perSeg; s++) {
        const t = s / perSeg, t2 = t * t, t3 = t2 * t;
        const x = 0.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
        const y = 0.5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
        if (samples.length) { const q = samples[samples.length - 1]; total += Math.hypot(x - q[0], y - q[1]); }
        if (s === 0) at[i] = total;
        samples.push([x, y, total]);
      }
    }
    const last = pts[n - 1], q = samples[samples.length - 1];
    if (q) total += Math.hypot(last[0] - q[0], last[1] - q[1]);
    samples.push([last[0], last[1], total]);
    at[n - 1] = total;
    return { samples, at, total };
  }

  /* stroke the first L pixels of a sampled route; returns the head point */
  function strokeTo(ctx, samples, L) {
    let head = samples[0];
    ctx.beginPath();
    for (let k = 0; k < samples.length; k++) {
      const s = samples[k];
      if (s[2] > L) {
        const a = samples[k - 1] || s, f = (L - a[2]) / Math.max(1e-6, s[2] - a[2]);
        head = [a[0] + (s[0] - a[0]) * f, a[1] + (s[1] - a[1]) * f];
        ctx.lineTo(head[0], head[1]);
        break;
      }
      head = s;
      k ? ctx.lineTo(s[0], s[1]) : ctx.moveTo(s[0], s[1]);
    }
    ctx.stroke();
    return head;
  }

  /* ───────────── keep-outs: where the drawing may not go ───────────── */

  /* el's layout box relative to `stage`, ignoring transforms (so a caption
     that is mid-fade still reserves its resting place) */
  function boxIn(el, stage) {
    let x = 0, y = 0, n = el;
    while (n && n !== stage) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent; }
    if (n !== stage) { // stage is not an offsetParent: fall back to rects
      const a = el.getBoundingClientRect(), b = stage.getBoundingClientRect();
      return { x: a.left - b.left, y: a.top - b.top, w: a.width, h: a.height };
    }
    return { x, y, w: el.offsetWidth, h: el.offsetHeight };
  }
  const pad = (r, d) => ({ x: r.x - d, y: r.y - d, w: r.w + 2 * d, h: r.h + 2 * d });
  const hits = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;

  /* the largest rectangle of [0,0,W,H] that misses every keep-out:
     cut the free area around each keep-out in turn, keeping the side with
     the best area (thin slivers are penalised) */
  function freeRect(W, H, keeps, margin = 0) {
    let F = { x: 0, y: 0, w: W, h: H };
    const score = (r) => (r.w <= 0 || r.h <= 0 ? -1 : r.w * r.h * Math.pow(Math.min(r.w, r.h) / Math.max(r.w, r.h), 0.35));
    const list = keeps.filter((k) => k.w > 0 && k.h > 0).sort((a, b) => b.w * b.h - a.w * a.h);
    for (const k of list) {
      if (!hits(F, k)) continue;
      const cands = [
        { x: F.x, y: F.y, w: k.x - F.x, h: F.h },                          // left of it
        { x: k.x + k.w, y: F.y, w: F.x + F.w - (k.x + k.w), h: F.h },      // right of it
        { x: F.x, y: F.y, w: F.w, h: k.y - F.y },                          // above it
        { x: F.x, y: k.y + k.h, w: F.w, h: F.y + F.h - (k.y + k.h) },      // below it
      ];
      F = cands.reduce((best, c) => (score(c) > score(best) ? c : best), cands[0]);
    }
    return { x: F.x + margin, y: F.y + margin, w: Math.max(0, F.w - 2 * margin), h: Math.max(0, F.h - 2 * margin) };
  }

  /* true when a circle at (x, y, r) touches a keep-out */
  const blocked = (keeps, x, y, r = 0) => keeps.some((k) => x + r > k.x && x - r < k.x + k.w && y + r > k.y && y - r < k.y + k.h);

  /* ─────────────────────── scrub ─────────────────────── */

  /* p for one element.
     mode "pin":  el is a tall track whose child (opts.stage, or a 100vh
                  viewport) is position: sticky; p = 0 when the track's top
                  reaches the viewport top, 1 when its bottom does.
     mode "view": p = how far el has travelled through the viewport.
     onFrame(p) runs only while p changes; then the loop stops. */
  function progressOf(el, mode, stage) {
    const r = el.getBoundingClientRect();
    const vh = window.innerHeight || 1;
    if (mode === "pin") {
      const span = el.offsetHeight - (stage ? stage.offsetHeight : vh);
      return span > 4 ? clamp01(-r.top / span) : clamp01(-r.top / vh);
    }
    return clamp01((vh - r.top) / (vh + r.height));
  }

  function scrub(el, onFrame, opts = {}) {
    const { mode = "pin", smooth = 0.18, stage = null, rootMargin = "25% 0px" } = opts;
    const reduce = opts.reduce != null ? opts.reduce : prefersReduced();
    let target = 0, cur = -1, raf = 0, on = false, dead = false;
    const measure = () => { target = progressOf(el, mode, stage); };
    function tick() {
      raf = 0;
      if (!on || dead) return;
      measure();
      const d = target - cur;
      cur = cur < 0 || reduce || Math.abs(d) < 0.0005 ? target : cur + d * smooth;
      onFrame(cur);
      if (cur !== target) raf = requestAnimationFrame(tick);
    }
    const kick = () => { if (on && !raf && !dead) raf = requestAnimationFrame(tick); };
    const io = new IntersectionObserver((es) => {
      const vis = es[es.length - 1].isIntersecting;
      if (vis && !on) { on = true; kick(); }
      else if (!vis && on) { on = false; if (raf) cancelAnimationFrame(raf); raf = 0; }
    }, { rootMargin });
    io.observe(el);
    addEventListener("scroll", kick, { passive: true });
    addEventListener("resize", kick);
    measure(); cur = target; onFrame(cur);
    return {
      kick,
      get p() { return cur; },
      stop() {
        dead = true; on = false; if (raf) cancelAnimationFrame(raf); raf = 0;
        io.disconnect();
        removeEventListener("scroll", kick);
        removeEventListener("resize", kick);
      },
    };
  }

  /* seek: scroll the page so a pinned track sits at progress p. Seeking is
     always done by scrolling, so the scroll position stays the one truth. */
  function scrollToProgress(el, p, opts = {}) {
    const stage = opts.stage || null;
    const top = el.getBoundingClientRect().top + window.scrollY;
    const run = el.offsetHeight - (stage ? stage.offsetHeight : window.innerHeight);
    const smooth = opts.smooth != null ? opts.smooth : !prefersReduced();
    window.scrollTo({ top: Math.round(top + run * clamp01(p)) + (opts.nudge || 0), behavior: smooth ? "smooth" : "instant" });
    // "instant", not "auto": auto obeys a page's CSS scroll-behavior: smooth
  }

  /* ─────────────────────── film ─────────────────────── */

  /* A pinned canvas film. Each reel owns an equal slice of p.
       reels:    [{ name, draw(ctx, t, env) }]   t ∈ [0,1] local progress
       captions: [element | null]              one per reel (DOM, data-keep)
     env = { W, H, mobile, colors, keep, free, alpha, p, index, reduce }
     A reel must be a pure function of (t, env): no state, no clock. */
  function film(opts) {
    const { track, stage, canvas, reels, captions = [], smooth = 0.14, dprMax = 2, onChange } = opts;
    const cross = opts.cross != null ? opts.cross : 0.12; // reel crossfade half-width, in reel units
    const capFade = opts.capFade != null ? opts.capFade : 0.1; // caption fade length, in reel units
    const keepPad = opts.keepPad != null ? opts.keepPad : 14;
    const ctx = canvas.getContext("2d");
    const n = reels.length;
    const reduce = opts.reduce != null ? opts.reduce : prefersReduced();
    const fixedKeeps = Array.from(stage.querySelectorAll("[data-keep]")).filter((el) => !captions.includes(el));
    const bar = stage.querySelector("[data-sm-bar]");
    const count = stage.querySelector("[data-sm-count]");
    let W = 0, H = 0, dpr = 1, colors = readPalette(stage), active = -1, painted = -1;
    let lastKeeps = [];

    function layout() {
      const r = stage.getBoundingClientRect();
      W = r.width; H = r.height;
      dpr = Math.min(window.devicePixelRatio || 1, dprMax);
      canvas.width = Math.max(1, Math.round(W * dpr));
      canvas.height = Math.max(1, Math.round(H * dpr));
      canvas.style.width = W + "px"; canvas.style.height = H + "px";
      colors = readPalette(stage);
      painted = -1;
    }

    /* the timeline, all pure functions of p */
    const local = (p, i) => p * n - i; // unclamped reel units: 0..1 is reel i's own slice
    function reelAlpha(p, i) {
      const u = local(p, i);
      const fin = i === 0 ? 1 : smoothstep((u + cross) / (2 * cross));
      const fout = i === n - 1 ? 1 : smoothstep((1 + cross - u) / (2 * cross));
      return Math.min(fin, fout);
    }
    /* captions sit inside their reel and leave a gap of 2*cross between
       neighbours, so two captions are never on screen at once */
    function capAlpha(p, i) {
      const u = local(p, i);
      const fin = i === 0 ? 1 : seg(u, cross, cross + capFade);
      const fout = i === n - 1 ? 1 : 1 - seg(u, 1 - cross - capFade, 1 - cross);
      return Math.min(fin, fout);
    }

    function paint(p) {
      // 1. writes: captions and HUD, all derived from p
      const caps = [];
      for (let i = 0; i < n; i++) {
        const el = captions[i];
        if (!el) continue;
        const a = capAlpha(p, i);
        caps[i] = a;
        el.style.opacity = a.toFixed(3);
        el.style.transform = reduce ? "none" : `translate3d(0, ${((1 - a) * 14).toFixed(1)}px, 0)`;
        el.style.visibility = a > 0.001 ? "visible" : "hidden";
      }
      if (bar) bar.style.transform = `scaleX(${p.toFixed(4)})`;
      const now = Math.min(n - 1, Math.floor(p * n));
      if (now !== active) {
        active = now;
        if (count) count.textContent = String(now + 1).padStart(2, "0") + " / " + String(n).padStart(2, "0");
        if (onChange) onChange(now, reels[now]);
      }

      // 2. reads: measure every keep-out this frame (one layout, if any)
      const fixed = fixedKeeps.filter((el) => el.offsetParent).map((el) => pad(boxIn(el, stage), keepPad));
      const own = captions.map((el) => (el && el.offsetParent ? pad(boxIn(el, stage), keepPad) : null));
      const visibleCaps = own.filter((r, i) => r && caps[i] > 0.001);
      lastKeeps = fixed.concat(visibleCaps);

      // 3. draw: clear, clip out every visible keep-out, then the reels
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, W, H);
      ctx.save();
      ctx.beginPath();
      ctx.rect(0, 0, W, H);
      for (const k of lastKeeps) ctx.rect(k.x, k.y, k.w, k.h);
      ctx.clip("evenodd"); // safety net: nothing can paint under a caption
      const mobile = W < 760;
      for (let i = 0; i < n; i++) {
        const alpha = reelAlpha(p, i);
        if (alpha <= 0.001) continue;
        const keep = fixed.concat(own[i] ? [own[i]] : [], visibleCaps);
        const env = {
          W, H, dpr, mobile, colors, keep, reduce, alpha, p, index: i,
          free: freeRect(W, H, keep, mobile ? 12 : 28),
        };
        ctx.save();
        ctx.globalAlpha = alpha;
        reels[i].draw(ctx, clamp01(local(p, i)), env);
        ctx.restore();
      }
      ctx.restore();
    }

    let target = 0, cur = 0, raf = 0, on = false;
    const measure = () => { target = progressOf(track, "pin", stage); };
    function tick() {
      raf = 0;
      if (!on) return;
      measure();
      const d = target - cur;
      cur = reduce || Math.abs(d) < 0.0005 ? target : cur + d * smooth;
      if (cur !== painted) { painted = cur; paint(cur); raf = requestAnimationFrame(tick); }
    }
    const kick = () => { if (on && !raf) raf = requestAnimationFrame(tick); };
    const io = new IntersectionObserver((es) => {
      const vis = es[es.length - 1].isIntersecting;
      if (vis && !on) { on = true; measure(); cur = target; painted = -1; kick(); }
      else if (!vis && on) { on = false; if (raf) cancelAnimationFrame(raf); raf = 0; }
    }, { rootMargin: "40% 0px" });
    io.observe(track);
    const ro = new ResizeObserver(() => { layout(); paint(cur); painted = cur; });
    ro.observe(stage);
    const mq = typeof matchMedia === "function" ? matchMedia("(prefers-color-scheme: dark)") : null;
    const retheme = () => { colors = readPalette(stage); paint(cur); };
    if (mq && mq.addEventListener) mq.addEventListener("change", retheme);
    addEventListener("scroll", kick, { passive: true });
    addEventListener("resize", kick);

    layout(); measure(); cur = target; paint(cur); painted = cur;

    return {
      get p() { return cur; },
      get keeps() { return lastKeeps.slice(); },
      /* paint an arbitrary p without scrolling (tests, posters) */
      renderAt(p) { paint(clamp01(p)); painted = -1; },
      seek(p) { scrollToProgress(track, p, { stage }); },
      seekReel(i, at = 0.5) { scrollToProgress(track, (clamp(i, 0, n - 1) + at) / n, { stage }); },
      retheme,
      destroy() {
        on = false; if (raf) cancelAnimationFrame(raf);
        io.disconnect(); ro.disconnect();
        if (mq && mq.removeEventListener) mq.removeEventListener("change", retheme);
        removeEventListener("scroll", kick);
        removeEventListener("resize", kick);
      },
    };
  }

  /* ─────────────────────── drum ─────────────────────── */

  /* An interactive story drum. Markup (see demo.html / scroll-motion.css):
       .sd (tall track)  > .sd-stage (sticky)
         [data-sd-cap] × n      captions, one per chapter
         .sd-viewport > .sd-ring > a.sd-face × n   faces are links
         [data-sd-go="i"]       rail buttons      [data-sd-prev] [data-sd-next]
         [data-sd-bar]          rail progress     [data-sd-count]
     Rotation is a pure function of scroll. Every control seeks by
     scrolling the page, so the scroll and the drum never disagree. */
  function drum(root, opts = {}) {
    const stage = root.querySelector(".sd-stage") || null;
    const vp = root.querySelector(".sd-viewport");
    const ring = root.querySelector(".sd-ring");
    const faces = Array.from(root.querySelectorAll(".sd-face"));
    const caps = Array.from(root.querySelectorAll("[data-sd-cap]"));
    const rail = Array.from(root.querySelectorAll("[data-sd-go]"));
    const prev = root.querySelector("[data-sd-prev]");
    const next = root.querySelector("[data-sd-next]");
    const bar = root.querySelector("[data-sd-bar]");
    const count = root.querySelector("[data-sd-count]");
    const n = faces.length, step = 360 / n;
    const reduce = opts.reduce != null ? opts.reduce : prefersReduced();
    let idx = -1;

    faces.forEach((f, i) => { f.style.transform = `rotateX(${-i * step}deg) translateZ(var(--r))`; f.draggable = false; });

    function show(i) {
      if (i === idx) return;
      idx = i;
      faces.forEach((f, k) => {
        const on = k === i;
        f.classList.toggle("on", on);
        f.tabIndex = on ? 0 : -1;               // only the face you see is in the tab order
        f.setAttribute("aria-hidden", on ? "false" : "true");
      });
      caps.forEach((c, k) => { c.hidden = k !== i; });
      rail.forEach((b, k) => {
        const li = b.closest("li") || b;
        li.classList.toggle("on", k === i);
        li.classList.toggle("done", k < i);
        if (k === i) b.setAttribute("aria-current", "step"); else b.removeAttribute("aria-current");
      });
      if (prev) prev.disabled = i === 0;
      if (next) next.disabled = i === n - 1;
      if (count) count.textContent = String(i + 1).padStart(2, "0") + " / " + String(n).padStart(2, "0");
      if (opts.onChange) opts.onChange(i);
    }

    const s = scrub(root, (p) => {
      const f = clamp01(p) * (n - 1);
      const base = Math.floor(f), t = f - base;
      // dwell: a smoothstep inside every step parks the drum on a face, then rolls
      const turn = reduce ? Math.round(f) : base + t * t * (3 - 2 * t);
      ring.style.transform = `translateZ(calc(var(--r) * -1)) rotateX(${(turn * step).toFixed(3)}deg)`;
      show(Math.min(n - 1, Math.round(f)));
      if (bar) bar.style.transform = `scaleY(${clamp01(p).toFixed(4)})`;
    }, { stage, smooth: opts.smooth || 0.18, reduce });

    const go = (i) => scrollToProgress(root, clamp(i, 0, n - 1) / (n - 1), { stage, nudge: 1 });

    const onRail = (e) => { const b = e.target.closest("[data-sd-go]"); if (b) go(+b.dataset.sdGo); };
    const onPrev = () => go(idx - 1), onNext = () => go(idx + 1);
    const onKey = (e) => {
      if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); go(idx + 1); }
      else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); go(idx - 1); }
      else if (e.key === "Home") { e.preventDefault(); go(0); }
      else if (e.key === "End") { e.preventDefault(); go(n - 1); }
    };
    root.addEventListener("click", onRail);
    if (prev) prev.addEventListener("click", onPrev);
    if (next) next.addEventListener("click", onNext);
    vp.addEventListener("keydown", onKey);

    /* drag with a mouse or pen: a vertical drag scrolls the page (touch
       already scrolls natively, so it is left alone) */
    let drag = null;
    const swallow = (ev) => { ev.preventDefault(); ev.stopPropagation(); };
    const down = (e) => {
      if (e.button !== 0 || e.pointerType === "touch") return;
      drag = { y: e.clientY, s: window.scrollY, moved: false };
    };
    const move = (e) => {
      if (!drag) return;
      const dy = e.clientY - drag.y;
      if (Math.abs(dy) > 4) { drag.moved = true; vp.classList.add("dragging"); }
      // instant: with CSS scroll-behavior: smooth every move would start a new smooth scroll
      if (drag.moved) window.scrollTo({ top: drag.s - dy * (opts.dragGain || 3.2), behavior: "instant" });
    };
    const up = () => {
      const d = drag; drag = null;
      vp.classList.remove("dragging");
      if (d && d.moved) {
        // let go on the face nearest the scroll position now (not the last
        // painted one, which can be a frame behind)
        go(Math.round(progressOf(root, "pin", stage) * (n - 1)));
        vp.addEventListener("click", swallow, { capture: true, once: true }); // the click that ends a drag is not a click
        setTimeout(() => vp.removeEventListener("click", swallow, { capture: true }), 50);
      }
    };
    vp.addEventListener("pointerdown", down);
    addEventListener("pointermove", move, { passive: true });
    addEventListener("pointerup", up);

    return {
      go,
      get index() { return idx; },
      destroy() {
        s.stop();
        root.removeEventListener("click", onRail);
        if (prev) prev.removeEventListener("click", onPrev);
        if (next) next.removeEventListener("click", onNext);
        vp.removeEventListener("keydown", onKey);
        vp.removeEventListener("pointerdown", down);
        removeEventListener("pointermove", move);
        removeEventListener("pointerup", up);
      },
    };
  }

  return {
    version: "1.0.0",
    // math
    clamp, clamp01, lerp, seg, smoothstep, ease, stagger, hash, prefersReduced,
    // colour
    parseColor, rgba, mix, readPalette,
    // geometry + layout
    route, strokeTo, boxIn, pad, hits, freeRect, blocked,
    // engines
    progressOf, scrub, scrollToProgress, film, drum,
  };
});
</script>
<script>
/*! reels.js v1.0.0 · ImuTechz · MIT
 *
 * Five example reels for ScrollMotion.film(). Each reel is
 *   { name, draw(ctx, t, env) }
 * and is a pure function of its local progress t ∈ [0, 1] and env:
 * no state between frames, no clock, no Math.random. Scroll back and
 * the reel plays backwards, frame for frame.
 *
 * Every reel lays itself out inside env.free — the largest rectangle
 * that misses every caption and HUD — so the drawing never needs to
 * be clipped (the engine clips anyway, as a safety net).
 *
 *   orbit  — one timeline, many moving parts
 *   path   — a route that draws (and un-draws) with the scroll
 *   stack  — staggered bars that build and settle
 *   wave   — phase from p, rhythm without a clock
 *   close  — scattered particles resolve onto one mark
 */
(function (root, factory) {
  if (typeof module === "object" && module.exports) module.exports = factory(require("./scroll-motion.js"));
  else root.ScrollReels = factory(root.ScrollMotion);
})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (SM) {
  "use strict";
  const { clamp01, lerp, seg, smoothstep, ease, stagger, hash, rgba, mix, route, strokeTo } = SM;
  const TAU = Math.PI * 2;
  const mono = (px) => `500 ${px}px ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace`;

  /* ── 1. orbit ─────────────────────────────────────────── */
  const orbit = {
    name: "orbit",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      const cx = F.x + F.w / 2, cy = F.y + F.h / 2;
      const R = Math.min(F.w, F.h) * 0.46;
      if (R < 8) return;
      const grow = ease.outCubic(seg(t, 0, 0.3));
      const tilt = lerp(1, 0.36, ease.inOut(seg(t, 0.15, 0.85)));
      const rings = [{ r: 0.42, m: 5 }, { r: 0.7, m: 8 }, { r: 0.98, m: 12 }];

      // rings, back halves first
      rings.forEach((ring, k) => {
        const r = R * ring.r * lerp(0.6, 1, ease.outCubic(seg(t, 0.05 + k * 0.08, 0.45 + k * 0.08)));
        ctx.strokeStyle = rgba(C.ink, 0.14);
        ctx.lineWidth = 1;
        ctx.setLineDash(k === 2 ? [2, 7] : []);
        ctx.beginPath(); ctx.ellipse(cx, cy, r, r * tilt, 0, 0, TAU); ctx.stroke();
      });
      ctx.setLineDash([]);

      // satellites, depth-sorted
      const sats = [];
      rings.forEach((ring, k) => {
        const r = R * ring.r;
        for (let j = 0; j < ring.m; j++) {
          const a = (j / ring.m) * TAU + t * Math.PI * (k % 2 ? -1 : 1) * (1 + k * 0.35) + k;
          const z = Math.sin(a);
          const on = ease.outBack(stagger(seg(t, 0.08, 0.7), j + k * 4, 12 + 8, 0.3));
          sats.push({ x: cx + Math.cos(a) * r, y: cy + z * r * tilt, z, k, on });
        }
      });
      sats.sort((a, b) => a.z - b.z);

      // spokes appear in the second half
      const spokes = seg(t, 0.45, 0.8);
      if (spokes > 0) {
        ctx.lineWidth = 1;
        for (const s of sats) {
          if (s.k !== 1) continue;
          ctx.strokeStyle = rgba(C.accent, 0.28 * spokes * (0.5 + 0.5 * (s.z + 1) / 2));
          ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(lerp(cx, s.x, spokes), lerp(cy, s.y, spokes)); ctx.stroke();
        }
      }

      for (const s of sats) {
        if (s.on <= 0) continue;
        const depth = (s.z + 1) / 2;
        const size = lerp(2.2, 5.5, depth) * s.on * (R > 120 ? 1 : 0.7);
        ctx.fillStyle = s.k === 1 ? rgba(C.accent, lerp(0.45, 1, depth)) : rgba(C.ink, lerp(0.25, 0.8, depth));
        ctx.beginPath(); ctx.arc(s.x, s.y, Math.max(0, size), 0, TAU); ctx.fill();
      }

      // the core, with a halo
      const cr = R * 0.13 * grow;
      const halo = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr * 3.2 + 1);
      halo.addColorStop(0, rgba(C.accent, 0.35)); halo.addColorStop(1, rgba(C.accent, 0));
      ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(cx, cy, cr * 3.2 + 1, 0, TAU); ctx.fill();
      ctx.fillStyle = rgba(C.accent2, 1); ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
      ctx.fillStyle = rgba(C.surface, 1); ctx.beginPath(); ctx.arc(cx, cy, cr * 0.36, 0, TAU); ctx.fill();

      if (R > 90) {
        ctx.font = mono(11); ctx.textAlign = "center"; ctx.fillStyle = rgba(C.ink, 0.5);
        ctx.fillText("p " + env.p.toFixed(2), cx, cy + R * tilt + 26);
      }
    },
  };

  /* ── 2. path ──────────────────────────────────────────── */
  const STEPS = ["pin", "measure", "draw", "caption", "drum", "verify", "ship"];
  const path = {
    name: "path",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 40 || F.h < 40) return;
      const n = STEPS.length, tall = F.h > F.w * 1.05;
      const nodes = [];
      for (let i = 0; i < n; i++) {
        const u = i / (n - 1);
        nodes.push(tall
          ? [F.x + F.w * (0.5 + Math.sin(u * Math.PI * 3.2) * 0.34), F.y + F.h * (0.06 + u * 0.88)]
          : [F.x + F.w * (0.04 + u * 0.92), F.y + 30 + (F.h - 74) * (0.5 + Math.sin(u * Math.PI * 2.4 + 0.4) * 0.5)]);
      }
      const { samples, at, total } = route(nodes);
      const L = seg(t, 0.04, 0.92) * total; // a lead-in and a hold so the ends get screen time

      // the route ahead: a faint dotted preview
      ctx.setLineDash([2, 9]); ctx.lineCap = "round"; ctx.lineWidth = 2;
      ctx.strokeStyle = rgba(C.ink, 0.18);
      ctx.beginPath(); samples.forEach(([x, y], k) => (k ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke();
      ctx.setLineDash([]);

      // the route travelled
      let head = samples[0];
      if (L > 0) {
        const g = ctx.createLinearGradient(nodes[0][0], nodes[0][1], nodes[n - 1][0], nodes[n - 1][1]);
        g.addColorStop(0, rgba(C.accent2)); g.addColorStop(1, rgba(C.accent));
        ctx.strokeStyle = g; ctx.lineWidth = env.mobile ? 3 : 4;
        head = strokeTo(ctx, samples, L);
      }

      // milestones pop with an overshoot as the head reaches them
      ctx.textAlign = "center";
      ctx.font = mono(env.mobile ? 10.5 : 12);
      for (let i = 0; i < n; i++) {
        const [x, y] = nodes[i];
        const reach = clamp01((L - at[i] + total * 0.012) / (total * 0.04));
        if (reach <= 0) {
          ctx.fillStyle = rgba(C.bg); ctx.strokeStyle = rgba(C.ink, 0.32); ctx.lineWidth = 1.5;
          ctx.beginPath(); ctx.arc(x, y, 5, 0, TAU); ctx.fill(); ctx.stroke();
          ctx.fillStyle = rgba(C.ink, 0.36);
        } else {
          const r = (env.mobile ? 8 : 11) * ease.outBack(reach);
          ctx.fillStyle = rgba(C.accent, 0.16 * reach);
          ctx.beginPath(); ctx.arc(x, y, r * 2.1, 0, TAU); ctx.fill();
          ctx.fillStyle = rgba(C.accent2); ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
          ctx.fillStyle = rgba(C.surface); ctx.beginPath(); ctx.arc(x, y, r * 0.36, 0, TAU); ctx.fill();
          ctx.fillStyle = rgba(C.ink, 0.5 + 0.4 * reach);
        }
        if (tall) { ctx.textAlign = x > F.x + F.w / 2 ? "right" : "left"; ctx.fillText(STEPS[i], x + (x > F.x + F.w / 2 ? -20 : 20), y + 4); }
        else ctx.fillText(STEPS[i], x, y + (i % 2 === 0 ? 34 : -24));
      }

      // the head: a glow that leads the line (no pulse: frames are pure)
      if (L > 0 && L < total) {
        const gr = ctx.createRadialGradient(head[0], head[1], 0, head[0], head[1], 26);
        gr.addColorStop(0, rgba(C.accent, 0.55)); gr.addColorStop(1, rgba(C.accent, 0));
        ctx.fillStyle = gr; ctx.beginPath(); ctx.arc(head[0], head[1], 26, 0, TAU); ctx.fill();
        ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(head[0], head[1], 3.2, 0, TAU); ctx.fill();
      }
    },
  };

  /* ── 3. stack ─────────────────────────────────────────── */
  const stack = {
    name: "stack",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 40 || F.h < 40) return;
      const n = env.mobile || F.w < 420 ? 9 : 14;
      const vals = Array.from({ length: n }, (_, i) => 0.22 + 0.68 * smoothstep(0.15 + 0.85 * ((i + 1) / n)) * (0.75 + 0.25 * hash(i, 3)));
      const base = F.y + F.h * 0.9, top = F.y + F.h * 0.08, gap = F.w / n, bw = gap * 0.56;
      const build = seg(t, 0, 0.72);

      // grid
      ctx.strokeStyle = rgba(C.ink, 0.08); ctx.lineWidth = 1;
      for (let g = 0; g <= 4; g++) {
        const y = lerp(base, top, g / 4);
        ctx.beginPath(); ctx.moveTo(F.x, y); ctx.lineTo(F.x + F.w, y); ctx.stroke();
      }
      ctx.strokeStyle = rgba(C.ink, 0.35);
      ctx.beginPath(); ctx.moveTo(F.x, base); ctx.lineTo(F.x + F.w, base); ctx.stroke();

      // bars: each has its own window inside the reel, and lands with an ease
      const tops = [];
      let best = 0;
      vals.forEach((v, i) => { if (v > vals[best]) best = i; });
      for (let i = 0; i < n; i++) {
        const k = ease.outCubic(stagger(build, i, n, 0.32));
        const h = (base - top) * vals[i] * k;
        const x = F.x + gap * i + (gap - bw) / 2;
        const grd = ctx.createLinearGradient(0, base - h, 0, base);
        const c = i === best ? C.accent : mix(C.ink, C.accent, i / (n - 1) * 0.55);
        grd.addColorStop(0, rgba(c, i === best ? 1 : 0.85)); grd.addColorStop(1, rgba(c, i === best ? 0.75 : 0.35));
        ctx.fillStyle = grd;
        const rr = Math.min(6, bw / 2, h / 2);
        ctx.beginPath();
        if (ctx.roundRect) ctx.roundRect(x, base - h, bw, h, [rr, rr, 0, 0]); else ctx.rect(x, base - h, bw, h);
        ctx.fill();
        tops.push([x + bw / 2, base - h - 10]);
      }

      // a trend line through the tops, drawn once the bars have landed
      const line = seg(t, 0.62, 0.96);
      if (line > 0) {
        const { samples, total } = route(tops, 16);
        ctx.strokeStyle = rgba(C.accent2, 0.9); ctx.lineWidth = 2; ctx.setLineDash([]);
        const head = strokeTo(ctx, samples, line * total);
        ctx.fillStyle = rgba(C.accent2); ctx.beginPath(); ctx.arc(head[0], head[1], 4, 0, TAU); ctx.fill();
      }

      if (F.w > 260) {
        ctx.font = mono(11); ctx.textAlign = "left"; ctx.fillStyle = rgba(C.ink, 0.5);
        ctx.fillText(String(Math.round(build * 100)).padStart(3, " ") + "%", F.x, top - 10);
      }
    },
  };

  /* ── 4. wave ──────────────────────────────────────────── */
  const wave = {
    name: "wave",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 40 || F.h < 40) return;
      const rows = env.mobile ? 9 : 13;
      const ampMax = (F.h / rows) * 1.1; // rows are inset by the largest swing, so no crest leaves F
      const amp0 = ampMax * lerp(0.25, 1, smoothstep(seg(t, 0, 0.35)));
      const rowY = (r) => F.y + ampMax + (F.h - 2 * ampMax) * (r / (rows - 1));
      const phase = t * TAU * 1.5;
      const step = Math.max(3, F.w / 140);
      ctx.lineWidth = env.mobile ? 1.4 : 1.8; ctx.lineCap = "round";
      for (let r = 0; r < rows; r++) {
        const y0 = rowY(r);
        const c = mix(C.ink, C.accent, r / (rows - 1));
        ctx.strokeStyle = rgba(c, lerp(0.25, 0.95, r / (rows - 1)));
        ctx.beginPath();
        for (let x = 0; x <= F.w + 0.5; x += step) {
          const u = x / F.w;
          const env1 = Math.sin(Math.PI * u);
          const y = y0 + Math.sin(u * TAU * 1.6 + phase + r * 0.45) * amp0 * env1 * env1;
          x ? ctx.lineTo(F.x + x, y) : ctx.moveTo(F.x + x, y);
        }
        ctx.stroke();
      }
      // a playhead that rides the middle row: position = t
      const mid = Math.floor(rows / 2), u = lerp(0.04, 0.96, t);
      const y0 = rowY(mid), e = Math.sin(Math.PI * u);
      const hx = F.x + u * F.w, hy = y0 + Math.sin(u * TAU * 1.6 + phase + mid * 0.45) * amp0 * e * e;
      ctx.strokeStyle = rgba(C.ink, 0.22); ctx.lineWidth = 1; ctx.setLineDash([3, 6]);
      ctx.beginPath(); ctx.moveTo(hx, F.y); ctx.lineTo(hx, F.y + F.h); ctx.stroke(); ctx.setLineDash([]);
      ctx.fillStyle = rgba(C.accent2); ctx.beginPath(); ctx.arc(hx, hy, 6, 0, TAU); ctx.fill();
      ctx.fillStyle = rgba(C.surface); ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, TAU); ctx.fill();
    },
  };

  /* ── 5. close ─────────────────────────────────────────── */
  const close = {
    name: "close",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 30 || F.h < 30) return;
      const cx = F.x + F.w / 2, cy = F.y + F.h / 2, R = Math.min(F.w, F.h) * 0.4;
      const M = env.mobile ? 110 : 160, outer = Math.round(M * 0.62);
      const go = seg(t, 0, 0.8);
      for (let i = 0; i < M; i++) {
        // the mark: an outer ring and an inner hexagon (six aperture blades)
        let tx, ty;
        if (i < outer) {
          const a = (i / outer) * TAU - Math.PI / 2;
          tx = cx + Math.cos(a) * R; ty = cy + Math.sin(a) * R;
        } else {
          const k = i - outer, per = (M - outer) / 6, side = Math.floor(k / per), f = (k % per) / per;
          const a0 = side * TAU / 6 - Math.PI / 2, a1 = a0 + TAU / 6, r = R * 0.52;
          tx = cx + lerp(Math.cos(a0), Math.cos(a1), f) * r; ty = cy + lerp(Math.sin(a0), Math.sin(a1), f) * r;
        }
        const sx = F.x + hash(i, 1) * F.w, sy = F.y + hash(i, 2) * F.h;
        const e = ease.inOutCubic(stagger(go, i % 40, 40, 0.6));
        const bend = Math.sin(e * Math.PI) * 40 * (hash(i, 5) - 0.5);
        const x = lerp(sx, tx, e) + bend, y = lerp(sy, ty, e) - bend;
        const landed = e > 0.98;
        ctx.fillStyle = landed ? rgba(i < outer ? C.ink : C.accent, 0.9) : rgba(C.ink, lerp(0.25, 0.6, e));
        ctx.beginPath(); ctx.arc(x, y, landed ? 2.4 : lerp(1.4, 2.2, e), 0, TAU); ctx.fill();
      }
      // the mark resolves: ring stroke and a core, once everything lands
      const fin = ease.outCubic(seg(t, 0.78, 1));
      if (fin > 0) {
        ctx.strokeStyle = rgba(C.accent, 0.7 * fin); ctx.lineWidth = 2;
        ctx.beginPath(); ctx.arc(cx, cy, R * 1.12, -Math.PI / 2, -Math.PI / 2 + TAU * fin); ctx.stroke();
        ctx.fillStyle = rgba(C.accent2, fin);
        ctx.beginPath(); ctx.arc(cx, cy, R * 0.16 * ease.outBack(fin), 0, TAU); ctx.fill();
      }
    },
  };

  const all = [orbit, path, stack, wave, close];

  /* paint one reel as a still into its own canvas (drum faces, posters):
     the same pure function, at a fixed t, with no keep-outs */
  function still(canvas, reel, t, colors, inset = 14) {
    // layout size, not getBoundingClientRect: a drum face is 3D-transformed,
    // and its projected box is not the size the canvas is drawn at
    const W = Math.max(1, canvas.clientWidth), H = Math.max(1, canvas.clientHeight), dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    const ctx = canvas.getContext("2d");
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, W, H);
    reel.draw(ctx, t, {
      W, H, dpr, mobile: true, colors, keep: [], reduce: true, alpha: 1, p: t, index: 0,
      free: { x: inset, y: inset, w: W - 2 * inset, h: H - 2 * inset },
    });
  }

  return { orbit, path, stack, wave, close, all, still };
});
</script>
<script>
/* ── page wiring ── */
(function () {
  "use strict";
  var SM = window.ScrollMotion, R = window.ScrollReels;
  var film = document.getElementById("film");
  var stage = film.querySelector(".smf-stage");

  // 1. the film: five reels, one caption each, HUD keep-outs found by [data-keep]
  var caps = R.all.map(function (r) { return film.querySelector('.smf-cap[data-reel="' + r.name + '"]'); });
  var player = SM.film({ track: film, stage: stage, canvas: film.querySelector(".smf-canvas"), reels: R.all, captions: caps });

  // 2. anchors inside the track, so a plain link (#reel-path) seeks the film
  var anchors = film.querySelectorAll(".smf-anchor");
  function placeAnchors() {
    var span = film.offsetHeight - stage.offsetHeight;
    anchors.forEach(function (a) { a.style.top = Math.round((parseFloat(a.dataset.at) / 5) * span) + "px"; });
  }

  // 3. the drum; its faces show each reel as a still (same pure function, fixed t)
  var drumEl = document.getElementById("drum");
  var drum = SM.drum(drumEl);
  var faceCanvases = drumEl.querySelectorAll(".sd-face canvas");
  function paintFaces() {
    var colors = SM.readPalette(drumEl);
    faceCanvases.forEach(function (cv, i) { R.still(cv, R.all[i], 0.86, colors, 12); });
  }

  // 4. theme toggle (auto follows prefers-color-scheme)
  var modes = ["auto", "light", "dark"], mode = 0, btn = document.getElementById("theme");
  btn.addEventListener("click", function () {
    mode = (mode + 1) % 3;
    if (mode === 0) delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = modes[mode];
    btn.textContent = "Theme: " + modes[mode];
    player.retheme(); paintFaces();
  });
  var mq = matchMedia("(prefers-color-scheme: dark)");
  if (mq.addEventListener) mq.addEventListener("change", paintFaces);

  var ro = new ResizeObserver(function () { placeAnchors(); paintFaces(); });
  ro.observe(film); ro.observe(drumEl.querySelector(".sd-ring"));
  placeAnchors(); paintFaces();

  // exposed for the verification script and for poking at in devtools
  window.__demo = { player: player, drum: drum };
})();
</script>
</body>
</html>
````


---


## assets/reels.js


````js
/*! reels.js v1.0.0 · ImuTechz · MIT
 *
 * Five example reels for ScrollMotion.film(). Each reel is
 *   { name, draw(ctx, t, env) }
 * and is a pure function of its local progress t ∈ [0, 1] and env:
 * no state between frames, no clock, no Math.random. Scroll back and
 * the reel plays backwards, frame for frame.
 *
 * Every reel lays itself out inside env.free — the largest rectangle
 * that misses every caption and HUD — so the drawing never needs to
 * be clipped (the engine clips anyway, as a safety net).
 *
 *   orbit  — one timeline, many moving parts
 *   path   — a route that draws (and un-draws) with the scroll
 *   stack  — staggered bars that build and settle
 *   wave   — phase from p, rhythm without a clock
 *   close  — scattered particles resolve onto one mark
 */
(function (root, factory) {
  if (typeof module === "object" && module.exports) module.exports = factory(require("./scroll-motion.js"));
  else root.ScrollReels = factory(root.ScrollMotion);
})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (SM) {
  "use strict";
  const { clamp01, lerp, seg, smoothstep, ease, stagger, hash, rgba, mix, route, strokeTo } = SM;
  const TAU = Math.PI * 2;
  const mono = (px) => `500 ${px}px ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace`;

  /* ── 1. orbit ─────────────────────────────────────────── */
  const orbit = {
    name: "orbit",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      const cx = F.x + F.w / 2, cy = F.y + F.h / 2;
      const R = Math.min(F.w, F.h) * 0.46;
      if (R < 8) return;
      const grow = ease.outCubic(seg(t, 0, 0.3));
      const tilt = lerp(1, 0.36, ease.inOut(seg(t, 0.15, 0.85)));
      const rings = [{ r: 0.42, m: 5 }, { r: 0.7, m: 8 }, { r: 0.98, m: 12 }];

      // rings, back halves first
      rings.forEach((ring, k) => {
        const r = R * ring.r * lerp(0.6, 1, ease.outCubic(seg(t, 0.05 + k * 0.08, 0.45 + k * 0.08)));
        ctx.strokeStyle = rgba(C.ink, 0.14);
        ctx.lineWidth = 1;
        ctx.setLineDash(k === 2 ? [2, 7] : []);
        ctx.beginPath(); ctx.ellipse(cx, cy, r, r * tilt, 0, 0, TAU); ctx.stroke();
      });
      ctx.setLineDash([]);

      // satellites, depth-sorted
      const sats = [];
      rings.forEach((ring, k) => {
        const r = R * ring.r;
        for (let j = 0; j < ring.m; j++) {
          const a = (j / ring.m) * TAU + t * Math.PI * (k % 2 ? -1 : 1) * (1 + k * 0.35) + k;
          const z = Math.sin(a);
          const on = ease.outBack(stagger(seg(t, 0.08, 0.7), j + k * 4, 12 + 8, 0.3));
          sats.push({ x: cx + Math.cos(a) * r, y: cy + z * r * tilt, z, k, on });
        }
      });
      sats.sort((a, b) => a.z - b.z);

      // spokes appear in the second half
      const spokes = seg(t, 0.45, 0.8);
      if (spokes > 0) {
        ctx.lineWidth = 1;
        for (const s of sats) {
          if (s.k !== 1) continue;
          ctx.strokeStyle = rgba(C.accent, 0.28 * spokes * (0.5 + 0.5 * (s.z + 1) / 2));
          ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(lerp(cx, s.x, spokes), lerp(cy, s.y, spokes)); ctx.stroke();
        }
      }

      for (const s of sats) {
        if (s.on <= 0) continue;
        const depth = (s.z + 1) / 2;
        const size = lerp(2.2, 5.5, depth) * s.on * (R > 120 ? 1 : 0.7);
        ctx.fillStyle = s.k === 1 ? rgba(C.accent, lerp(0.45, 1, depth)) : rgba(C.ink, lerp(0.25, 0.8, depth));
        ctx.beginPath(); ctx.arc(s.x, s.y, Math.max(0, size), 0, TAU); ctx.fill();
      }

      // the core, with a halo
      const cr = R * 0.13 * grow;
      const halo = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr * 3.2 + 1);
      halo.addColorStop(0, rgba(C.accent, 0.35)); halo.addColorStop(1, rgba(C.accent, 0));
      ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(cx, cy, cr * 3.2 + 1, 0, TAU); ctx.fill();
      ctx.fillStyle = rgba(C.accent2, 1); ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
      ctx.fillStyle = rgba(C.surface, 1); ctx.beginPath(); ctx.arc(cx, cy, cr * 0.36, 0, TAU); ctx.fill();

      if (R > 90) {
        ctx.font = mono(11); ctx.textAlign = "center"; ctx.fillStyle = rgba(C.ink, 0.5);
        ctx.fillText("p " + env.p.toFixed(2), cx, cy + R * tilt + 26);
      }
    },
  };

  /* ── 2. path ──────────────────────────────────────────── */
  const STEPS = ["pin", "measure", "draw", "caption", "drum", "verify", "ship"];
  const path = {
    name: "path",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 40 || F.h < 40) return;
      const n = STEPS.length, tall = F.h > F.w * 1.05;
      const nodes = [];
      for (let i = 0; i < n; i++) {
        const u = i / (n - 1);
        nodes.push(tall
          ? [F.x + F.w * (0.5 + Math.sin(u * Math.PI * 3.2) * 0.34), F.y + F.h * (0.06 + u * 0.88)]
          : [F.x + F.w * (0.04 + u * 0.92), F.y + 30 + (F.h - 74) * (0.5 + Math.sin(u * Math.PI * 2.4 + 0.4) * 0.5)]);
      }
      const { samples, at, total } = route(nodes);
      const L = seg(t, 0.04, 0.92) * total; // a lead-in and a hold so the ends get screen time

      // the route ahead: a faint dotted preview
      ctx.setLineDash([2, 9]); ctx.lineCap = "round"; ctx.lineWidth = 2;
      ctx.strokeStyle = rgba(C.ink, 0.18);
      ctx.beginPath(); samples.forEach(([x, y], k) => (k ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke();
      ctx.setLineDash([]);

      // the route travelled
      let head = samples[0];
      if (L > 0) {
        const g = ctx.createLinearGradient(nodes[0][0], nodes[0][1], nodes[n - 1][0], nodes[n - 1][1]);
        g.addColorStop(0, rgba(C.accent2)); g.addColorStop(1, rgba(C.accent));
        ctx.strokeStyle = g; ctx.lineWidth = env.mobile ? 3 : 4;
        head = strokeTo(ctx, samples, L);
      }

      // milestones pop with an overshoot as the head reaches them
      ctx.textAlign = "center";
      ctx.font = mono(env.mobile ? 10.5 : 12);
      for (let i = 0; i < n; i++) {
        const [x, y] = nodes[i];
        const reach = clamp01((L - at[i] + total * 0.012) / (total * 0.04));
        if (reach <= 0) {
          ctx.fillStyle = rgba(C.bg); ctx.strokeStyle = rgba(C.ink, 0.32); ctx.lineWidth = 1.5;
          ctx.beginPath(); ctx.arc(x, y, 5, 0, TAU); ctx.fill(); ctx.stroke();
          ctx.fillStyle = rgba(C.ink, 0.36);
        } else {
          const r = (env.mobile ? 8 : 11) * ease.outBack(reach);
          ctx.fillStyle = rgba(C.accent, 0.16 * reach);
          ctx.beginPath(); ctx.arc(x, y, r * 2.1, 0, TAU); ctx.fill();
          ctx.fillStyle = rgba(C.accent2); ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
          ctx.fillStyle = rgba(C.surface); ctx.beginPath(); ctx.arc(x, y, r * 0.36, 0, TAU); ctx.fill();
          ctx.fillStyle = rgba(C.ink, 0.5 + 0.4 * reach);
        }
        if (tall) { ctx.textAlign = x > F.x + F.w / 2 ? "right" : "left"; ctx.fillText(STEPS[i], x + (x > F.x + F.w / 2 ? -20 : 20), y + 4); }
        else ctx.fillText(STEPS[i], x, y + (i % 2 === 0 ? 34 : -24));
      }

      // the head: a glow that leads the line (no pulse: frames are pure)
      if (L > 0 && L < total) {
        const gr = ctx.createRadialGradient(head[0], head[1], 0, head[0], head[1], 26);
        gr.addColorStop(0, rgba(C.accent, 0.55)); gr.addColorStop(1, rgba(C.accent, 0));
        ctx.fillStyle = gr; ctx.beginPath(); ctx.arc(head[0], head[1], 26, 0, TAU); ctx.fill();
        ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(head[0], head[1], 3.2, 0, TAU); ctx.fill();
      }
    },
  };

  /* ── 3. stack ─────────────────────────────────────────── */
  const stack = {
    name: "stack",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 40 || F.h < 40) return;
      const n = env.mobile || F.w < 420 ? 9 : 14;
      const vals = Array.from({ length: n }, (_, i) => 0.22 + 0.68 * smoothstep(0.15 + 0.85 * ((i + 1) / n)) * (0.75 + 0.25 * hash(i, 3)));
      const base = F.y + F.h * 0.9, top = F.y + F.h * 0.08, gap = F.w / n, bw = gap * 0.56;
      const build = seg(t, 0, 0.72);

      // grid
      ctx.strokeStyle = rgba(C.ink, 0.08); ctx.lineWidth = 1;
      for (let g = 0; g <= 4; g++) {
        const y = lerp(base, top, g / 4);
        ctx.beginPath(); ctx.moveTo(F.x, y); ctx.lineTo(F.x + F.w, y); ctx.stroke();
      }
      ctx.strokeStyle = rgba(C.ink, 0.35);
      ctx.beginPath(); ctx.moveTo(F.x, base); ctx.lineTo(F.x + F.w, base); ctx.stroke();

      // bars: each has its own window inside the reel, and lands with an ease
      const tops = [];
      let best = 0;
      vals.forEach((v, i) => { if (v > vals[best]) best = i; });
      for (let i = 0; i < n; i++) {
        const k = ease.outCubic(stagger(build, i, n, 0.32));
        const h = (base - top) * vals[i] * k;
        const x = F.x + gap * i + (gap - bw) / 2;
        const grd = ctx.createLinearGradient(0, base - h, 0, base);
        const c = i === best ? C.accent : mix(C.ink, C.accent, i / (n - 1) * 0.55);
        grd.addColorStop(0, rgba(c, i === best ? 1 : 0.85)); grd.addColorStop(1, rgba(c, i === best ? 0.75 : 0.35));
        ctx.fillStyle = grd;
        const rr = Math.min(6, bw / 2, h / 2);
        ctx.beginPath();
        if (ctx.roundRect) ctx.roundRect(x, base - h, bw, h, [rr, rr, 0, 0]); else ctx.rect(x, base - h, bw, h);
        ctx.fill();
        tops.push([x + bw / 2, base - h - 10]);
      }

      // a trend line through the tops, drawn once the bars have landed
      const line = seg(t, 0.62, 0.96);
      if (line > 0) {
        const { samples, total } = route(tops, 16);
        ctx.strokeStyle = rgba(C.accent2, 0.9); ctx.lineWidth = 2; ctx.setLineDash([]);
        const head = strokeTo(ctx, samples, line * total);
        ctx.fillStyle = rgba(C.accent2); ctx.beginPath(); ctx.arc(head[0], head[1], 4, 0, TAU); ctx.fill();
      }

      if (F.w > 260) {
        ctx.font = mono(11); ctx.textAlign = "left"; ctx.fillStyle = rgba(C.ink, 0.5);
        ctx.fillText(String(Math.round(build * 100)).padStart(3, " ") + "%", F.x, top - 10);
      }
    },
  };

  /* ── 4. wave ──────────────────────────────────────────── */
  const wave = {
    name: "wave",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 40 || F.h < 40) return;
      const rows = env.mobile ? 9 : 13;
      const ampMax = (F.h / rows) * 1.1; // rows are inset by the largest swing, so no crest leaves F
      const amp0 = ampMax * lerp(0.25, 1, smoothstep(seg(t, 0, 0.35)));
      const rowY = (r) => F.y + ampMax + (F.h - 2 * ampMax) * (r / (rows - 1));
      const phase = t * TAU * 1.5;
      const step = Math.max(3, F.w / 140);
      ctx.lineWidth = env.mobile ? 1.4 : 1.8; ctx.lineCap = "round";
      for (let r = 0; r < rows; r++) {
        const y0 = rowY(r);
        const c = mix(C.ink, C.accent, r / (rows - 1));
        ctx.strokeStyle = rgba(c, lerp(0.25, 0.95, r / (rows - 1)));
        ctx.beginPath();
        for (let x = 0; x <= F.w + 0.5; x += step) {
          const u = x / F.w;
          const env1 = Math.sin(Math.PI * u);
          const y = y0 + Math.sin(u * TAU * 1.6 + phase + r * 0.45) * amp0 * env1 * env1;
          x ? ctx.lineTo(F.x + x, y) : ctx.moveTo(F.x + x, y);
        }
        ctx.stroke();
      }
      // a playhead that rides the middle row: position = t
      const mid = Math.floor(rows / 2), u = lerp(0.04, 0.96, t);
      const y0 = rowY(mid), e = Math.sin(Math.PI * u);
      const hx = F.x + u * F.w, hy = y0 + Math.sin(u * TAU * 1.6 + phase + mid * 0.45) * amp0 * e * e;
      ctx.strokeStyle = rgba(C.ink, 0.22); ctx.lineWidth = 1; ctx.setLineDash([3, 6]);
      ctx.beginPath(); ctx.moveTo(hx, F.y); ctx.lineTo(hx, F.y + F.h); ctx.stroke(); ctx.setLineDash([]);
      ctx.fillStyle = rgba(C.accent2); ctx.beginPath(); ctx.arc(hx, hy, 6, 0, TAU); ctx.fill();
      ctx.fillStyle = rgba(C.surface); ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, TAU); ctx.fill();
    },
  };

  /* ── 5. close ─────────────────────────────────────────── */
  const close = {
    name: "close",
    draw(ctx, t, env) {
      const { free: F, colors: C } = env;
      if (F.w < 30 || F.h < 30) return;
      const cx = F.x + F.w / 2, cy = F.y + F.h / 2, R = Math.min(F.w, F.h) * 0.4;
      const M = env.mobile ? 110 : 160, outer = Math.round(M * 0.62);
      const go = seg(t, 0, 0.8);
      for (let i = 0; i < M; i++) {
        // the mark: an outer ring and an inner hexagon (six aperture blades)
        let tx, ty;
        if (i < outer) {
          const a = (i / outer) * TAU - Math.PI / 2;
          tx = cx + Math.cos(a) * R; ty = cy + Math.sin(a) * R;
        } else {
          const k = i - outer, per = (M - outer) / 6, side = Math.floor(k / per), f = (k % per) / per;
          const a0 = side * TAU / 6 - Math.PI / 2, a1 = a0 + TAU / 6, r = R * 0.52;
          tx = cx + lerp(Math.cos(a0), Math.cos(a1), f) * r; ty = cy + lerp(Math.sin(a0), Math.sin(a1), f) * r;
        }
        const sx = F.x + hash(i, 1) * F.w, sy = F.y + hash(i, 2) * F.h;
        const e = ease.inOutCubic(stagger(go, i % 40, 40, 0.6));
        const bend = Math.sin(e * Math.PI) * 40 * (hash(i, 5) - 0.5);
        const x = lerp(sx, tx, e) + bend, y = lerp(sy, ty, e) - bend;
        const landed = e > 0.98;
        ctx.fillStyle = landed ? rgba(i < outer ? C.ink : C.accent, 0.9) : rgba(C.ink, lerp(0.25, 0.6, e));
        ctx.beginPath(); ctx.arc(x, y, landed ? 2.4 : lerp(1.4, 2.2, e), 0, TAU); ctx.fill();
      }
      // the mark resolves: ring stroke and a core, once everything lands
      const fin = ease.outCubic(seg(t, 0.78, 1));
      if (fin > 0) {
        ctx.strokeStyle = rgba(C.accent, 0.7 * fin); ctx.lineWidth = 2;
        ctx.beginPath(); ctx.arc(cx, cy, R * 1.12, -Math.PI / 2, -Math.PI / 2 + TAU * fin); ctx.stroke();
        ctx.fillStyle = rgba(C.accent2, fin);
        ctx.beginPath(); ctx.arc(cx, cy, R * 0.16 * ease.outBack(fin), 0, TAU); ctx.fill();
      }
    },
  };

  const all = [orbit, path, stack, wave, close];

  /* paint one reel as a still into its own canvas (drum faces, posters):
     the same pure function, at a fixed t, with no keep-outs */
  function still(canvas, reel, t, colors, inset = 14) {
    // layout size, not getBoundingClientRect: a drum face is 3D-transformed,
    // and its projected box is not the size the canvas is drawn at
    const W = Math.max(1, canvas.clientWidth), H = Math.max(1, canvas.clientHeight), dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    const ctx = canvas.getContext("2d");
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, W, H);
    reel.draw(ctx, t, {
      W, H, dpr, mobile: true, colors, keep: [], reduce: true, alpha: 1, p: t, index: 0,
      free: { x: inset, y: inset, w: W - 2 * inset, h: H - 2 * inset },
    });
  }

  return { orbit, path, stack, wave, close, all, still };
});
````


---


## assets/scroll-motion.css


````css
/*! scroll-motion.css v1.0.0 · ImuTechz · MIT
   The film (.smf) and the story drum (.sd). Colours are tokens on :root;
   the canvas reads the same tokens (ScrollMotion.readPalette), so light
   and dark mode are this one stylesheet. */

:root {
  --sm-bg: #f3ede2;
  --sm-surface: #fbf8f2;
  --sm-ink: #1c1914;
  --sm-muted: #5c554b;
  --sm-dim: #8a8277;
  --sm-line: rgba(28, 25, 20, 0.12);
  --sm-line-strong: rgba(28, 25, 20, 0.22);
  --sm-accent: #f2661a;
  --sm-accent-2: #d9481e;
  --sm-accent-soft: rgba(242, 102, 26, 0.18);
  --sm-cool: #2e6c96;
  --sm-card: rgba(255, 255, 255, 0.74);
  --sm-card-line: rgba(255, 255, 255, 0.9);
  --sm-shadow: rgba(28, 25, 20, 0.4);
  --sm-on-ink: #fbf8f2;
  --sm-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --sm-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
  --sm-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --sm-nav-h: 0px; /* height of a fixed site header, if the page has one */
  color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --sm-bg: #0d0c0b; --sm-surface: #171513; --sm-ink: #f4efe7; --sm-muted: #b9b0a3; --sm-dim: #8a8277;
    --sm-line: rgba(244, 239, 231, 0.12); --sm-line-strong: rgba(244, 239, 231, 0.24);
    --sm-accent: #ff7a33; --sm-accent-2: #f2661a; --sm-accent-soft: rgba(255, 122, 51, 0.22); --sm-cool: #6fb2dc;
    --sm-card: rgba(24, 22, 20, 0.78); --sm-card-line: rgba(255, 255, 255, 0.12); --sm-shadow: rgba(0, 0, 0, 0.9); --sm-on-ink: #0d0c0b;
  }
}
:root[data-theme="dark"] {
  --sm-bg: #0d0c0b; --sm-surface: #171513; --sm-ink: #f4efe7; --sm-muted: #b9b0a3; --sm-dim: #8a8277;
  --sm-line: rgba(244, 239, 231, 0.12); --sm-line-strong: rgba(244, 239, 231, 0.24);
  --sm-accent: #ff7a33; --sm-accent-2: #f2661a; --sm-accent-soft: rgba(255, 122, 51, 0.22); --sm-cool: #6fb2dc;
  --sm-card: rgba(24, 22, 20, 0.78); --sm-card-line: rgba(255, 255, 255, 0.12); --sm-shadow: rgba(0, 0, 0, 0.9); --sm-on-ink: #0d0c0b;
}

.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

/* ───────────── the film ───────────── */

/* the track: tall, so scrolling through it is the timeline. Never put
   overflow: hidden/auto on an ancestor of the track, or sticky breaks. */
.smf { position: relative; height: calc(var(--smf-n, 5) * 90svh + 100svh); }
.smf-stage { position: sticky; top: 0; height: 100svh; overflow: hidden; color: var(--sm-ink); }
.smf-canvas { position: absolute; inset: 0; display: block; pointer-events: none; }
.smf-frame { position: absolute; inset: calc(var(--sm-nav-h) + 16px) clamp(16px, 4vw, 48px) 20px; pointer-events: none; }
.smf-frame > * { pointer-events: auto; }

.smf-hud { position: absolute; left: 0; right: 0; top: 0; display: flex; align-items: center; gap: 16px; font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--sm-dim); }
.smf-hud-t { color: var(--sm-ink); }
.smf-hud-bar { flex: 1; height: 1.5px; background: var(--sm-line-strong); position: relative; overflow: hidden; }
.smf-hud-bar > i { position: absolute; inset: 0; background: var(--sm-accent); transform-origin: left; transform: scaleX(0); }

.smf-cap {
  position: absolute; width: min(400px, 100%); padding: 22px 24px 24px; border-radius: 20px;
  background: var(--sm-card); border: 1px solid var(--sm-card-line);
  -webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4);
  box-shadow: 0 18px 50px -26px var(--sm-shadow);
  opacity: 0; visibility: hidden; will-change: opacity, transform;
}
.smf-cap[data-place="left"] { left: 0; top: 50%; margin-top: -110px; }
.smf-cap[data-place="right"] { right: 0; top: 50%; margin-top: -110px; }
.smf-cap[data-place="bottom"] { left: 0; bottom: 0; width: min(560px, 100%); }
.smf-cap[data-place="top"] { left: 0; top: 44px; }
.smf-cap-k { display: flex; justify-content: space-between; gap: 12px; margin: 0 0 12px; font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--sm-accent-2); }
.smf-cap-h { margin: 0 0 10px; font-size: clamp(21px, 2.2vw, 28px); line-height: 1.12; letter-spacing: -0.02em; font-weight: 600; }
.smf-cap-d { margin: 0; font-size: 14.5px; line-height: 1.55; color: var(--sm-muted); }
.smf-hint { position: absolute; right: 0; bottom: 0; margin: 0; font-family: var(--sm-mono); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--sm-dim); }

@media (max-width: 759px) {
  .smf { height: calc(var(--smf-n, 5) * 80svh + 100svh); }
  .smf-frame { inset: calc(var(--sm-nav-h) + 12px) 16px 16px; }
  .smf-cap, .smf-cap[data-place] { left: 0; right: 0; top: auto; bottom: 0; width: auto; margin: 0; padding: 16px 18px 18px; }
  .smf-cap-d { font-size: 14px; }
  .smf-hint { display: none; }
  .smf-hud-x { display: none; }
}

/* ───────────── the story drum ───────────── */

.sd { position: relative; }
.sd-stage {
  position: sticky; top: 0; height: 100svh; max-width: 1320px; margin: 0 auto;
  padding: var(--sm-nav-h) clamp(16px, 4vw, 40px) 0;
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1.1fr) 180px; align-items: center; gap: clamp(20px, 3.5vw, 52px);
  color: var(--sm-ink);
}
.sd-k { margin: 0 0 12px; font-family: var(--sm-mono); font-size: 11.5px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--sm-dim); }
.sd-title { margin: 0 0 26px; font-size: clamp(30px, 3.6vw, 50px); line-height: 1.02; letter-spacing: -0.03em; }
.sd-cap { min-height: 250px; animation: sd-in 0.55s var(--sm-ease) both; }
.sd-cap[hidden] { display: none; }
@keyframes sd-in { from { opacity: 0; transform: translateY(18px); filter: blur(5px); } }
.sd-n { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: var(--sm-accent-2); }
.sd-h { margin: 0 0 12px; font-size: clamp(22px, 2.2vw, 30px); line-height: 1.15; letter-spacing: -0.02em; }
.sd-d { margin: 0 0 18px; max-width: 42ch; font-size: 15.5px; line-height: 1.6; color: var(--sm-muted); }
.sd-cta { display: inline-flex; gap: 8px; padding: 11px 18px; border-radius: 999px; background: var(--sm-ink); color: var(--sm-on-ink); font-weight: 600; font-size: 14px; text-decoration: none; transition: transform 0.3s; }
.sd-cta:hover { transform: translateY(-2px); }
.sd-cta:focus-visible, .sd-nav button:focus-visible, .sd-rail button:focus-visible { outline: 2px solid var(--sm-accent); outline-offset: 3px; }
.sd-nav { display: flex; align-items: center; gap: 8px; margin-top: 26px; }
.sd-nav button { width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--sm-line-strong); background: var(--sm-surface); color: var(--sm-ink); font-size: 20px; line-height: 1; cursor: pointer; transition: background 0.2s, color 0.2s; }
.sd-nav button:hover:not(:disabled) { background: var(--sm-ink); color: var(--sm-on-ink); }
.sd-nav button:disabled { opacity: 0.35; cursor: default; }
.sd-tip { margin-left: 8px; font-size: 12px; color: var(--sm-dim); }

.sd-viewport {
  --r: clamp(180px, 19vw, 260px);
  position: relative; height: min(74vh, 600px); perspective: 1300px; display: grid; place-items: center;
  cursor: grab; outline: none; touch-action: pan-y; /* touch scrolls the page natively */
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent);
}
.sd-viewport.dragging { cursor: grabbing; }
.sd-viewport:focus-visible .sd-face.on { outline: 2px solid var(--sm-accent); outline-offset: 4px; }
.sd-ring { position: relative; width: 100%; height: calc(var(--r) * 1.12); transform-style: preserve-3d; will-change: transform; }
.sd-face {
  position: absolute; inset: 0; display: grid; grid-template-rows: 1fr auto; padding: 10px; border-radius: 22px;
  text-decoration: none; color: var(--sm-ink); background: var(--sm-surface); border: 1px solid var(--sm-line-strong);
  backface-visibility: hidden; -webkit-backface-visibility: hidden; user-select: none; -webkit-user-drag: none;
  transition: border-color 0.4s, box-shadow 0.4s;
}
.sd-face.on { border-color: var(--sm-accent); box-shadow: 0 30px 70px -40px var(--sm-shadow), 0 0 0 4px var(--sm-accent-soft); }
.sd-face-art { position: relative; border-radius: 14px; overflow: hidden; background: var(--sm-bg); }
.sd-face-art canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.sd-face-meta { display: flex; align-items: center; gap: 12px; padding: 12px 6px 4px; }
.sd-face-n { font-family: var(--sm-mono); font-size: 11px; color: var(--sm-dim); }
.sd-face-t { flex: 1; font-weight: 600; font-size: 16px; }
.sd-face-go { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: var(--sm-ink); color: var(--sm-on-ink); font-size: 14px; transition: transform 0.3s; }
.sd-face.on:hover .sd-face-go { transform: rotate(45deg); }

.sd-rail { position: relative; list-style: none; margin: 0; padding: 0 0 0 22px; display: grid; gap: 14px; }
.sd-rail > i { position: absolute; left: 0; top: 6px; bottom: 6px; width: 1.5px; background: var(--sm-line-strong); }
.sd-rail > i > i { position: absolute; inset: 0; background: var(--sm-accent); transform-origin: top; transform: scaleY(0); }
.sd-rail button { border: 0; background: none; padding: 2px 0; font: inherit; font-family: var(--sm-mono); font-size: 11.5px; letter-spacing: 0.12em; text-transform: uppercase; text-align: left; color: var(--sm-dim); cursor: pointer; transition: color 0.3s, transform 0.3s; }
.sd-rail li.done button { color: var(--sm-muted); }
.sd-rail li.on button { color: var(--sm-ink); transform: translateX(4px); }
.sd-rail button:hover { color: var(--sm-accent-2); }

@media (max-width: 1000px) {
  .sd-stage { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto; align-content: center; gap: 18px; padding-top: calc(var(--sm-nav-h) + 8px); }
  .sd-rail, .sd-title, .sd-tip { display: none; }
  .sd-d { display: none; }
  .sd-nav { margin-top: 12px; }
  .sd-nav button { width: 44px; height: 44px; } /* touch targets */
  .sd-viewport { --r: clamp(150px, 40vw, 220px); height: min(52vh, 440px); }
  .sd-copy { order: 2; }
  .sd-cap { min-height: 150px; }
  .sd-h { font-size: 22px; }
}

@media (prefers-reduced-motion: reduce) {
  .sd-cap { animation: none; }
  .sd-face, .sd-rail button, .sd-cta, .sd-face-go { transition: none; }
}

/* ───────────── a self-rolling preview drum (decorative) ───────────── */

.sdp { --r: clamp(90px, 11vw, 150px); --sdp-n: 4; position: absolute; inset: 0; display: grid; place-items: center; perspective: 900px;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent); mask-image: linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent); }
.sdp-ring { position: relative; width: 70%; height: calc(var(--r) * 1.12); transform-style: preserve-3d; animation: sdp-roll 12s cubic-bezier(0.7, 0, 0.3, 1) infinite; }
.sdp-face { position: absolute; inset: 0; border-radius: 16px; overflow: hidden; background: var(--sm-surface); border: 1px solid var(--sm-line-strong); backface-visibility: hidden; -webkit-backface-visibility: hidden; }
/* four faces: dwell 18% of the loop on each, roll in 7% */
@keyframes sdp-roll {
  0%, 18% { transform: translateZ(calc(var(--r) * -1)) rotateX(0deg); }
  25%, 43% { transform: translateZ(calc(var(--r) * -1)) rotateX(90deg); }
  50%, 68% { transform: translateZ(calc(var(--r) * -1)) rotateX(180deg); }
  75%, 93% { transform: translateZ(calc(var(--r) * -1)) rotateX(270deg); }
  100% { transform: translateZ(calc(var(--r) * -1)) rotateX(360deg); }
}
@media (prefers-reduced-motion: reduce) { .sdp-ring { animation: none; transform: translateZ(calc(var(--r) * -1)); } }
````


---


## assets/scroll-motion.js


````js
/*! scroll-motion.js v1.0.0 · ImuTechz · MIT
 *
 * The engine behind scroll motion graphics: scroll position becomes a
 * progress value p in [0, 1], and every frame is a pure function of p.
 *
 *   ScrollMotion.scrub(el, onFrame, opts)   p for one element (useScrub, vanilla)
 *   ScrollMotion.film(opts)                 a pinned canvas film made of reels
 *   ScrollMotion.drum(root, opts)           an interactive 3D story drum
 *   ScrollMotion.scrollToProgress(el, p)    seek by scrolling the page
 *   ScrollMotion.*                          small math + drawing helpers
 *
 * Rules the engine keeps for you:
 *   - it never captures wheel, touch or keys and never pins the body;
 *     normal scrolling always moves the page
 *   - the rAF loop runs only while p is changing and the element is near
 *     the viewport; an idle page costs nothing
 *   - prefers-reduced-motion: no smoothing, no in-between drum rotation
 *
 * Zero dependencies. Works as a classic <script> (window.ScrollMotion),
 * as CommonJS, or through a bundler's CommonJS interop (import X from).
 */
(function (root, factory) {
  if (typeof module === "object" && module.exports) module.exports = factory();
  else root.ScrollMotion = factory();
})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function () {
  "use strict";

  /* ───────────────────────── math ───────────────────────── */

  const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
  const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
  const lerp = (a, b, t) => a + (b - a) * t;
  /* local progress of p inside the window [a, b] */
  const seg = (p, a, b) => clamp01((p - a) / (b - a || 1));
  const smoothstep = (t) => { t = clamp01(t); return t * t * (3 - 2 * t); };
  const ease = {
    linear: (t) => t,
    inOut: smoothstep,
    outCubic: (t) => 1 - Math.pow(1 - clamp01(t), 3),
    inOutCubic: (t) => { t = clamp01(t); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; },
    outBack: (t) => { t = clamp01(t); const c = 1.70158, c3 = c + 1; return 1 + c3 * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); },
    outExpo: (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * clamp01(t))),
  };
  /* staggered local progress for item i of n: item i starts at i*gap */
  const stagger = (t, i, n, span = 0.5) => {
    const gap = n > 1 ? (1 - span) / (n - 1) : 0;
    return clamp01((t - i * gap) / span);
  };
  /* a deterministic pseudo-random number in [0, 1) — never Math.random in a reel */
  const hash = (i, s = 0) => { const x = Math.sin(i * 127.1 + s * 311.7) * 43758.5453; return x - Math.floor(x); };
  const prefersReduced = () =>
    typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;

  /* ─────────────────────── colours ──────────────────────── */

  /* "#f2661a" | "rgb(…)" → [r, g, b] */
  function parseColor(c, fallback = [28, 25, 20]) {
    c = String(c || "").trim();
    let m = /^#([0-9a-f]{3})$/i.exec(c);
    if (m) return m[1].split("").map((h) => parseInt(h + h, 16));
    m = /^#([0-9a-f]{6})$/i.exec(c);
    if (m) return [0, 2, 4].map((k) => parseInt(m[1].slice(k, k + 2), 16));
    m = /^rgba?\(([^)]+)\)$/i.exec(c);
    if (m) return m[1].split(/[ ,/]+/).slice(0, 3).map(Number);
    return fallback;
  }
  const rgba = (c, a = 1) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
  const mix = (c1, c2, t) => [0, 1, 2].map((k) => Math.round(lerp(c1[k], c2[k], t)));

  /* the canvas palette comes from CSS custom properties on `el`, so light
     and dark mode are one stylesheet change, never a second code path */
  function readPalette(el) {
    const cs = getComputedStyle(el);
    const v = (name, fb) => parseColor(cs.getPropertyValue(name), fb);
    return {
      ink: v("--sm-ink", [28, 25, 20]),
      dim: v("--sm-dim", [138, 130, 119]),
      bg: v("--sm-bg", [243, 237, 226]),
      surface: v("--sm-surface", [251, 248, 242]),
      accent: v("--sm-accent", [242, 102, 26]),
      accent2: v("--sm-accent-2", [217, 72, 30]),
      cool: v("--sm-cool", [46, 108, 150]),
    };
  }

  /* ─────────────────────── geometry ─────────────────────── */

  /* Catmull-Rom through `pts`, sampled by arc length:
     returns { samples: [[x, y, len]], at: [len of each node], total } */
  function route(pts, perSeg = 40) {
    const n = pts.length, samples = [], at = [];
    let total = 0;
    if (!n) return { samples, at, total };
    const P = (i) => pts[clamp(i, 0, n - 1)];
    for (let i = 0; i < n - 1; i++) {
      const p0 = P(i - 1), p1 = P(i), p2 = P(i + 1), p3 = P(i + 2);
      for (let s = 0; s < perSeg; s++) {
        const t = s / perSeg, t2 = t * t, t3 = t2 * t;
        const x = 0.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
        const y = 0.5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
        if (samples.length) { const q = samples[samples.length - 1]; total += Math.hypot(x - q[0], y - q[1]); }
        if (s === 0) at[i] = total;
        samples.push([x, y, total]);
      }
    }
    const last = pts[n - 1], q = samples[samples.length - 1];
    if (q) total += Math.hypot(last[0] - q[0], last[1] - q[1]);
    samples.push([last[0], last[1], total]);
    at[n - 1] = total;
    return { samples, at, total };
  }

  /* stroke the first L pixels of a sampled route; returns the head point */
  function strokeTo(ctx, samples, L) {
    let head = samples[0];
    ctx.beginPath();
    for (let k = 0; k < samples.length; k++) {
      const s = samples[k];
      if (s[2] > L) {
        const a = samples[k - 1] || s, f = (L - a[2]) / Math.max(1e-6, s[2] - a[2]);
        head = [a[0] + (s[0] - a[0]) * f, a[1] + (s[1] - a[1]) * f];
        ctx.lineTo(head[0], head[1]);
        break;
      }
      head = s;
      k ? ctx.lineTo(s[0], s[1]) : ctx.moveTo(s[0], s[1]);
    }
    ctx.stroke();
    return head;
  }

  /* ───────────── keep-outs: where the drawing may not go ───────────── */

  /* el's layout box relative to `stage`, ignoring transforms (so a caption
     that is mid-fade still reserves its resting place) */
  function boxIn(el, stage) {
    let x = 0, y = 0, n = el;
    while (n && n !== stage) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent; }
    if (n !== stage) { // stage is not an offsetParent: fall back to rects
      const a = el.getBoundingClientRect(), b = stage.getBoundingClientRect();
      return { x: a.left - b.left, y: a.top - b.top, w: a.width, h: a.height };
    }
    return { x, y, w: el.offsetWidth, h: el.offsetHeight };
  }
  const pad = (r, d) => ({ x: r.x - d, y: r.y - d, w: r.w + 2 * d, h: r.h + 2 * d });
  const hits = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;

  /* the largest rectangle of [0,0,W,H] that misses every keep-out:
     cut the free area around each keep-out in turn, keeping the side with
     the best area (thin slivers are penalised) */
  function freeRect(W, H, keeps, margin = 0) {
    let F = { x: 0, y: 0, w: W, h: H };
    const score = (r) => (r.w <= 0 || r.h <= 0 ? -1 : r.w * r.h * Math.pow(Math.min(r.w, r.h) / Math.max(r.w, r.h), 0.35));
    const list = keeps.filter((k) => k.w > 0 && k.h > 0).sort((a, b) => b.w * b.h - a.w * a.h);
    for (const k of list) {
      if (!hits(F, k)) continue;
      const cands = [
        { x: F.x, y: F.y, w: k.x - F.x, h: F.h },                          // left of it
        { x: k.x + k.w, y: F.y, w: F.x + F.w - (k.x + k.w), h: F.h },      // right of it
        { x: F.x, y: F.y, w: F.w, h: k.y - F.y },                          // above it
        { x: F.x, y: k.y + k.h, w: F.w, h: F.y + F.h - (k.y + k.h) },      // below it
      ];
      F = cands.reduce((best, c) => (score(c) > score(best) ? c : best), cands[0]);
    }
    return { x: F.x + margin, y: F.y + margin, w: Math.max(0, F.w - 2 * margin), h: Math.max(0, F.h - 2 * margin) };
  }

  /* true when a circle at (x, y, r) touches a keep-out */
  const blocked = (keeps, x, y, r = 0) => keeps.some((k) => x + r > k.x && x - r < k.x + k.w && y + r > k.y && y - r < k.y + k.h);

  /* ─────────────────────── scrub ─────────────────────── */

  /* p for one element.
     mode "pin":  el is a tall track whose child (opts.stage, or a 100vh
                  viewport) is position: sticky; p = 0 when the track's top
                  reaches the viewport top, 1 when its bottom does.
     mode "view": p = how far el has travelled through the viewport.
     onFrame(p) runs only while p changes; then the loop stops. */
  function progressOf(el, mode, stage) {
    const r = el.getBoundingClientRect();
    const vh = window.innerHeight || 1;
    if (mode === "pin") {
      const span = el.offsetHeight - (stage ? stage.offsetHeight : vh);
      return span > 4 ? clamp01(-r.top / span) : clamp01(-r.top / vh);
    }
    return clamp01((vh - r.top) / (vh + r.height));
  }

  function scrub(el, onFrame, opts = {}) {
    const { mode = "pin", smooth = 0.18, stage = null, rootMargin = "25% 0px" } = opts;
    const reduce = opts.reduce != null ? opts.reduce : prefersReduced();
    let target = 0, cur = -1, raf = 0, on = false, dead = false;
    const measure = () => { target = progressOf(el, mode, stage); };
    function tick() {
      raf = 0;
      if (!on || dead) return;
      measure();
      const d = target - cur;
      cur = cur < 0 || reduce || Math.abs(d) < 0.0005 ? target : cur + d * smooth;
      onFrame(cur);
      if (cur !== target) raf = requestAnimationFrame(tick);
    }
    const kick = () => { if (on && !raf && !dead) raf = requestAnimationFrame(tick); };
    const io = new IntersectionObserver((es) => {
      const vis = es[es.length - 1].isIntersecting;
      if (vis && !on) { on = true; kick(); }
      else if (!vis && on) { on = false; if (raf) cancelAnimationFrame(raf); raf = 0; }
    }, { rootMargin });
    io.observe(el);
    addEventListener("scroll", kick, { passive: true });
    addEventListener("resize", kick);
    measure(); cur = target; onFrame(cur);
    return {
      kick,
      get p() { return cur; },
      stop() {
        dead = true; on = false; if (raf) cancelAnimationFrame(raf); raf = 0;
        io.disconnect();
        removeEventListener("scroll", kick);
        removeEventListener("resize", kick);
      },
    };
  }

  /* seek: scroll the page so a pinned track sits at progress p. Seeking is
     always done by scrolling, so the scroll position stays the one truth. */
  function scrollToProgress(el, p, opts = {}) {
    const stage = opts.stage || null;
    const top = el.getBoundingClientRect().top + window.scrollY;
    const run = el.offsetHeight - (stage ? stage.offsetHeight : window.innerHeight);
    const smooth = opts.smooth != null ? opts.smooth : !prefersReduced();
    window.scrollTo({ top: Math.round(top + run * clamp01(p)) + (opts.nudge || 0), behavior: smooth ? "smooth" : "instant" });
    // "instant", not "auto": auto obeys a page's CSS scroll-behavior: smooth
  }

  /* ─────────────────────── film ─────────────────────── */

  /* A pinned canvas film. Each reel owns an equal slice of p.
       reels:    [{ name, draw(ctx, t, env) }]   t ∈ [0,1] local progress
       captions: [element | null]              one per reel (DOM, data-keep)
     env = { W, H, mobile, colors, keep, free, alpha, p, index, reduce }
     A reel must be a pure function of (t, env): no state, no clock. */
  function film(opts) {
    const { track, stage, canvas, reels, captions = [], smooth = 0.14, dprMax = 2, onChange } = opts;
    const cross = opts.cross != null ? opts.cross : 0.12; // reel crossfade half-width, in reel units
    const capFade = opts.capFade != null ? opts.capFade : 0.1; // caption fade length, in reel units
    const keepPad = opts.keepPad != null ? opts.keepPad : 14;
    const ctx = canvas.getContext("2d");
    const n = reels.length;
    const reduce = opts.reduce != null ? opts.reduce : prefersReduced();
    const fixedKeeps = Array.from(stage.querySelectorAll("[data-keep]")).filter((el) => !captions.includes(el));
    const bar = stage.querySelector("[data-sm-bar]");
    const count = stage.querySelector("[data-sm-count]");
    let W = 0, H = 0, dpr = 1, colors = readPalette(stage), active = -1, painted = -1;
    let lastKeeps = [];

    function layout() {
      const r = stage.getBoundingClientRect();
      W = r.width; H = r.height;
      dpr = Math.min(window.devicePixelRatio || 1, dprMax);
      canvas.width = Math.max(1, Math.round(W * dpr));
      canvas.height = Math.max(1, Math.round(H * dpr));
      canvas.style.width = W + "px"; canvas.style.height = H + "px";
      colors = readPalette(stage);
      painted = -1;
    }

    /* the timeline, all pure functions of p */
    const local = (p, i) => p * n - i; // unclamped reel units: 0..1 is reel i's own slice
    function reelAlpha(p, i) {
      const u = local(p, i);
      const fin = i === 0 ? 1 : smoothstep((u + cross) / (2 * cross));
      const fout = i === n - 1 ? 1 : smoothstep((1 + cross - u) / (2 * cross));
      return Math.min(fin, fout);
    }
    /* captions sit inside their reel and leave a gap of 2*cross between
       neighbours, so two captions are never on screen at once */
    function capAlpha(p, i) {
      const u = local(p, i);
      const fin = i === 0 ? 1 : seg(u, cross, cross + capFade);
      const fout = i === n - 1 ? 1 : 1 - seg(u, 1 - cross - capFade, 1 - cross);
      return Math.min(fin, fout);
    }

    function paint(p) {
      // 1. writes: captions and HUD, all derived from p
      const caps = [];
      for (let i = 0; i < n; i++) {
        const el = captions[i];
        if (!el) continue;
        const a = capAlpha(p, i);
        caps[i] = a;
        el.style.opacity = a.toFixed(3);
        el.style.transform = reduce ? "none" : `translate3d(0, ${((1 - a) * 14).toFixed(1)}px, 0)`;
        el.style.visibility = a > 0.001 ? "visible" : "hidden";
      }
      if (bar) bar.style.transform = `scaleX(${p.toFixed(4)})`;
      const now = Math.min(n - 1, Math.floor(p * n));
      if (now !== active) {
        active = now;
        if (count) count.textContent = String(now + 1).padStart(2, "0") + " / " + String(n).padStart(2, "0");
        if (onChange) onChange(now, reels[now]);
      }

      // 2. reads: measure every keep-out this frame (one layout, if any)
      const fixed = fixedKeeps.filter((el) => el.offsetParent).map((el) => pad(boxIn(el, stage), keepPad));
      const own = captions.map((el) => (el && el.offsetParent ? pad(boxIn(el, stage), keepPad) : null));
      const visibleCaps = own.filter((r, i) => r && caps[i] > 0.001);
      lastKeeps = fixed.concat(visibleCaps);

      // 3. draw: clear, clip out every visible keep-out, then the reels
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, W, H);
      ctx.save();
      ctx.beginPath();
      ctx.rect(0, 0, W, H);
      for (const k of lastKeeps) ctx.rect(k.x, k.y, k.w, k.h);
      ctx.clip("evenodd"); // safety net: nothing can paint under a caption
      const mobile = W < 760;
      for (let i = 0; i < n; i++) {
        const alpha = reelAlpha(p, i);
        if (alpha <= 0.001) continue;
        const keep = fixed.concat(own[i] ? [own[i]] : [], visibleCaps);
        const env = {
          W, H, dpr, mobile, colors, keep, reduce, alpha, p, index: i,
          free: freeRect(W, H, keep, mobile ? 12 : 28),
        };
        ctx.save();
        ctx.globalAlpha = alpha;
        reels[i].draw(ctx, clamp01(local(p, i)), env);
        ctx.restore();
      }
      ctx.restore();
    }

    let target = 0, cur = 0, raf = 0, on = false;
    const measure = () => { target = progressOf(track, "pin", stage); };
    function tick() {
      raf = 0;
      if (!on) return;
      measure();
      const d = target - cur;
      cur = reduce || Math.abs(d) < 0.0005 ? target : cur + d * smooth;
      if (cur !== painted) { painted = cur; paint(cur); raf = requestAnimationFrame(tick); }
    }
    const kick = () => { if (on && !raf) raf = requestAnimationFrame(tick); };
    const io = new IntersectionObserver((es) => {
      const vis = es[es.length - 1].isIntersecting;
      if (vis && !on) { on = true; measure(); cur = target; painted = -1; kick(); }
      else if (!vis && on) { on = false; if (raf) cancelAnimationFrame(raf); raf = 0; }
    }, { rootMargin: "40% 0px" });
    io.observe(track);
    const ro = new ResizeObserver(() => { layout(); paint(cur); painted = cur; });
    ro.observe(stage);
    const mq = typeof matchMedia === "function" ? matchMedia("(prefers-color-scheme: dark)") : null;
    const retheme = () => { colors = readPalette(stage); paint(cur); };
    if (mq && mq.addEventListener) mq.addEventListener("change", retheme);
    addEventListener("scroll", kick, { passive: true });
    addEventListener("resize", kick);

    layout(); measure(); cur = target; paint(cur); painted = cur;

    return {
      get p() { return cur; },
      get keeps() { return lastKeeps.slice(); },
      /* paint an arbitrary p without scrolling (tests, posters) */
      renderAt(p) { paint(clamp01(p)); painted = -1; },
      seek(p) { scrollToProgress(track, p, { stage }); },
      seekReel(i, at = 0.5) { scrollToProgress(track, (clamp(i, 0, n - 1) + at) / n, { stage }); },
      retheme,
      destroy() {
        on = false; if (raf) cancelAnimationFrame(raf);
        io.disconnect(); ro.disconnect();
        if (mq && mq.removeEventListener) mq.removeEventListener("change", retheme);
        removeEventListener("scroll", kick);
        removeEventListener("resize", kick);
      },
    };
  }

  /* ─────────────────────── drum ─────────────────────── */

  /* An interactive story drum. Markup (see demo.html / scroll-motion.css):
       .sd (tall track)  > .sd-stage (sticky)
         [data-sd-cap] × n      captions, one per chapter
         .sd-viewport > .sd-ring > a.sd-face × n   faces are links
         [data-sd-go="i"]       rail buttons      [data-sd-prev] [data-sd-next]
         [data-sd-bar]          rail progress     [data-sd-count]
     Rotation is a pure function of scroll. Every control seeks by
     scrolling the page, so the scroll and the drum never disagree. */
  function drum(root, opts = {}) {
    const stage = root.querySelector(".sd-stage") || null;
    const vp = root.querySelector(".sd-viewport");
    const ring = root.querySelector(".sd-ring");
    const faces = Array.from(root.querySelectorAll(".sd-face"));
    const caps = Array.from(root.querySelectorAll("[data-sd-cap]"));
    const rail = Array.from(root.querySelectorAll("[data-sd-go]"));
    const prev = root.querySelector("[data-sd-prev]");
    const next = root.querySelector("[data-sd-next]");
    const bar = root.querySelector("[data-sd-bar]");
    const count = root.querySelector("[data-sd-count]");
    const n = faces.length, step = 360 / n;
    const reduce = opts.reduce != null ? opts.reduce : prefersReduced();
    let idx = -1;

    faces.forEach((f, i) => { f.style.transform = `rotateX(${-i * step}deg) translateZ(var(--r))`; f.draggable = false; });

    function show(i) {
      if (i === idx) return;
      idx = i;
      faces.forEach((f, k) => {
        const on = k === i;
        f.classList.toggle("on", on);
        f.tabIndex = on ? 0 : -1;               // only the face you see is in the tab order
        f.setAttribute("aria-hidden", on ? "false" : "true");
      });
      caps.forEach((c, k) => { c.hidden = k !== i; });
      rail.forEach((b, k) => {
        const li = b.closest("li") || b;
        li.classList.toggle("on", k === i);
        li.classList.toggle("done", k < i);
        if (k === i) b.setAttribute("aria-current", "step"); else b.removeAttribute("aria-current");
      });
      if (prev) prev.disabled = i === 0;
      if (next) next.disabled = i === n - 1;
      if (count) count.textContent = String(i + 1).padStart(2, "0") + " / " + String(n).padStart(2, "0");
      if (opts.onChange) opts.onChange(i);
    }

    const s = scrub(root, (p) => {
      const f = clamp01(p) * (n - 1);
      const base = Math.floor(f), t = f - base;
      // dwell: a smoothstep inside every step parks the drum on a face, then rolls
      const turn = reduce ? Math.round(f) : base + t * t * (3 - 2 * t);
      ring.style.transform = `translateZ(calc(var(--r) * -1)) rotateX(${(turn * step).toFixed(3)}deg)`;
      show(Math.min(n - 1, Math.round(f)));
      if (bar) bar.style.transform = `scaleY(${clamp01(p).toFixed(4)})`;
    }, { stage, smooth: opts.smooth || 0.18, reduce });

    const go = (i) => scrollToProgress(root, clamp(i, 0, n - 1) / (n - 1), { stage, nudge: 1 });

    const onRail = (e) => { const b = e.target.closest("[data-sd-go]"); if (b) go(+b.dataset.sdGo); };
    const onPrev = () => go(idx - 1), onNext = () => go(idx + 1);
    const onKey = (e) => {
      if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); go(idx + 1); }
      else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); go(idx - 1); }
      else if (e.key === "Home") { e.preventDefault(); go(0); }
      else if (e.key === "End") { e.preventDefault(); go(n - 1); }
    };
    root.addEventListener("click", onRail);
    if (prev) prev.addEventListener("click", onPrev);
    if (next) next.addEventListener("click", onNext);
    vp.addEventListener("keydown", onKey);

    /* drag with a mouse or pen: a vertical drag scrolls the page (touch
       already scrolls natively, so it is left alone) */
    let drag = null;
    const swallow = (ev) => { ev.preventDefault(); ev.stopPropagation(); };
    const down = (e) => {
      if (e.button !== 0 || e.pointerType === "touch") return;
      drag = { y: e.clientY, s: window.scrollY, moved: false };
    };
    const move = (e) => {
      if (!drag) return;
      const dy = e.clientY - drag.y;
      if (Math.abs(dy) > 4) { drag.moved = true; vp.classList.add("dragging"); }
      // instant: with CSS scroll-behavior: smooth every move would start a new smooth scroll
      if (drag.moved) window.scrollTo({ top: drag.s - dy * (opts.dragGain || 3.2), behavior: "instant" });
    };
    const up = () => {
      const d = drag; drag = null;
      vp.classList.remove("dragging");
      if (d && d.moved) {
        // let go on the face nearest the scroll position now (not the last
        // painted one, which can be a frame behind)
        go(Math.round(progressOf(root, "pin", stage) * (n - 1)));
        vp.addEventListener("click", swallow, { capture: true, once: true }); // the click that ends a drag is not a click
        setTimeout(() => vp.removeEventListener("click", swallow, { capture: true }), 50);
      }
    };
    vp.addEventListener("pointerdown", down);
    addEventListener("pointermove", move, { passive: true });
    addEventListener("pointerup", up);

    return {
      go,
      get index() { return idx; },
      destroy() {
        s.stop();
        root.removeEventListener("click", onRail);
        if (prev) prev.removeEventListener("click", onPrev);
        if (next) next.removeEventListener("click", onNext);
        vp.removeEventListener("keydown", onKey);
        vp.removeEventListener("pointerdown", down);
        removeEventListener("pointermove", move);
        removeEventListener("pointerup", up);
      },
    };
  }

  return {
    version: "1.0.0",
    // math
    clamp, clamp01, lerp, seg, smoothstep, ease, stagger, hash, prefersReduced,
    // colour
    parseColor, rgba, mix, readPalette,
    // geometry + layout
    route, strokeTo, boxIn, pad, hits, freeRect, blocked,
    // engines
    progressOf, scrub, scrollToProgress, film, drum,
  };
});
````


---

## Install

Save this file as `scroll-motion-graphics/SKILL.md` inside your agent's skills
directory (`~/.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, ...).
The reference sections above are inlined here; if you download the folder
edition instead, keep them as separate files next to `SKILL.md`.
