gsap / ScrollTrigger timeline that powers the homepage reveal batch and the lifecycle trace page's scroll-driven engine. All motion is registered once in src/lib/gsap.ts and gated for headless, reduced-motion, and escape paths.src/lib/gsap.ts:1-7import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { MotionPathPlugin } from "gsap/MotionPathPlugin"
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin)
export { gsap, ScrollTrigger, MotionPathPlugin }gsap instance — vitest mocks and ChapterScene.test.tsx assert against the same singleton.src/pages/LifecyclePage.tsxisTransitioning lock. Each of the 10 stage-* sections creates a scrubbed pin whose reveal timeline is bound to scrollTrigger inside the timeline config. Native #scroller scroll distance drives progress.// LifecyclePage.tsx:347 — gsap.context() so StrictMode revert restores inline styles
const animCtx = gsap.context(() => { ... }, root)immediateRender stays true (default): sections sit pre-hidden and are revealed by the scrub — no finished-state flash before the pin engages.buildStageTimeline:348-382const tl = gsap.timeline({ defaults:{ease:"power2.out"}, scrollTrigger: stVars })
tl.from(icon-wrap/stage-tag/section-title/desc p/chip/artifact, {opacity:0,y:18,stagger:.06,duration:.6}, 0)
.from(panel, {opacity:0,y:30,duration:.6}, .15)
.to(draws, {strokeDashoffset:0,duration:.9,stagger:.035,ease:"power2.inOut"}, .4)
.from(lc-fade, {autoAlpha:0,duration:.35,stagger:.03}, .75)
// motionPath pulse dot — circle appended to each .lc-flow path
.fromTo(dot,{opacity:0},{opacity:1,duration:.15}, at)
.to(dot,{motionPath:{path,align:path,alignOrigin:[.5,.5]},duration:1.15,ease:"power1.inOut"}, at)
.to(dot,{opacity:0,duration:.15})prepDraws:327-338 primes every .lc-draw (strokeDasharray = len, strokeDashoffset = len) so the to can draw.createElementNS("http://www.w3.org/2000/svg","circle") and appended to the path parent — cleaned on unmount querySelectorAll(".lc-pulse-dot").forEach(d=>d.remove()).stVars (385-404):{ trigger: sec, scroller: scrollerEl, start:"top top", end:"+=80%",
pin:true, pinSpacing:true, anticipatePin:1, scrub:0.45,
onToggle: self => { if(!self.isActive) return; activate(sec); currentIdx = STAGE_IDS.indexOf(id)+1 } }top center trigger on a pinned element mismeasures and makes the rail regress mid-scroll.gsap.timeline({delay:.1}).from(title line span {yPercent:110, stagger:.09}) + from(hero-art {opacity:0,x:60}) + from(scroll-cue).gsap.to(heroTitle/hero-art/scroll-cue {opacity:0,y:-40, scrub:true, trigger:"#lc-hero", start:"top top", end:"bottom top", onToggle: activate(hero)}).gsap.timeline({ scrollTrigger:{trigger:"#outro", scroller, start:"top 85%", end:"bottom bottom", scrub:true, onToggle: activate(outro)}})
.fromTo("#outro status-final/hero-title/hero-sub/chip {opacity:0,y:26} → {opacity:1,y:0,stagger:.08}", ...)end:"bottom bottom" owns the tail through maxScroll — the old top 30% left the final stretch deactivated so wrapping up from hero landed on a dead rail.if(reducedMotion) vs if(!reducedMotion)LifecyclePage.tsx:216 ignores OS prefers-reduced-motion intentionally (showcase always animates). Only QA opt-out ?motion=reduced flips the path:!reducedMotion builds the 10 scrub pins + hero entrance/exit + outro scrub (above).reducedMotion builds 12 plain triggers start:"top center", end:"bottom center", onToggle: activate — rail still live, zero pins, instant goTo (behavior: auto).startOffset:320 — Map<string,number> of trigger.id → st.start. refreshStarts:488 sorts stInstances with Number(b.animation!=null) - Number(a.animation!=null) so pin/scrub starts beat top center activation triggers (300px priority).goTo:501 — mapped ?? fallback scrollerEl.scrollTop + rect delta, scrollTo({behavior: reducedMotion?"auto":"smooth"}).activate:270 sets --accent, typewrites dataset.trace (14ms char), updates counter ring strokeDashoffset = 100-pct, rail --rail-pct, progress bar width, and history.replaceState(?stage=id) — sandboxed try/catch.WRAP_DELTA_WHEEL=40, WRAP_DELTA_TOUCH=8:handleEdgeWheel: e.deltaY ≥40 && atBottom → wrapAnimate(2) (top), e.deltaY ≤-40 && atTop → wrapAnimate(maxScroll-4)
wrapAnimate: gsap.to(scroller, {scrollTop:top, duration:reducedMotion?0:1.05, ease:"power2.inOut"})handleDirStart/Move tracks touchDirY, handleEdgeTouch checks abs(touchDirY) <8 → ignore, goingDown = touchDirY<0. Guard lastEdgeWrapAt 400ms debounce.return () => { ... }:621-641clearTimeout(deepLinkTimer/resizeTimer), remove load/keydown/wheel/touch listeners, clearInterval(typeTimer), animCtx.revert() (kills STs+timelines AND restores inline styles — critical so StrictMode pass #2 doesn't record hidden styles), stInstances.length=0, remove .lc-pulse-dot, removeProperty("--accent"), ScrollTrigger.defaults({scroller:null}), restore history.scrollRestoration.window → requestAnimationFrame sleeps: only gsap.set progresses. Lifecycle never relies on to for visibility (scrub needs scroll); the only to is the scrubbed timeline and the animated wrapAnimate (tested via forced stInstances push).document.fonts.ready.then(()=>refresh+refreshStarts) + load + setTimeout 220ms recalc.src/pages/Home.tsx:178-219gsap.context:gsap.set(".fe-flip,.hard-card,.demo-card,.dep-step",{autoAlpha:0,y:16})
ScrollTrigger.batch(".fe-flip,...",{start:"top 88%", once:true,
onEnter:(batch)=>gsap.to(batch,{autoAlpha:1,y:0,duration:.5,stagger:.06,clearProps:"transform"})})top 88%):gsap.to(..., {autoAlpha:1,y:0,duration:.4,clearProps:"transform"}) — animated catch-up.gsap.set(..., {autoAlpha:1,y:0}) — ticker-free forced completion.useIsReducedMotion exists but the showcase ignores it; section three honors it only via .motion-reduced class (Home.tsx:437 ?motion=reduced), and flip cards honor matchMedia("(prefers-reduced-motion: reduce)") in CSS (Transition: none; animation: none under .motion-reduced).src/components/lifecycle/ChapterScene.tsx:9-308SEC = var(--lc-sec), TXT = var(--lc-ink), DIM = var(--lc-mut) — light remap via html.light .lifecycle-root. Per-chapter SVG viewBox 0 0 900 420:.lc-draw — geometric strokes, prepped by prepDraws, drawn via to({strokeDashoffset:0})..lc-fade — labels/chips, from({autoAlpha:0})..lc-flow / data-flow="1" — pulse dot path; dot appended per flow, driven by motionPath.src/index.css:492-547: fx-boot 4.6s, fx-wave 1.05s, fx-tilt3d 3.2s, fx-radar-sweep 2.6s conic-gradient, fx-toast-up 5s, fx-pass-print 5.2s plane 6%→100%-22px. Gate: .fe-flip .fe-motif {paused} → :hover/:focus/.is-flipped {running}.766-714: fill-up, shield-flash, countdown 301 dashoffset, move-dot, pulse-glow, fade-in-row.trace-flow 1.4s dashoffset -18, hero-marquee 24s translateX -16.6% paused on hover.isTransitioning lock — native scroller scroll drives scrub:0.45 progress; wrap is the only gsap.to(scroller,scrollTop).gsap.context(...,root) + revert() kills triggers, timelines, inline styles and restores the DOM so StrictMode pass #2 doesn't record hidden as natural.top center trigger) prevents rail regression mid-pin.gsap.set after 4s guarantees visibility when requestAnimationFrame sleeps in headless/background tabs.?motion=reduced is the only kill-switch, wired before registration.