feat: give the chapters motion, diagrams, and retrieval practice
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:
@@ -21,7 +21,7 @@ a bug.
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
files=['legacy/styles/guide.css','legacy/styles/chapters.css','legacy/styles/skills.css',
|
||||
files=['legacy/styles/guide.css','legacy/styles/chapters.css','legacy/styles/skills-chapter.css',
|
||||
'legacy/styles/skills-review.css','legacy/styles/change-lens.css',
|
||||
'legacy/styles/audit.css','public/hands-on/starter/styles.css',
|
||||
'public/hands-on/rules/styles.css']
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user