79920c9e6c
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>
189 lines
8.7 KiB
Markdown
189 lines
8.7 KiB
Markdown
# 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:
|
|
|
|
```css
|
|
@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; prefer `entry`/`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
|
|
|
|
```css
|
|
/* 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
|
|
while `pathLength` normalises 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 `@supports` fallback.
|
|
- `scroll-driven-animations.style` has a range visualiser; there is a
|
|
Scroll-Driven Animations debugger extension for DevTools.
|
|
- `bash .agents/scripts/gate.sh` — `audit-ui.mjs` fails the build on horizontal
|
|
overflow, which a mis-sized decorative SVG will cause.
|