feat(skills): site-motion, site-level scroll and transition choreography

Personal skill distilling the MengTo motion pack invariants (LRN-141):
gates first (reduced motion renders final states, content visible without
JS, compositor-only, offscreen pause), one smooth-scroll engine with the
Lenis/ScrollTrigger sync, Astro ClientRouter lifecycle, numbered recipes
(reveal, scrub, sticky stack, video and image scrub, TreeWalker split,
progressive blur, marquee, WebGL budgets), upstream pitfalls, checklist.
Routed into the Build UI chain of CLAUDE.global.md and lib/design-gate.md.
This commit is contained in:
bastien
2026-09-28 01:40:01 +02:00
parent 2a1ad1797b
commit ba14b5ea03
4 changed files with 196 additions and 1 deletions
+185
View File
@@ -0,0 +1,185 @@
---
name: site-motion
description: |
Site-level motion choreography: scroll engine choice, page-transition
rules, and pin/scrub sequencing across a whole page or Astro route —
not one component's hover or enter/exit (that's design-motion-principles
or emil-design-eng). Distills the invariants behind smooth scroll,
scroll storytelling, sticky card stacks, video/image scrubbing, and
WebGL hero lanes into gates, numbers, and pitfalls.
Triggers: "site mouvementé", "scroll storytelling", "smooth scroll",
"hero WebGL", "transitions de page", "page transitions", "Lenis",
"ScrollTrigger", "Awwwards".
argument-hint: <page, section or route to choreograph>
allowed-tools:
- Read
- Edit
- Write
- Bash
- Grep
- Glob
---
# Site motion — page-level scroll and transition choreography
Invariants, not machinery: numbers and gates that hold across whichever
scroll library the project already runs (LRN-141). Defaults follow
`rules/web-building.md`; this skill only adds the site-level layer on
top of it.
## When to use this, not the component skills
- One component's hover, tap feedback, or enter/exit → the target feels
small and self-contained → `skills-external/design-motion-principles/SKILL.md`
(component motion, frequency/duration framework) or
`skills-external/emil-design-eng/SKILL.md` (taste, polish).
- The choice is page-wide: which scroll engine, whether to pin a section,
how a route transition should morph, how a WebGL hero should degrade →
this skill.
- Linting already-shipped motion against anti-slop rules →
`skills/impeccable/reference/animate.md` (`impeccable detect`) as the
deterministic floor, or design-motion-principles' own audit workflow
(`skills-external/design-motion-principles/workflows/audit.md`).
- Non-motion defaults (fonts, color, spacing, the public-site checklist)
stay in `rules/web-building.md` — read it, don't restate it here.
## Gates first (fail closed, not invisible)
- Under `prefers-reduced-motion: reduce`, every animation renders its
FINAL state, never a shortened version of the same tween — jump, don't
rush.
- Content is visible with JavaScript disabled; no permanent
`opacity: 0` gated only by a script that might fail to run.
- Any `html.js` (or `.has-motion`) class that hides the pre-animation
state is added only AFTER `gsap.registerPlugin(...)` and the reveal
setup both succeed — never before. An error between the two otherwise
leaves real content stuck invisible with no JS path left to reveal it.
- Animate compositor-only properties: `transform`, `opacity`, short-lived
`filter`/`clip-path`. Never a layout property during scroll.
- `will-change` only while an element is actively animating; drop it
once the animation ends.
- Every RAF loop, CSS animation, and WebGL render loop pauses when its
section leaves the viewport and resumes on re-entry.
- The first-viewport CTA is never covered by a preloader.
- No preloader on a fixed timer — tie its exit to real load state.
## Engine choice
- Exactly one smooth-scroll engine per page. A second scroller (native
plus Lenis, or two libraries) fights the first over wheel/touch input
and desyncs from anything watching scroll position.
- Lenis feeds `ScrollTrigger` through `gsap.ticker`, with
`gsap.ticker.lagSmoothing(0)` — without it, a tab-switch catch-up jump
throws scrub position out of sync with the visuals.
- Reach for CSS `animation-timeline: scroll()` / `view()` first when the
effect is a plain progress mapping with no pin and no cross-timeline
coordination. GSAP/Lenis earn their cost on pin, scrub-linked
sequencing, or a timeline shared across sections (BDR-005: `motion` is
the default library; GSAP is allowed once the project already uses it
or the effect needs pin/scrub).
## Astro lifecycle (ClientRouter)
Route swaps replace the DOM without a full reload, so `DOMContentLoaded`
fires once and never again.
- Init scroll engines, `ScrollTrigger` instances, and RAF loops on
`astro:page-load` — it fires after every swap, including the first.
- Teardown on `astro:before-swap`: kill ScrollTriggers, stop the
scroller, cancel RAF, disconnect observers, before the old DOM is
replaced — otherwise the previous route's loop keeps running detached.
- `transition:persist` on a WebGL canvas or renderer that should survive
the swap instead of losing its context every navigation; pair it with
hooks that update the scene, not rebuild it.
- `transition:name` for element morphs (hero image to detail image)
across routes; leave unrelated elements unnamed to avoid accidental
cross-fades.
- Test every scene with JavaScript on and off — without it, the
ClientRouter falls back to a normal navigation and the page still has
to make sense.
## Recipes (the numbers)
- Reveal: trigger at `top 82%`, once; ease the entrance tween itself,
never the trigger point.
- Scrub scenes: `scrub: 0.8` to `1.4`, `ease: "none"` on the
scroll-driven tween — ease the child tweens inside it instead, so the
outer timeline stays scroll-linear while the content still feels eased.
- Sticky card stack: the receding card scales to `0.92 + i * 0.015`
(`i` = card index), scrubbed from the next card crossing `top 78%` to
`top 24%`.
- Story pacing: budget `0.7` to `1.8` viewport heights of scroll per
story beat — below that it reads as a flicker, above it as a stall.
- Video scrub: encode with
`ffmpeg -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart` — a
keyframe every 8 frames so scrubbed `currentTime` seeks land on-frame.
- Image sequences: preload the current frame first, prefetch neighbors,
and cancel stale in-flight requests on a fast scroll so a slow response
can't paint an out-of-order frame.
- Word-level reveal on marked-up text (links, `em`, `strong` inside):
walk it with `TreeWalker`, wrap non-whitespace tokens in spans, keep
the original text and its inline markup intact.
- Progressive blur: stack `backdrop-filter` layers from `0.5px` to
`64px`, each masked to a `12.5%` band of the gradient; add the
`-webkit-backdrop-filter` prefix for Safari; cap the band at `12%` of
viewport height from the top edge, `65%` from the bottom.
- Marquee: duplicate the track, animate `translateX(-50%)` linear, mark
the duplicate `aria-hidden`, pause the track while its section is
offscreen.
- Magnetic and cursor motion: drive with `gsap.quickTo()` so a pointer
move updates the existing tween instead of creating a new one per
event.
- WebGL hero, one lane per page:
- A pricing or checkout page keeps the WebGL lane decorative only.
- Build the poster fallback first, enhance after — the poster is the
page when WebGL fails or the tab throttles.
- Handle `webglcontextlost`/`webglcontextrestored` explicitly.
- Mobile budget: DPR `1.25` to `1.5`, `150k` to `300k` visible
triangles, `50` to `90` draw calls.
- Track two progress values: exact scroll progress for navigation and
ARIA state, a damped one for the camera — the camera can lag, the
nav state cannot.
- Leak census: sample `document.getAnimations()` before and after a
route round-trip. A count that grows across `astro:page-load` cycles
means a teardown is missing, not that more is animating.
## Upstream pitfalls
- `clearProps` combined with a visibility override in the same
reduced-motion call clears that override before the CSS-hidden class
it was meant to defeat ever lifts — text stays hidden. Clear props or
drop the hiding class; don't do both in one step.
- `registerPlugin` running after the `html.js` gate is already set: see
Gates above, this is the concrete failure it prevents.
- A per-frame increment (`phi += 0.01`) with no delta-time factor ties
rotation speed to frame rate — the same globe spins at a different
real-world speed on a 30 Hz and a 120 Hz display.
- A WebGL context torn down and rebuilt on every `resize` event: expensive,
and rapid resizing (mobile keyboard, orientation flicker) can trigger a
real context loss. Resize the renderer/camera in place; debounce first.
- A preloader gated on a fixed timer exits early on a slow connection and
late on a fast one; gate it on real asset load state instead.
- `aria-label` set on a `<p>` after flattening it to plain text: any
inline link, `em`, or `strong` it contained is gone for assistive tech,
which now hears the label instead of the real content. Use `TreeWalker`
splitting on paragraphs with inline markup; `aria-label` is fine only on
a plain-text heading with nothing inside to lose.
- Critical CSS starting elements at `opacity: 0` with no fallback: if the
script errors, is blocked, or never loads, the section stays invisible
forever. Pair every such rule with the gates above.
## Verification checklist
- Reload each scene with reduced motion forced on: final states, no
smooth scroll, no pinning.
- Disable JavaScript: every section is present and readable in order.
- Scrub through fast and reversed: same scroll position always yields
the same visual state.
- Resize mid-scene, including orientation change on a real WebGL scene.
- Navigate the Astro route twice: `document.getAnimations()` count
returns to baseline, no duplicate ScrollTriggers, no orphaned RAF loop.
- Throttle to a slow connection: preloader and poster still make sense,
CTA is reachable immediately.
- Tab away mid-scrub, come back: no jump beyond what `lagSmoothing(0)`
already accounts for.
- Run `impeccable detect` on the touched files as the anti-slop floor.
+6
View File
@@ -0,0 +1,6 @@
[
{"id": 1, "prompt": "Build a scroll storytelling landing page with Lenis smooth scroll and a sticky project card stack", "expected": "Route to site-motion: one smooth-scroll engine, Lenis->ScrollTrigger sync via gsap.ticker + lagSmoothing(0), sticky stack scale 0.92+i*0.015 recipe, reduced-motion gate"},
{"id": 2, "prompt": "Ajoute des transitions de page fluides avec un hero WebGL qui doit survivre à la navigation", "expected": "Route to site-motion: Astro ClientRouter lifecycle, astro:page-load/astro:before-swap, transition:persist on the canvas, WebGL poster fallback + context-loss handling"},
{"id": 3, "prompt": "Add a hover scale effect and a fade-in on this pricing card component", "expected": "Component-level, not site-motion: route to design-motion-principles or emil-design-eng instead"},
{"id": 4, "prompt": "Make our site feel like an Awwwards ScrollTrigger showcase, with a scrubbed video sequence", "expected": "Route to site-motion: scrub 0.8-1.4 with ease none + eased children, ffmpeg -g 8 -keyint_min 8 encoding recipe, offscreen-pause and reduced-motion gates before any pin/scrub work"}
]