- lib/effort-pins.txt (map) + lib/effort-pins.sh (idempotent re-apply) replace the hardcoded brainstorming/writing-plans loop; called after the last vendoring step of install-plugins.sh AND update-all.sh (the resync dropped the pins until the next make plugin) - design stack high uniform (last loaded wins), superpowers, agent-skills, 21st pack pinned from the map; skills-perso low, pdf-translate medium, site-motion high - doctrine: design stack loads paired with the first Read; one level per stack (CLAUDE.global.md, lib/effort-shift.md) - lib/effort-audit.py prints thinking coverage per scope (sub-agent records carry no thinking count on ~94 % of requests) - census map-driven + fixture suite lib/tests/effort-pins.test.sh; docs README/USAGE/CHANGELOG; contract + TODO plan
9.4 KiB
9.4 KiB
name, effort, description, argument-hint, allowed-tools
| name | effort | description | argument-hint | allowed-tools | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| site-motion | high | 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". | <page, section or route to choreograph> |
|
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) orskills-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: 0gated 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 AFTERgsap.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-livedfilter/clip-path. Never a layout property during scroll. will-changeonly 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
ScrollTriggerthroughgsap.ticker, withgsap.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:motionis 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,
ScrollTriggerinstances, and RAF loops onastro: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:persiston 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:namefor 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.8to1.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 crossingtop 78%totop 24%. - Story pacing: budget
0.7to1.8viewport 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 scrubbedcurrentTimeseeks 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,stronginside): walk it withTreeWalker, wrap non-whitespace tokens in spans, keep the original text and its inline markup intact. - Progressive blur: stack
backdrop-filterlayers from0.5pxto64px, each masked to a12.5%band of the gradient; add the-webkit-backdrop-filterprefix for Safari; cap the band at12%of viewport height from the top edge,65%from the bottom. - Marquee: duplicate the track, animate
translateX(-50%)linear, mark the duplicatearia-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/webglcontextrestoredexplicitly. - Mobile budget: DPR
1.25to1.5,150kto300kvisible triangles,50to90draw 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 acrossastro:page-loadcycles means a teardown is missing, not that more is animating.
Upstream pitfalls
clearPropscombined 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.registerPluginrunning after thehtml.jsgate 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
resizeevent: 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-labelset on a<p>after flattening it to plain text: any inline link,em, orstrongit contained is gone for assistive tech, which now hears the label instead of the real content. UseTreeWalkersplitting on paragraphs with inline markup;aria-labelis fine only on a plain-text heading with nothing inside to lose.- Critical CSS starting elements at
opacity: 0with 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 detecton the touched files as the anti-slop floor.