Three strands of work on the chapter surface, all reading from the same constraint: this site ships no runtime dependencies, so every effect below is native CSS or an .astro component. Motion (src/styles/motion.css, DrawRule.astro). A scroll-driven layer of reveals, hero parallax, section depth, and a hairline that paints itself along its path, scrubbed by `animation-timeline: view()` — 0KB against lottie-web's ~60KB gzipped on the main thread. Every scrubbed rule sits inside `prefers-reduced-motion: no-preference` and `@supports`, so a Firefox reader or an opted-out one gets the complete static page rather than one with holes in it. Ranges key to `cover 40%`-`cover 75%` where the motion is meant to be watched: `view()` ranges key to first visibility, which on a 2600px page is long before anyone is reading the section. `.agents/skills/motion/references/scroll-driven.md` records the technique and the two ways `pathLength` normalisation was broken while building it. Diagrams (StepFlow.astro, WorktreeMap.astro). The `.steps` stack on /skills/, /models/, and /agents/ becomes a numbered flow with connectors, and /agents/ grows the worktree map it was describing in prose — reusing the `trees` collection rather than a second set of strings. The map's static variant is gated one class deeper than full-guide's page styles, so the interactive copy renders byte-identical. Connectors are pseudo-elements, not SVG: a stretched path desynchronises its own dash pattern, and a straight line does not need one. Mermaid was considered and rejected at ~1MB of runtime. Retrieval practice (/rules/, /skills/). A "check yourself" section of native `<details>` question/answer pairs plus a citation row, both bilingual through the existing `data-copy` toggle, and both JavaScript-free. Two audit blind spots surfaced and are closed rather than worked around: `build.inlineStylesheets: 'never'`, because Astro inlined sheets under ~4kB and audit-ui.mjs reads its colour and size baseline from dist/_astro/*.css; and `--columns` declared in the grid components, because an element-level custom property is not a declaration the audit can resolve. legacy/styles/skills.css is renamed and imported for its side effect. Inside a *page*, `?url` resolves to that page's own CSS chunk whatever file it names, so the link pointed at the wrong asset and the sheet was emitted but never loaded — the package preview had been rendering unstyled and overflowing since the Astro cutover. verify.mjs gains 5 assertions for the recall sections and their Portuguese copy: 89 now, against the 84 baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.7 KiB
Reference: scroll-driven motion
Everything scrubbed by scroll position on this site — reveals, parallax, section
depth, line draw. Read before adding a class to src/styles/motion.css or
reaching for a scroll library.
Why this is the whole toolkit
.agents/rules/animation.md forbids animation libraries: the site's thesis is
having no runtime dependencies. That rules out Lottie, Rive, GSAP ScrollTrigger,
Motion One, and AOS. It is not a hardship — for the motion this site does, the
native features are also the better engineering:
| Approach | Runtime | Thread |
|---|---|---|
animation-timeline: view() |
0 KB | compositor |
| lottie-web | ~60 KB gz | main, requestAnimationFrame |
| dotLottie | ~50 KB gz | main |
| Rive (WASM) | ~200 KB | main + WASM |
Lottie earns its place for cinematic, multi-layer, path-morphing artwork drawn
by a motion designer. Reveals, drifts, and a hairline drawing itself are not
that. If a future request genuinely needs frame-by-frame artwork, it is a rule
change to argue for in .agents/rules/animation.md, not a quiet pnpm add.
Support and the two gates
Chromium 115+, Safari 26+. Firefox has it behind a flag. That is fine, because every scrubbed rule sits inside two nested gates:
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
/* scrubbed rules only */
}
}
The @supports gate is not decoration. Anything that hides an element at rest —
opacity: 0, stroke-dashoffset: 100 — must live inside it. Declared
outside, a Firefox reader gets a permanently invisible element, because the
animation that would have revealed it never runs.
Ranges
animation-range is the duration; there is no animation-duration on a
scrubbed animation. Defaults are not what you would guess: bare
animation-range: normal means cover 0% cover 100%, edge-to-edge for the
element's whole visible life, which is rarely the effect wanted. Always state
the range.
cover— first pixel enters → last pixel leaves. The full journey.contain— only while fully visible. Flips meaning when the element is taller than the viewport: it then means "the element fills the scrollport". This is the usual source of confusion; preferentry/exit/cover.entry— starts entering → fully inside. Use for one-shot arrivals.exit— starts leaving → fully outside. Use for departures.
Ranges may be mixed at the two ends (entry 15% cover 55%). Legal, and harder
to debug — do it only when a single named range cannot express the moment.
Always both for fill mode. Without it the animation snaps back to its from
state when scrolled past, and holds nothing before the range begins.
Always linear easing. The scroll position is the timing function; a curve
here double-eases and reads as drag. This is the one sanctioned exception to
"never linear" in .agents/rules/animation.md; time-based motion still uses
--ease-out.
Ranges key to first visibility, not to reading position
The trap that produced the first version of every animation on /skills/.
entry begins the moment one pixel crosses the bottom of the scrollport. On a
2600px page, a section's entry phase is over while the reader is still on the
hero — the motion is finished before anyone is looking at it. Measured on the
skills chapter: an entry 15% cover 55% line draw ran from scrollY 190 to 654,
and the section it belonged to did not reach the top of the viewport until 599.
For motion meant to be watched, target the band where the subject sits around
the middle of the screen: cover 40% to cover 75%. For motion meant to be
over before reading starts — a reveal — entry is right, and that is why
.reveal keeps it.
Check this, do not eyeball it. Print
getAnimations()[0].effect.getComputedTiming().progress at several
window.scrollTo positions and confirm progress crosses 0→1 inside the range
where the section's own top is between roughly 0 and 400px.
Amplitude has a floor
A drift spread across an element's whole visible life is mostly off-screen. A 22px/6px parallax pair measured out at 3-10px of separation across the window where the section was actually read — arithmetically present, perceptually absent. Motion that cannot be seen is not restraint, it is dead code. The pair is 38px/10px for that reason, ~25px of separation in the reading window.
What this site's classes mean
| Class | Range | For |
|---|---|---|
.reveal |
entry 0% entry 45% |
a section rising into place, settled early |
.parallax-near / -far |
exit 0% exit 100% |
hero pieces, which start already on screen |
.depth-slow / .depth-fast |
entry 0% exit 100% |
the two columns of a mid-page section |
.draw-line path |
cover 40% cover 75% |
a hairline painting itself, while watched |
The hero pair and the section pair differ in range for a reason: a hero is on screen at load, so only its exit is watched; a mid-page section is watched from first pixel to last.
Parallax rates go the same direction with different magnitudes. Opposite directions read as the page coming apart, not as depth.
Line draw
/* inside both gates */
.draw-line path {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: line-draw linear both;
animation-timeline: view();
animation-range: entry 15% cover 55%;
}
@keyframes line-draw {
to {
stroke-dashoffset: 0;
}
}
pathLength="100" normalises the path to 100 user units whatever its real
geometry, so the dash numbers above are constant with no JS measurement step.
fill: none on the path, or the shape fills in behind the stroke.
stroke-dasharray and stroke-dashoffset must be equal at rest. Offset
smaller than the array → the line starts part-drawn. Larger → a gap that never
closes.
Never let user space and device space diverge
pathLength normalises in user space. Anything that makes the rendered
geometry a different shape from the user-space geometry desynchronises the dash
pattern, and the line renders as a painted run, a gap, and a floating fragment.
Two ways to cause it, both hit while building DrawRule.astro:
preserveAspectRatio="none"on a stretched viewBox — each axis scales by a different factor.vector-effect="non-scaling-stroke"— Chromium spaces dashes in device space whilepathLengthnormalises in user space. Uniform scaling does not save you here.
The fix is to remove the scaling rather than compensate for it: build the
viewBox at the rendered pixel size and emit the path in those units. Scale
factor 1, one space, stroke-width: 1 is 1px, and neither attribute is needed.
Verify by counting painted runs, not by looking — a correct draw has exactly one
at every progress value. Sample points along path.getTotalLength(), convert
the computed stroke-dasharray/stroke-dashoffset out of the normalised 100
units, and count transitions from gap to dash.
stroke-dashoffset is the site's one sanctioned exception to
transform/opacity-only. It forces a repaint, not a layout, and the subject is a
hairline — but do not extend the exception to filled artwork.
Straight lines do not need SVG
A plain vertical or horizontal rule that grows is transform: scaleY() with
transform-origin: top on a 1px div. Fully composited, no repaint, no SVG.
Reach for the dash technique only when the path bends.
Printing
Print freezes animations at their current time, so a section that never entered
prints invisible and a half-drawn line prints as a fragment. Every scrubbed
class needs an entry in the @media print block at the foot of motion.css —
animation: none for transforms, plus stroke-dasharray: none for a drawn
line, since undoing the animation alone leaves the dash pattern in place.
Checking it
- Chrome DevTools → Rendering → Emulate
prefers-reduced-motion: reduce. The static page must be correct and complete, not a page with holes in it. - Firefox, or Chrome with
about:config-style support disabled: same test for the@supportsfallback. scroll-driven-animations.stylehas a range visualiser; there is a Scroll-Driven Animations debugger extension for DevTools.bash .agents/scripts/gate.sh—audit-ui.mjsfails the build on horizontal overflow, which a mis-sized decorative SVG will cause.