From a58c00a6d75e57567d9a0a935b6890f8396dd7b7 Mon Sep 17 00:00:00 2001 From: jbernard077 Date: Tue, 8 Sep 2026 14:33:09 -0400 Subject: [PATCH] feat(skills): spring registers, bakeSpring for the CSS/WAAPI lanes, follow-through + inertia-chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends adapters/gsap-easing-and-stagger.md → Spring Eases on top of the existing springEase: named registers as a feel-word lookup (snappy / heavy-settle for entrances; bouncy / wobbly as a separate impact-recovery table, with the "< 0.55 — Don't" row amended to name that exception), the greppable duration-is-an-output criterion, and bakeSpring() emitting a CSS linear() easing for the css and waapi adapters with measured parity. Adds two rules — follow-through (a velocity- continuous ring composed into any ease that arrives with speed) and inertia-chain (followers replay the leader's identical tween, time-shifted by frames) — their rules-index rows, and one-line pointers in spring-pop-entrance.md, css-animations.md and waapi.md. --- skills-manifest.json | 4 +- .../adapters/css-animations.md | 1 + .../adapters/gsap-easing-and-stagger.md | 125 +++++++++++++++++- .../hyperframes-animation/adapters/waapi.md | 1 + skills/hyperframes-animation/rules-index.md | 2 + .../rules/follow-through.md | 89 +++++++++++++ .../rules/inertia-chain.md | 94 +++++++++++++ .../rules/spring-pop-entrance.md | 2 +- 8 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 skills/hyperframes-animation/rules/follow-through.md create mode 100644 skills/hyperframes-animation/rules/inertia-chain.md diff --git a/skills-manifest.json b/skills-manifest.json index 184a7712c4..18f4ca3843 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,8 +22,8 @@ "files": 17 }, "hyperframes-animation": { - "hash": "3947516ec450f3aa", - "files": 121 + "hash": "34856794427ae403", + "files": 123 }, "hyperframes-audio": { "hash": "b39bac771e873eae", diff --git a/skills/hyperframes-animation/adapters/css-animations.md b/skills/hyperframes-animation/adapters/css-animations.md index 61915aa793..fe7659403b 100644 --- a/skills/hyperframes-animation/adapters/css-animations.md +++ b/skills/hyperframes-animation/adapters/css-animations.md @@ -100,6 +100,7 @@ Use CSS custom properties to avoid duplicating keyframes: - Decorative loops with a known repeat count. - Mask, glow, shimmer, grain, and subtle parallax layers. - Simple one-element entrances where a full JS timeline would be excessive. +- A physical spring settle without GSAP: bake the closed-form spring into a `linear()` timing function with `bakeSpring()` (`gsap-easing-and-stagger.md` → Spring Eases → bakeSpring); the duration is still the helper's. ## Avoid diff --git a/skills/hyperframes-animation/adapters/gsap-easing-and-stagger.md b/skills/hyperframes-animation/adapters/gsap-easing-and-stagger.md index 8e03eafd53..4bb290da11 100644 --- a/skills/hyperframes-animation/adapters/gsap-easing-and-stagger.md +++ b/skills/hyperframes-animation/adapters/gsap-easing-and-stagger.md @@ -116,12 +116,12 @@ tl.fromTo( ); ``` -| dampingFraction | overshoot | register | -| ----------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| **1.0 (default)** | none (monotone) | The house settle — the exact curve `power3.out` approximates. Product / enterprise / serious tone. | -| 0.80–0.85 | ~1–1.5% | "Alive, not bouncy" — the iOS system default register. The overshoot is felt, not seen. | -| 0.60–0.70 | ~5–10% | Explicitly-playful ONLY (same rule as `back.out`, which this replaces — a spring's second-order settle reads physical where `back` reads cartoon). | -| < 0.55 | > 12% | Don't. Cartoon-wobble territory. | +| dampingFraction | overshoot | register | +| ----------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1.0 (default)** | none (monotone) | The house settle — the exact curve `power3.out` approximates. Product / enterprise / serious tone. | +| 0.80–0.85 | ~1–1.5% | "Alive, not bouncy" — the iOS system default register. The overshoot is felt, not seen. | +| 0.60–0.70 | ~5–10% | Explicitly-playful ONLY (same rule as `back.out`, which this replaces — a spring's second-order settle reads physical where `back` reads cartoon). | +| < 0.55 | > 12% | Don't — as an entrance. Cartoon-wobble territory for an arrival. The one sanctioned home for this band is impact **recovery** (Recovery Registers below), never the travel itself. | | response | duration (ζ=1) | feel | | --------- | -------------- | ------------------------------------------------------------ | @@ -135,6 +135,119 @@ Craft notes: - **At ζ<1, overshooting curves go on transforms only** — never on `opacity` (it would push past 1) or color. Split opacity onto its own `power2.out` tween at the same timeline position. - **Doctrine unchanged**: ζ below ~0.8 is still the rare, explicitly-playful exception (`rules/spring-pop-entrance.md`). The default of this section is ζ=1 — real spring physics is not a license for bounce. +### Named Registers + +Feel words over the same helper — a lookup table, not a second API. Each register is a `{ response, dampingFraction }` pair that shipped through a deterministic reference build (24 s reel, 2026-09-08, running this helper: every spring tween consuming the helper's duration verbatim, double-render bit-identical). The durations below are what `springEase` returns for the pair; take them from the helper, never from this table. + +```javascript +const SPRING_REGISTERS = { + // Entrance / settle voices — arrivals, lockups, hero landings (ζ ≥ 0.8 per the doctrine above). + snappy: { response: 0.22, dampingFraction: 0.9 }, // tight snap: chips, small UI + "heavy-settle": { response: 0.8, dampingFraction: 1 }, // weighted lockup — the settle IS the shot + // Recovery voices — ONLY the spring-back after a contact (see Recovery Registers). Never an arrival. + bouncy: { response: 0.4, dampingFraction: 0.5 }, + wobbly: { response: 0.5, dampingFraction: 0.28 }, +}; +const spring = (feel) => springEase(SPRING_REGISTERS[feel]); + +const land = spring("heavy-settle"); +tl.fromTo( + "#lockup", + { y: 80, opacity: 0 }, + { y: 0, opacity: 1, duration: land.duration, ease: land.ease }, + 1.2, +); +``` + +| register | response | ζ | duration (from the helper) | use | +| -------------- | -------- | ---- | -------------------------- | ---------------------------------------------------------- | +| `snappy` | 0.22 | 0.90 | ≈ 0.29s | tight snap — chips, badges, small UI; ~0.1% overshoot | +| `heavy-settle` | 0.80 | 1.00 | ≈ 1.18s | weighted hero landing, end card, wordmark lockup; monotone | + +#### Recovery Registers (impact recovery only) + +`bouncy` and `wobbly` sit inside the "< 0.55 — Don't" band on purpose. They are **deformation-recovery** voices — the spring-back of a body after it lands and squashes, a control after release, the settle-back of a chain of followers after the leader's arrival — not entrance eases. A recovery starts _at_ the contact frame and moves the element by a small fraction of the arrival travel — a few percent of a large element's height, up to about its own height for a chip or badge — never the travel itself, so a 16–40% overshoot of that small displacement reads as material (rubber, jelly, drag), where the same overshoot on the arrival travel reads as cartoon. + +| register | response | ζ | overshoot | duration (from the helper) | recovery context | +| -------- | -------- | ---- | --------- | -------------------------- | ------------------------------------------------------------------------- | +| `bouncy` | 0.40 | 0.50 | ~16% | ≈ 0.81s | soft-body landing recovery, a released press, a chain's settle-back move | +| `wobbly` | 0.50 | 0.28 | ~40% | ≈ 1.90s | rubber / jelly tier — the wobble _is_ the material read; rare, deliberate | + +- The arrival keeps the entrance doctrine (ζ ≥ 0.8, or `power3.out`); only the post-contact recovery may go `bouncy` / `wobbly`. +- Recovery goes on a transform or a deformation proxy that was just displaced — never on `opacity`, never on the arrival travel. +- Longer flight in a chain or trail comes from `response`, not from the duration (next section). + +### Duration Is an Output — the Greppable Criterion + +`springEase` returns the settle time and the tween consumes it verbatim, so the audit is mechanical: a spring tween's `duration:` is `.duration` with **no arithmetic on it**. + +```bash +grep -nE 'duration\s*\*|\*\s*[A-Za-z_.]*duration' index.html # spring tweens: zero hits +``` + +The `css` / `waapi` lanes need milliseconds: cast once through a helper (`ms(s.duration)`, next section) so a unit conversion never reads as arithmetic on a spring duration. A longer or shorter flight comes from `response` — `springEase({ ...SPRING_REGISTERS.bouncy, response: 0.4 * 1.4 })` — the same normalized curve over a physics-derived settle. A stretched duration draws the identical pixels (it re-times the same curve) and is still the anti-pattern the grep catches: it hides the physics parameter from the reader and from the next edit, and it is the first thing to drift when a beat gets re-timed. + +### bakeSpring — the Same Spring in the CSS-Keyframes and WAAPI Lanes + +`@keyframes` and `element.animate()` can't take a function ease, but both accept CSS `linear()` — a piecewise-linear easing with explicit stops. Bake the spring into one at setup; the curve is then a pure function of the animation's own time, so the `css` and `waapi` adapters seek it like any other keyframe animation (`css-animations.md`, `waapi.md`). + +```javascript +// Curvature-adaptive sampling: stops cluster where the curve bends (the overshoot lobes). +function bakeSpring(spring, { maxPts = 75 } = {}) { + const dense = 400; + const pts = [[0, 0]]; + const curv = []; + for (let i = 1; i < dense; i++) { + const y0 = spring.ease((i - 1) / dense); + const y1 = spring.ease(i / dense); + const y2 = spring.ease((i + 1) / dense); + curv.push(Math.abs(y2 - 2 * y1 + y0)); // second difference ≈ local curvature + } + const total = curv.reduce((a, b) => a + b, 0) || 1; + const budget = maxPts - 2; + let acc = 0; + for (let i = 1; i < dense; i++) { + acc += (curv[i - 1] / total) * budget; + if (acc >= 1) { + pts.push([i / dense, spring.ease(i / dense)]); + acc = 0; + } + } + pts.push([1, 1]); + const css = `linear(${pts.map(([x, y]) => `${y.toFixed(5)} ${(x * 100).toFixed(3)}%`).join(", ")})`; + return { points: pts, css }; // at most maxPts stops; the four registers land at 57–61 with the default +} + +const ms = (seconds) => Math.round(seconds * 1000); // the css / waapi lanes take milliseconds — one unit cast, kept out of the tween sites +const s = springEase(SPRING_REGISTERS.snappy); +const baked = bakeSpring(s); +// CSS lane — the duration is still the helper's: +style.textContent = `#chip { animation: chip-in ${ms(s.duration)}ms ${baked.css} 200ms 1 both; }`; +// WAAPI lane: +chip + .animate([{ transform: "translateY(-220px)" }, { transform: "translateY(0)" }], { + duration: ms(s.duration), + delay: 200, + easing: baked.css, + fill: "both", + iterations: 1, + }) + .pause(); +``` + +Parity against the analytic ease is a function of travel: the bake error is a fraction of the curve, so it grows with the distance the element moves. Computed against this file's `springEase` (script sampling of the bake at 4000 points, 2026-09-08): + +| register | points at the default cap | worst error, 1000 px travel | at 220 px | with `maxPts: 200` | +| -------------- | ------------------------- | --------------------------- | --------- | ------------------ | +| `snappy` | 61 | 2.3 px | 0.5 px | 115 pts → 0.5 px | +| `heavy-settle` | 58 | 2.5 px | 0.5 px | 110 pts → 0.6 px | +| `bouncy` | 61 | 3.0 px | 0.7 px | 123 pts → 1.0 px | +| `wobbly` | 57 | 7.3 px | 1.6 px | 107 pts → 1.9 px | + +In the render itself (one composition, the `wobbly` register at `maxPts: 200`, the GSAP analytic ease beside the CSS-keyframes and WAAPI lanes on the same travel, 1-px edge measurement on every frame of the tween): both baked lanes stay within **1 px** of the analytic chip over 220 px of travel and within **2 px** over 900 px. + +Rule of thumb: keep `|baked − analytic| × travel ≤ 2 px`. The default cap holds `snappy`, `heavy-settle` and `bouncy` to that tolerance up to roughly 650 px of travel (the two entrance registers to about 800 px); the `wobbly` register's lobes want `maxPts: 200` for the same tolerance at 1000 px. Raise `maxPts` rather than accepting a visible step. + ## Stagger ```javascript diff --git a/skills/hyperframes-animation/adapters/waapi.md b/skills/hyperframes-animation/adapters/waapi.md index fc87cdad98..bb3c0c2289 100644 --- a/skills/hyperframes-animation/adapters/waapi.md +++ b/skills/hyperframes-animation/adapters/waapi.md @@ -69,6 +69,7 @@ document.querySelectorAll(".token").forEach((token, index) => { - Lightweight DOM motion where CSS keyframes are too rigid and GSAP is unnecessary. - Generated animations from structured data. - Simple timelines that can be represented as keyframes, delays, and offsets. +- A physical spring settle as the `easing` string: bake the closed-form spring into `linear()` with `bakeSpring()` (`gsap-easing-and-stagger.md` → Spring Eases → bakeSpring); pass the helper's duration. ## Composition Duration diff --git a/skills/hyperframes-animation/rules-index.md b/skills/hyperframes-animation/rules-index.md index 8568d22791..03d65aff6e 100644 --- a/skills/hyperframes-animation/rules-index.md +++ b/skills/hyperframes-animation/rules-index.md @@ -93,6 +93,8 @@ A rule's own **Critical Constraints** section lists only what is SPECIFIC to tha Container morphs apparent size + corner radius + surface treatment between two shots, then fades to reveal the real target underneath. HyperFrames substitutes uniform `scale` for the forbidden `width`/`height` tween, plus paint-only `borderRadius`/`background`/`boxShadow`. Tags: morph, anchor, transition, border-radius, container, shape, handoff Whole-theme in-place morph under a fixed anchor — background, typography, radii, icons, chrome and logos blend simultaneously (~0.3s) through N pre-styled skins while one anchor element never moves. Stacked complete layers + opacity-only crossfade, anchor rendered once on top (or per-layer at identical geometry); static camera. Single container instead → `card-morph-anchor`. Tags: theme, skin, crossfade, morph, anchor, reskin, cycle, ui The canonical ENTRANCE pop — an element (or staggered group) arrives by springing `scale: 0 → 1` with `back.out` overshoot, `fromTo` so it's correct at t=0 under seek. Single hero, staggered group (≤500ms cap), overshoot tuned by personality. Distinct from `press-release-spring` (a click/press reaction). Tags: spring, entrance, pop, scale-in, overshoot, stagger, arrival +Velocity-continuous ring-out composed INTO any ease that arrives with speed (`none`, an `in` ease, a slam) — measure the base curve's arrival velocity numerically, extend the window by a decaying-sine tail proportional to it, so a fast arrival over-rings and a slow one barely stirs. One tween, one pure composed ease; does nothing on `out` eases by design. Tags: follow-through, overshoot, ring-out, impact, secondary-motion, composed-ease +Follow-the-leader secondary motion — every follower runs the leader's IDENTICAL tween time-shifted by `i × DELTA` (≈ 2 frames) on the master timeline, so a stack or trail reads as one body with drag; the tip settles last. The slide-in runs an entrance register; only a post-contact settle-back may use a recovery register, whose overshoot ripples down the chain. Longer-travel followers scale `response`, never the duration. Distinct from an entrance stagger. Tags: inertia, chain, drag, follow-the-leader, trail, stack, secondary-motion Fake directional velocity blur on a fast entrance / camera push-through — blur peaks at max speed, resolves to 0 at the settle. Two paths: SVG `feGaussianBlur` stdDeviation on the motion axis (proxy-tweened), or a deterministic echo/ghost trail that collapses into the lead. Entrances / mid-shot only. Tags: motion-blur, streak, velocity, ghost, echo, fast Staggered ARRIVAL cascade — words/elements whip in from below, each starting before the previous settles, an accelerating wave that resolves composed. Title cards, segment openers, list intros. Binary 0→1 opacity via `tl.set` — never fade an arrival. Tags: entrance, cascade, stagger, kinetic-text, title-card, arrival, waterfall Deterministic particle / confetti events — confetti pop that bursts up and drifts down on gravity (optional instant-shrink), dot burst from behind text, glyph dissolve to particles. Fixed pool, index-seeded launch values, one `ease: "none"` driver whose onUpdate computes each particle as a pure ballistic function of time — scrub-safe mid-flight, ≤ ~40 particles. Tags: particles, confetti, burst, dissolve, ballistic, deterministic, punctuation diff --git a/skills/hyperframes-animation/rules/follow-through.md b/skills/hyperframes-animation/rules/follow-through.md new file mode 100644 index 0000000000..fc327b7fba --- /dev/null +++ b/skills/hyperframes-animation/rules/follow-through.md @@ -0,0 +1,89 @@ +--- +name: follow-through +description: A velocity-continuous ring-out composed INTO any ease that arrives with speed — measure the base curve's arrival velocity numerically, extend the tween by a short decaying-sine tail whose amplitude is proportional to that velocity, so a fast arrival over-rings and a slow one barely stirs. One tween, one pure composed ease; seek-safe by construction. Not for `out` eases (they already arrive at rest). +metadata: + tags: follow-through, overshoot, ring-out, settle, secondary-motion, impact, arrival, composed-ease, physics, velocity +--- + +# Follow-Through + +Classical follow-through: a body that stops does not stop all at once. It arrives, overshoots by an amount that depends on how fast it was going, and rings down. This rule composes that into the ease itself — `withFollowThrough(baseEase, baseDur)` returns a **new** ease and duration: the base curve verbatim, then a decaying sine tail whose amplitude is the base curve's own arrival velocity. Nothing is hand-keyed; the same arrival at half speed rings half as much, automatically. + +Boundaries: the ring belongs to arrivals that **carry speed into the stop** — a linear slide, an accelerating `in`-ease impact, a nudge chain's burst — where the base ease has no settle of its own. Every `out` ease ([spring-pop-entrance.md](spring-pop-entrance.md), `power3.out`, `expo.out`) arrives at zero velocity by definition, so the measured tail is flat and the rule does nothing there — that _is_ the doctrine: an `out` ease already contains its settle. For a physical settle on an entrance, use `springEase` (`../adapters/gsap-easing-and-stagger.md` → Spring Eases); don't stack the two. Cursor-driven presses keep their own two-tween chain ([press-release-spring.md](press-release-spring.md)). + +## How It Works + +1. **Measure the arrival velocity** numerically from the base ease: `v = (f(1) − f(1 − ε)) / ε / baseDur` (progress per second, `ε = 1e-4`). This works for any ease function — a GSAP name via `gsap.parseEase`, a spring, a custom curve. +2. **Extend the window**: `total = baseDur + 3 / decay` — the tail lasts until the envelope has fallen to `e⁻³` (~5%). +3. **Ring**: for `τ` seconds after arrival, `1 + v · amp · sin(2π · freqHz · τ) · e^(−decay · τ)`. The tail starts with slope `v · amp · 2π · freqHz`; with `amp = 1 / (2π · freqHz)` that slope equals `v` — velocity-continuous (C¹) at the arrival frame, which is what makes it read as the same body continuing, not a second motion bolted on. Larger `amp` over-rings on purpose; smaller under-rings. + +The composed function is a pure function of progress, so it is seek-safe like any other ease. + +## Recipe + +```js +// Compose a ring-out into any ease that arrives with velocity. +function withFollowThrough(baseEase, baseDur, { amp, freqHz = 3, decay = 5 } = {}) { + const A = amp ?? 1 / (2 * Math.PI * freqHz); // C¹ default: the tail starts at the arrival velocity + const eps = 1e-4; + const v = (baseEase(1) - baseEase(1 - eps)) / eps / baseDur; // arrival velocity, progress/s + const tail = 3 / decay; + const total = baseDur + tail; + const ease = (p) => { + const t = p * total; + if (t <= baseDur) return baseEase(t / baseDur); + const tau = t - baseDur; + return 1 + v * A * Math.sin(2 * Math.PI * freqHz * tau) * Math.exp(-decay * tau); + }; + return { ease, duration: total, arrivalVelocity: v }; +} + +// An accelerating impact: the word slams in and rings. GSAP's `power1.in` is quadratic — it arrives at 2 × travel / IMPACT_DUR. +const slam = withFollowThrough(gsap.parseEase("power1.in"), IMPACT_DUR); +tl.fromTo( + "#word", + { x: ENTER_FROM_X }, + { x: 0, duration: slam.duration, ease: slam.ease }, // BOTH from the helper — the tail is part of the ease + IMPACT_AT, +); +// Opacity rides its own tween on the base window only (the composed ease passes 1). +tl.fromTo( + "#word", + { opacity: 0 }, + { opacity: 1, duration: IMPACT_DUR, ease: "power2.out" }, + IMPACT_AT, +); +``` + +Ring size is predictable: peak overshoot ≈ `0.68 · v · A` of the travel at the defaults (the first lobe of `sin · e^(−decay τ)`). GSAP's `in` family arrives at `n + 1` times the average speed (`power1.in` is quadratic, `power2.in` cubic, and so on): `power1.in` over 0.5 s gives `v = 4/s` and, with the C¹ default, a ~14% ring; the same arrival over 1.0 s rings ~7%; `power2.in` over 0.5 s (`v = 6/s`) rings ~22% — past the cap below. The reference build (2026-09-08, 24 s reel) runs the `power1.in` pair — full speed, then half speed — beside a bare `power1.in` that stops dead. + +## Variations + +- **Linear slide with a landing** — `ease: "none"` base (`v = 1 / baseDur`: a ~7% ring at 0.5 s, ~3.5% at 1.0 s): a mechanical move that stops with a small physical shudder. Pair with `decay: 8` (~6% at 0.5 s) so it reads as mass, not bounce. +- **Rotation follow-through** — the same composed ease on `rotation` for a sign or a card that swings in: `freqHz: 2`, `decay: 3` (a hanging object rings slower and longer). +- **Tight impact** — `decay: 8` (tail 0.375 s) for a hard object; **loose** — `decay: 2` (tail 1.5 s) for something soft or heavy on a string. +- **Chain** — the same composed ease on every member of a stack with [inertia-chain.md](inertia-chain.md) offsets: the ring ripples down the chain and the tip settles last. +- **Deliberate over-ring** — `amp: 1.5 / (2π · freqHz)` when the beat wants a visible exaggeration (a slapstick register); never the default. + +## Values + +| token | range | notes | +| --------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| baseEase | `none`, `power1.in`, `power2.in`, a truncated arc | must arrive with velocity (`v = 1/baseDur` linear, `2/baseDur` quadratic, `3/baseDur` cubic) — an `out` / `inOut` base measures `v ≈ 0` and rings nothing | +| baseDur | 0.3–1.0s | `v` scales as `1 / baseDur`: halving the duration doubles the ring | +| amp | `1 / (2π·freqHz)` (default, C¹) … 1.5× that | overshoot ≈ `0.68 · v · amp` of travel; keep the peak ≲ 15% of travel | +| freqHz | 2–4 | 3 reads as a firm object; 2 as a hanging one | +| decay | 2 (loose) – 8 (tight), default 5 | tail = `3 / decay` s — schedule the next beat off `total`, not `baseDur` | +| IMPACT_AT | on a cause | the ring is the reaction of a stop; an un-caused stop with a ring reads as a glitch | + +## Critical Constraints + +- **Base ease arrives with velocity** — `none`, an `in` ease, or a custom curve with non-zero end slope. Check `arrivalVelocity` once at setup; if it is ~0 the rule is the wrong tool (use `springEase` or leave the `out` ease alone). +- **Transforms only** — the composed ease passes 1; on `opacity` it would push past 1 (split opacity onto its own tween on the base window, as in the recipe). Never on color. +- **One tween** — the ring lives inside the ease. Never a second tween, an `onComplete`, or a hand-keyed overshoot after the base tween. +- **Take both `ease` and `duration` from the helper** — the tail extends the window; scheduling the following beat off `baseDur` starts it mid-ring. +- Peak ring ≲ 15% of travel; past that the stop reads as bounce, which is a rare, explicitly-playful register (`../adapters/gsap-easing-and-stagger.md` → Easing Vocabulary). + +## See also + +`inertia-chain` (the same composed ease rippling down a stack) · `spring-pop-entrance` / `springEase` (physical settle for an entrance — the `out`-ease world this rule does not touch) · `nudge-curve` (a burst-dominant slide whose tail could carry a ring) · `kinetic-beat-slam` (an impact beat that can end in a ring). diff --git a/skills/hyperframes-animation/rules/inertia-chain.md b/skills/hyperframes-animation/rules/inertia-chain.md new file mode 100644 index 0000000000..1b4cbc7e88 --- /dev/null +++ b/skills/hyperframes-animation/rules/inertia-chain.md @@ -0,0 +1,94 @@ +--- +name: inertia-chain +description: Follow-the-leader secondary motion — every follower runs the leader's IDENTICAL tween, time-shifted by i × DELTA (≈ 2 frames) on the master timeline, so a stack or trail reads as one body with drag: the overshoot ripples down the chain and the tip settles last. Distinct from an entrance stagger, which offsets the start times of separate arrivals. +metadata: + tags: inertia, chain, drag, follow-the-leader, secondary-motion, trail, stack, overlap, spring, offset, whip +--- + +# Inertia Chain + +A title stack, a dotted trail, a ribbon of chips: when the leader moves, each follower replays the **same curve** a beat later. The group reads as one body with drag — the leader's overshoot travels down the chain and the last member settles last. The mechanism is time, not a second animation: every follower gets the leader's exact tween (same from/to, same ease, same duration) placed at `leaderAt + i × DELTA` on the master timeline. + +Boundaries: an entrance **stagger** ([spring-pop-entrance.md](spring-pop-entrance.md), [waterfall-entry.md](waterfall-entry.md)) offsets the start of _separate_ arrivals so a group lands as one beat; the chain offsets one _continuing_ motion so a group moves as one object. A stagger ends when everyone has arrived; a chain persists through every subsequent move of the leader. + +## How It Works + +1. **One curve, many clocks.** Build the leader's tween once and give each follower `i` the identical tween at `leaderAt + i × DELTA`. The arrival move uses an entrance register (`snappy`, `heavy-settle` — `../adapters/gsap-easing-and-stagger.md` → Named Registers): the phase lag alone is the drag. A later settle-back or recovery move — the group returning after contact — may use a recovery register (`bouncy`); its overshoot is what makes the ripple down the chain visible. +2. **DELTA is frames, not a fraction of the tween** — ~2 frames at the composition fps for a tight body, 3–5 for a loose tail. The chain's "drag" is the visible phase lag between neighbours. +3. **Every later move of the leader gets the same treatment** — the chain is a property of the group, so a second move (a settle-back, a nudge) is placed with the same per-member offsets. +4. **A follower that must travel further** (trail dots crossing more distance than the leader) runs the same register with a larger `response` — `springEase({ ...register, response: register.response × k })` — the same normalized curve over a longer physics-derived settle. Never a stretched duration (`../adapters/gsap-easing-and-stagger.md` → Duration Is an Output). + +## Recipe + +```html +
+
{lineA}
+
{lineB}
+
{lineC}
+
{lineD}
+
+``` + +```js +// Two curves, durations from the helper: an entrance register for the arrival, a recovery register for the settle-back. +const arrive = springEase(SPRING_REGISTERS.snappy); // the arrival move — the phase lag alone is the drag +const recover = springEase(SPRING_REGISTERS.bouncy); // the settle-back after contact — its overshoot ripples down the chain +const DELTA = 2 / FPS; // 2 frames + +// Move 1: the stack slides in. Same tween per row, time-shifted. +gsap.utils.toArray(".chain-row").forEach((row, i) => { + tl.fromTo( + row, + { x: FROM_X }, + { x: TO_X, duration: arrive.duration, ease: arrive.ease }, + MOVE_AT + i * DELTA, + ); +}); + +// Move 2: the leader settles back after the contact; the chain drags the same way and the overshoot travels down it. +gsap.utils.toArray(".chain-row").forEach((row, i) => { + tl.to(row, { x: REST_X, duration: recover.duration, ease: recover.ease }, MOVE2_AT + i * DELTA); +}); + +// Trail dots crossing more distance: the same register with a longer response, not a stretched duration. +const trail = springEase({ + ...SPRING_REGISTERS["heavy-settle"], + response: SPRING_REGISTERS["heavy-settle"].response * 1.4, +}); +gsap.utils.toArray(".trail-dot").forEach((dot, i) => { + tl.fromTo( + dot, + { x: TRAIL_FROM_X }, + { x: TRAIL_TO_X, duration: trail.duration, ease: trail.ease }, + TRAIL_AT + i * DELTA, + ); +}); +``` + +## Variations + +- **Path leader** — when the leader follows a function of time rather than a tween (a driven cursor, a tracked point), followers evaluate `leaderFn(t − i × DELTA)` inside one timeline-driven `onUpdate` (an `ease: "none"` proxy tween): the same pure lookup, time-shifted, no state. +- **Whip** — larger `DELTA` (4–5 frames) on the `snappy` arrival: the tail cracks after the head. +- **Rotation chain** — the same time-shifted tween on `rotation` for a hanging sign or a ribbon of cards; combine with [follow-through.md](follow-through.md) when the leader's own ease arrives with velocity. +- **Two-axis** — x and y on the same offsets; keep both from one tween so the phase lag stays identical per axis. + +## Values + +| token | range | notes | +| ------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| DELTA | 2 frames (tight) – 5 (loose) | in seconds: `frames / FPS`; more than ~6 frames stops reading as one body | +| N (members) | 3–12 | a 12-dot trail and a 5-line stack both read; keep `N × DELTA ≤ ~0.8 s` | +| register | arrival: `snappy` / `heavy-settle`; settle-back: `bouncy` | recovery registers only on the post-contact move — never on the slide-in (`../adapters/gsap-easing-and-stagger.md` → Recovery Registers) | +| response × k | 1.2–1.6 for longer-travel followers | scale `response`, never the duration | + +## Critical Constraints + +- **Identical tween per member** — same from/to, ease and duration; only the timeline position differs. A per-member ease or duration breaks the one-body read. +- **Offsets are frames at the composition fps** — never a fraction of the tween duration (a longer tween would loosen the chain). +- **Every leader move is chained** — a second move without the offsets snaps the group back into lockstep and reads as a cut. +- **`fromTo` with explicit from-states** for the first move (t=0 correct under seek); later moves are absolute `to` values, never relative `+=`. +- Head-to-tail lag `N × DELTA ≤ ~0.8 s` (a 12-member trail at 2 frames, 30 fps) — past that the tail is a separate arrival, not drag. This lag is drag on a continuing move, not the entrance-stagger cap: the chain's own slide-in still lands inside one beat (`items × stagger ≤ ~0.5 s`, rules-index contract). + +## See also + +`follow-through` (a ring composed into the leader's ease, rippled by the chain) · `spring-pop-entrance` / `waterfall-entry` (arrival staggers — not chains) · `cursor-drag` (a ghost riding a cursor in exact lockstep is the zero-DELTA case) · `nudge-curve` (a group slide that can be chained). diff --git a/skills/hyperframes-animation/rules/spring-pop-entrance.md b/skills/hyperframes-animation/rules/spring-pop-entrance.md index 2debc1eee0..2346837858 100644 --- a/skills/hyperframes-animation/rules/spring-pop-entrance.md +++ b/skills/hyperframes-animation/rules/spring-pop-entrance.md @@ -66,7 +66,7 @@ gsap.utils.toArray(".pop-item").forEach((el, i) => { - **Calm settle** (premium / enterprise): `power3.out`, no rotation, `Y_RISE` 0–12px — a weighted, confident landing for a hero wordmark or product shot. - **Firm settle** (everyday default): `power3.out` or `expo.out` for a punchier front, `Y_RISE` ~24px — cards, icons, callouts. -- **Exact-physics settle**: when the settle IS the shot, swap the ease for `springEase({ response: 0.4 })` (critically damped) from `../adapters/gsap-easing-and-stagger.md` → Spring Eases; take `duration` from the helper. +- **Exact-physics settle**: when the settle IS the shot, swap the ease for `springEase({ response: 0.4 })` (critically damped) from `../adapters/gsap-easing-and-stagger.md` → Spring Eases; take `duration` from the helper — or a named entrance register (`snappy`, `heavy-settle`) from the same section's Named Registers. - **Origin-anchored pop**: a callout growing out of a specific point (marker, pointer tip) sets `transform-origin` to that point (e.g. `0% 100%`) so `scale: 0 → 1` reads as "emerging from the source", not "inflating in place". - **Pop into a held slot**: land the pop and hold still — no idle loop baked into the entrance. If the held frame genuinely needs life, hand off to [sine-wave-loop.md](sine-wave-loop.md) for subtle jitter on a separate later tween; prefer revealing the next element on its VO cue. - **Bouncy pop (RARE — explicitly-playful only)**: swap the ease for `back.out(OVERSHOOT)` and optionally settle a small `rotation: ROT_FROM → 0` so elements look hand-placed. Only for a deliberately playful register — never product / enterprise / serious tone: