feat: give the chapters motion, diagrams, and retrieval practice
verify-and-publish / gate (push) Successful in 7m4s
verify-and-publish / publish (push) Has been skipped

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>
This commit is contained in:
Marcos Paulo
2026-09-06 16:04:08 -03:00
parent ab2308441c
commit 79920c9e6c
38 changed files with 1712 additions and 133 deletions
+4
View File
@@ -41,6 +41,10 @@ If there is no answer, ship it static. That is a legitimate, common outcome.
- One thing moves at a time. No staggered card cascades.
- `will-change` only immediately before animating, removed after.
- No animation library. This site's thesis is having no runtime dependencies.
- Scroll-scrubbed motion (reveals, parallax, depth, line draw) has its own rules
— ranges, feature gates, the `linear` exception, SVG line drawing. Read
[`references/scroll-driven.md`](references/scroll-driven.md) before touching
`src/styles/motion.css`.
## Reduced motion is not optional
@@ -0,0 +1,188 @@
# 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.