@mehshekari/gsap-motion 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
28
28
  build without warning:
29
29
 
30
30
  ```json
31
- "lint": "eslint && npx @mehshekari/gsap-motion@3.2.0 audit src --quiet"
31
+ "lint": "eslint && npx @mehshekari/gsap-motion@3.3.0 audit src --quiet"
32
32
  ```
33
33
 
34
34
  Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/gsap-motion",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Install the gsap-creative-animation Agent Skill, and audit GSAP animation for leaks, jank and accessibility failures — with npx.",
5
5
  "keywords": [
6
6
  "gsap",
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
4
4
  license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
5
5
  compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 18+ and nothing else.
6
6
  metadata:
7
- version: "3.2.0"
7
+ version: "3.3.0"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -18,8 +18,8 @@ Core principles:
18
18
 
19
19
  - **Motion carries meaning or it goes.** Every animation answers "what does
20
20
  this tell the visitor?" Hierarchy, causality, continuity, state, or
21
- personality. An animation that answers none of those is decoration with a
22
- frame cost, and the right move is to delete it.
21
+ personality. One that answers none of those is decoration with a frame cost:
22
+ delete it.
23
23
  - **A timeline, not a pile of tweens.** Five independent tweens with hand-tuned
24
24
  delays is a choreography nobody can change. One timeline with labels and
25
25
  relative positions is the same animation you can actually retime.
@@ -33,16 +33,16 @@ Core principles:
33
33
 
34
34
  ## Setup
35
35
 
36
- 1. **Everything is free.** GSAP became 100% free, every former Club plugin
37
- included, with 3.13 (April 2025). Check `package.json` for what the project
38
- has — `gsap`, and `@gsap/react` for React — before writing imports. Install
39
- steps for a membership, a token, an `.npmrc` or `gsap-bonus.tgz` come from
40
- stale training data: stop.
36
+ 1. **Everything is free.** GSAP became 100% free with 3.13 (April 2025), every
37
+ former Club plugin included. Check `package.json` for what the project has —
38
+ `gsap`, and `@gsap/react` for React — before writing imports. Install steps
39
+ for a membership, a token, an `.npmrc` or `gsap-bonus.tgz` are stale
40
+ training data: stop.
41
41
  2. **Read the project's own animation rules first. They outrank this skill.**
42
- Look for `ANIMATION.md` at the repository root, then for a file that
43
- `AGENTS.md`, `CLAUDE.md` or similar points to. Open
42
+ Look for `ANIMATION.md` at the root, then a file `AGENTS.md`, `CLAUDE.md` or
43
+ similar points to. Open
44
44
  [reference/project-rules.md](reference/project-rules.md) only when there is
45
- no such file, or for its RTL, colour and waiver rules.
45
+ none, or for its RTL, colour and waiver rules.
46
46
  3. **Load by the size of the request, and nothing "for completeness".**
47
47
  - **One element, one tween or interaction** — a hover, a fade, a retime:
48
48
  [motion-design.md](reference/motion-design.md) and the command's own
@@ -80,12 +80,11 @@ Core principles:
80
80
  Routing:
81
81
 
82
82
  - **No argument, or no target:** read [routing.md](reference/routing.md) and
83
- ask what should move. A whole page or site with nothing in it named — "add
84
- some animation to my homepage" — has no target. Never invent one.
85
- - **An explicit or clearly implied command:** load its reference and follow it.
86
- - **A description rather than a command** ("make this feel like Apple", "the
87
- hero is boring"): that is `animate`. Classify the intent with routing.md's
88
- table, then load what that classification needs.
83
+ ask what should move. "Add some animation to my homepage" names no target:
84
+ never invent one.
85
+ - **A command, explicit or implied:** load its reference and follow it.
86
+ - **A description** ("make this feel like Apple", "the hero is boring"): that is
87
+ `animate`. Classify with routing.md's table, then load what that needs.
89
88
  - **Two commands fit:** ask once. A scroll-driven text reveal is `scroll` and
90
89
  `text` together, not a coin toss.
91
90
 
@@ -113,7 +112,8 @@ intent → precedent → targets → trigger → motion language → technique
113
112
  8. **Implementation** — typed, cleaned up, responsive.
114
113
  9. **Performance** — transform and opacity; measure before defending anything else.
115
114
  10. **Accessibility** — the reduced-motion design, focus, keyboard.
116
- 11. **Validate** — the checklist at the end of [motion-design.md](reference/motion-design.md).
115
+ 11. **Validate** — [refine.md](reference/refine.md): audit, fix in order,
116
+ re-run, then the checklist in [motion-design.md](reference/motion-design.md).
117
117
 
118
118
  ## Output format
119
119
 
@@ -126,16 +126,19 @@ For anything beyond a one-line tweak, answer in this shape, each part short:
126
126
  - **Implementation** — complete code. Not pseudocode, not an outline.
127
127
  - **Usage** — how to mount it.
128
128
  - **Notes** — responsive behaviour, reduced motion, cost, dev-only tooling.
129
+ - **Verification** — one line per check with its evidence, and what you did not
130
+ check. Silence reads as "checked and fine".
129
131
 
130
- When a simpler technique does what the user asked for, **write only the
131
- simpler one**. Say in Analysis which technique you declined and why, and offer
132
- the requested version in one sentence; write it only if the user still wants it
133
- after that. Never quietly substitute, and never ship both.
132
+ When a simpler technique does what was asked, **write only that one**. Say in
133
+ Analysis what you declined and why, and offer the requested version in a
134
+ sentence — write it only if they still want it. Never substitute quietly, and
135
+ never ship both.
134
136
 
135
137
  ## Reference map
136
138
 
137
139
  - **Craft and core** — [motion-design](reference/motion-design.md) ·
138
- [core-gsap](reference/core-gsap.md) · [timeline](reference/timeline.md)
140
+ [core-gsap](reference/core-gsap.md) · [timeline](reference/timeline.md) ·
141
+ [devices](reference/devices.md) · [refine](reference/refine.md)
139
142
  - **Domains** — [scrolltrigger](reference/scrolltrigger.md) ·
140
143
  [svg](reference/svg.md) · [text](reference/text.md) ·
141
144
  [interaction](reference/interaction.md) · [flip](reference/flip.md)
@@ -145,7 +148,7 @@ after that. Never quietly substitute, and never ship both.
145
148
  - **Constraints** — [performance](reference/performance.md) ·
146
149
  [accessibility](reference/accessibility.md) ·
147
150
  [project-rules](reference/project-rules.md)
148
- - **Presets**, starting points to adapt — [reveal](preset/reveal.md) ·
151
+ - **Presets** to adapt — [reveal](preset/reveal.md) ·
149
152
  [magnetic](preset/magnetic.md) · [cursor](preset/cursor.md) ·
150
153
  [marquee](preset/marquee.md) · [card-stack](preset/card-stack.md) ·
151
154
  [loader](preset/loader.md) · [cinematic](preset/cinematic.md) ·
@@ -160,13 +163,12 @@ after that. Never quietly substitute, and never ship both.
160
163
 
161
164
  ## Tools
162
165
 
163
- Both scripts catch failures that are **silent**. Run them from the project
164
- root; `<skill-dir>` is where this skill is installed.
166
+ Both scripts catch **silent** failures. Run them from the project root;
167
+ `<skill-dir>` is where this skill is installed.
165
168
 
166
169
  ```bash
167
- # Leaks, per-event tweens, layout properties, eased loops, an onComplete that
168
- # never fires, shared plugin ids, unregistered plugins, dev tooling left in.
169
- # Exit 1 on an error.
170
+ # Leaks, per-event and per-frame tweens, layout and paint properties, loops,
171
+ # shared plugin ids, unregistered plugins, dev tooling. Exit 1 on an error.
170
172
  node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
171
173
  node <skill-dir>/scripts/audit-gsap.mjs src --quiet # errors only
172
174
  node <skill-dir>/scripts/audit-gsap.mjs src --json # machine-readable
@@ -177,10 +179,11 @@ node <skill-dir>/scripts/audit-svg.mjs --morph <a.svg> <b.svg>
177
179
  node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg> # also flag hue literals
178
180
  ```
179
181
 
180
- Run `audit-gsap` first in an `audit` and after writing any animation. A false
181
- finding is a bug in the script, not something to work around.
182
+ Run `audit-gsap` first in an `audit` and after writing any animation — as a
183
+ Claude Code plugin, a hook already runs it on every edit and hands back what it
184
+ finds. A false finding is a bug in the script, not something to work around.
182
185
 
183
186
  `MotionPathHelper`, `GSDevTools`, `markers: true` and
184
- `MorphSVGPlugin.findShapeIndex()` are development-only. A static import ships
185
- whatever `if` surrounds it: import them dynamically behind a `NODE_ENV` check,
186
- and delete the block once their value is baked in.
187
+ `MorphSVGPlugin.findShapeIndex()` are development-only, and a static import
188
+ ships whatever `if` surrounds it. Import them dynamically behind a `NODE_ENV`
189
+ check, and delete the block once their value is baked in.
@@ -0,0 +1,110 @@
1
+ # Devices
2
+
3
+ A width breakpoint is not a device. A 1024px window can be a phone in landscape,
4
+ a tablet with a keyboard, or a desktop browser scaled to half the screen, and
5
+ "mobile" has stood in for all three for so long that most animation ships one
6
+ guess about which it is.
7
+
8
+ Read three axes instead. They are independent, and each answers a different
9
+ question.
10
+
11
+ | Axis | Question | Read from |
12
+ |---|---|---|
13
+ | Layout | How much room is there? | the viewport, in `matchMedia` |
14
+ | Input | Can the visitor hover, and how precise are they? | `(hover: hover)`, `(pointer: fine)` |
15
+ | Motion tier | How much motion should run, and can this device run it? | `prefers-reduced-motion`, then capability |
16
+
17
+ ```ts
18
+ const mm = gsap.matchMedia();
19
+
20
+ mm.add(
21
+ {
22
+ wide: "(min-width: 48em)",
23
+ hoverable: "(hover: hover) and (pointer: fine)",
24
+ motion: "(prefers-reduced-motion: no-preference)",
25
+ },
26
+ (context) => {
27
+ const { wide, hoverable, motion } = context.conditions;
28
+ // …one branch, three facts
29
+ },
30
+ );
31
+ ```
32
+
33
+ `matchMedia` re-runs the body when any condition changes and reverts what the
34
+ previous run created, so a visitor who rotates a phone, plugs in a mouse or
35
+ turns on reduced motion gets the right version without a reload.
36
+
37
+ ## The device changes the technique, never the story
38
+
39
+ A scene says something — this section arrived, that item is now first, the page
40
+ is loading. That does not change with the screen. What changes is how much
41
+ motion is spent saying it.
42
+
43
+ So each beat has three versions, and the beat decides which parts of itself are
44
+ device-sensitive:
45
+
46
+ - **Full** — the designed version.
47
+ - **Light** — the same meaning, less work: a shorter travel, no blur, fewer
48
+ elements moving, a stagger that runs as one group.
49
+ - **Static** — the end state, set. Still says *what* happened, with no travel.
50
+
51
+ Only branch the techniques that actually depend on the device. A fade is a fade
52
+ everywhere; a magnetic cursor is not. Writing three whole timelines where one
53
+ beat differs is how a page ends up with two motion languages and one of them
54
+ untested.
55
+
56
+ ## Start safe, then enhance
57
+
58
+ Begin from the version that works everywhere — the light one — and add to it
59
+ when an axis says there is room. The opposite order ships the expensive version
60
+ to the device least able to run it and then tries to claw it back.
61
+
62
+ If you measure capability at run time, **downgrade once**. A scene that keeps
63
+ sampling frame rate and flipping between versions spends its budget on the
64
+ flipping, and the visitor sees the page change its mind. Decide early, once, and
65
+ stay there.
66
+
67
+ Device memory and core count (`navigator.deviceMemory`, `hardwareConcurrency`)
68
+ are hints, not facts: they are absent on some browsers and rounded on others.
69
+ Use them to pick a tier, never to decide whether a thing works.
70
+
71
+ ## Where to read each axis
72
+
73
+ `prefers-reduced-motion`, `hover` and `pointer` are media queries, so
74
+ `gsap.matchMedia` is the one place to read them — inside the animation's own
75
+ context, where its cleanup already is.
76
+
77
+ The trap is reading them during render. A server has no viewport and no pointer,
78
+ so anything that branches on them before mount is a hydration mismatch waiting
79
+ to happen. Each adapter says where its own safe point is:
80
+
81
+ - **React and Next** — in the `useGSAP` body, never in render. See
82
+ [react.md](../adapter/react.md).
83
+ - **Vue and Nuxt** — in `onMounted`. See [vue.md](../adapter/vue.md).
84
+ - **Svelte** — in `$effect` or `onMount`. See [svelte.md](../adapter/svelte.md).
85
+ - **Astro** — in the component script, which is already browser-only. See
86
+ [astro.md](../adapter/astro.md).
87
+ - **Vanilla** — wherever you mount. See [vanilla.md](../adapter/vanilla.md).
88
+
89
+ ## What each axis is allowed to change
90
+
91
+ | Axis | Changes | Does not change |
92
+ |---|---|---|
93
+ | Layout | travel distance, stagger shape, which elements move, pinning | what the scene says |
94
+ | Input | hover effects, cursors, magnetic pulls, drag affordances | entrance and scroll behaviour |
95
+ | Motion tier | travel, loops, parallax, blur and shadow work | the end state the visitor is left in |
96
+
97
+ A hover effect with no input branch is the common failure: a tap on a touch
98
+ device fires `mouseenter`, nothing fires the leave, and the effect sticks. The
99
+ audit reports that as `ungated-hover`.
100
+
101
+ ## Reduced motion is a tier, not an off switch
102
+
103
+ The static version still communicates. It sets the end state, keeps the
104
+ hierarchy the motion was carrying — by order, weight or a very short fade — and
105
+ drops travel, loops and parallax. An empty branch is a page that looks broken to
106
+ the people who asked for less motion.
107
+
108
+ See [accessibility.md](accessibility.md) for what "reduced" means in practice,
109
+ and [performance.md](performance.md) for measuring a tier rather than guessing
110
+ it.
@@ -86,8 +86,8 @@ radiating, start-to-end reads as reading order. In RTL, "reading order" is
86
86
 
87
87
  ## Overlap
88
88
 
89
- Sequential beats look like a slideshow. Real choreography overlaps, and the
90
- overlap is where an animation stops feeling like a list of steps.
89
+ Sequential beats look like a slideshow. Overlap is where an animation stops
90
+ feeling like a list of steps.
91
91
 
92
92
  ```ts
93
93
  tl.to(a, { ... })
@@ -105,15 +105,18 @@ finishing.
105
105
  - **Weight** — a large or heavy-looking element should move slower and ease
106
106
  harder than a small one. Same duration on a hero image and a caption reads as
107
107
  a bug.
108
- - **Anticipation** — a small counter-move before the main one. Costs 80–120ms
109
- and makes a gesture feel intentional. Use on one element, not a group.
108
+ - **Anticipation** — a small counter-move before the main one: 80–120ms, on one
109
+ element, not a group.
110
110
  - **Follow-through** — elements should not all stop at once. A 0.05s offset on
111
- the last beat is usually the difference between mechanical and alive.
111
+ the last beat is the difference between mechanical and alive.
112
112
  - **Contrast** — fast against slow is what creates emphasis. If everything moves
113
113
  at 400ms, nothing is emphasised.
114
114
  - **Restraint** — the most premium-feeling motion is usually one or two things
115
115
  moving well, not six things moving at all.
116
116
 
117
+ Technique is chosen on a ladder — transition, tween, timeline, plugin, scroll
118
+ takeover — in [routing.md](routing.md).
119
+
117
120
  ## Reading a request
118
121
 
119
122
  | They say | They usually mean |
@@ -17,6 +17,18 @@ Before writing code, look for the project's animation rules, in this order:
17
17
  Read the whole file. Where it disagrees with this skill, the project wins. If a
18
18
  project rule looks wrong, say so — do not quietly follow the skill instead.
19
19
 
20
+ ## A motion budget
21
+
22
+ A page gets busy one reasonable addition at a time. A budget makes the cost
23
+ visible before the tenth one lands: how many groups may move at once, how many
24
+ loops may be visible, how many scroll-bound and pointer effects a viewport
25
+ carries, and which plugins a route may load.
26
+
27
+ Going over is allowed — with a reason stated in the answer, not by accident.
28
+ Before adding to a route, inventory what it already animates: that is step 2 of
29
+ the pipeline, and it is also how you find the animation this one should replace
30
+ rather than join.
31
+
20
32
  ## Deriving them when there is no file
21
33
 
22
34
  A few minutes here is cheaper than a page with two motion languages.
@@ -62,6 +74,10 @@ one is short, and each section is an instruction rather than an essay:
62
74
  | Stagger | `amount` (a total) or `each` (per item) |
63
75
  | Reduced motion | what the reduced branch shows |
64
76
 
77
+ ## Motion budget
78
+ <!-- Per viewport: groups moving at once, visible loops, scroll-bound effects,
79
+ pointer effects. Plugins per route. Going over is allowed, with a reason. -->
80
+
65
81
  ## Verify with
66
82
  <!-- The commands to run before calling it done. -->
67
83
 
@@ -0,0 +1,84 @@
1
+ # Refining
2
+
3
+ Writing the animation is the first draft. This is what turns it into something
4
+ worth shipping, and it is mostly checking rather than writing.
5
+
6
+ ```text
7
+ generate → audit → fix → re-run every check → tune → final
8
+ ```
9
+
10
+ The loop matters because the failures this skill exists to catch are silent: the
11
+ code compiles, the page looks right in a demo, and the leak shows up three
12
+ navigations later. Nothing downstream will tell you. So the check is part of
13
+ writing, not something done afterwards if there is time.
14
+
15
+ ## Audit first, and mean it
16
+
17
+ Run `audit-gsap` on the file you just wrote, before reading it yourself. It
18
+ costs a second, it is deterministic, and it finds the mechanical failures so
19
+ your attention goes to the ones it cannot see.
20
+
21
+ With the plugin installed, a hook does this automatically after every edit and
22
+ hands the findings back — see the Tools section of SKILL.md. Without it, run the
23
+ script.
24
+
25
+ A finding you believe is wrong is a bug in the script. Report it; do not work
26
+ around it with a waiver.
27
+
28
+ ## Fix in this order
29
+
30
+ The order is not taste. A fix further down the list can be undone by a fix
31
+ further up it, so doing them in this order avoids fixing the same thing twice.
32
+
33
+ 1. **Lifecycle and correctness** — does it clean up, does it run once, does it
34
+ target what you think it targets.
35
+ 2. **Accessibility** — the reduced-motion design, focus order, anything the
36
+ motion hides from a keyboard.
37
+ 3. **Performance** — layout and paint properties, per-frame allocation, work
38
+ that scales with the number of items.
39
+ 4. **Composition** — beats, hierarchy, what happens at the same time.
40
+ 5. **Rhythm** — durations, eases, overlap. Last, because everything above
41
+ changes it.
42
+
43
+ ## Verify a claim before you fix
44
+
45
+ When a fix rests on a claim about how something behaves — "`clearProps` runs
46
+ after the plugin's last write", "this element is the one that moves under
47
+ `fill`" — check it before writing the fix. A minimal reproduction, one variable
48
+ at a time, and read what actually happened in the DOM.
49
+
50
+ A fix built on a guess usually works by accident, and the comment above it is
51
+ then wrong for everyone who reads it next.
52
+
53
+ ## When to stop
54
+
55
+ Stop when every check is clean, or after **three rounds**, reporting what is
56
+ left.
57
+
58
+ Three is not a budget to spend. Most changes are right after one. Stopping on a
59
+ number matters for the case where a fix keeps moving the problem: the third
60
+ round is where you say what is still wrong and why, rather than trying a fourth
61
+ shape of the same idea.
62
+
63
+ **Leave what works alone.** The loop is for the animation you were asked about.
64
+ Retiming a neighbouring section because you noticed it is churn: it costs review
65
+ attention, it risks a regression nobody asked for, and it is exactly what the
66
+ `outcome-no-churn` eval measures.
67
+
68
+ **Depth scales with the request.** A hover retime does not need a composition
69
+ pass. A site intro does.
70
+
71
+ ## Report what you checked
72
+
73
+ The answer's **Verification** section is one line per check, with its evidence:
74
+
75
+ ```text
76
+ Verification
77
+ - audit-gsap: clean (3 warns waived: layout-property on the grid, with reasons)
78
+ - reduced motion: emulated, the static branch sets the end state
79
+ - StrictMode: mounted twice in dev, no doubled tweens
80
+ - not checked: frame cost on a real phone — no device to hand
81
+ ```
82
+
83
+ Say what you did not check, too. "Not checked" is information; silence reads as
84
+ "checked and fine", and that is the one thing it must never mean.
@@ -47,6 +47,21 @@ the thing you can say in a sentence — "the logo writes itself, then the dot th
47
47
  drew it runs off and writes the word" — and every technique either serves that
48
48
  sentence or is cut.
49
49
 
50
+ ## The technique ladder
51
+
52
+ Start at the bottom, and each rung up says why the one below is not enough:
53
+
54
+ 1. **A CSS transition** — one property, one state change, no sequencing.
55
+ 2. **One transform tween** — it needs an ease or a duration CSS cannot say.
56
+ 3. **A timeline** — two or more things must be ordered or overlapped.
57
+ 4. **A plugin** — the shape needs it: draw, morph, path, split. Its weight ships
58
+ to every visitor.
59
+ 5. **A scroll takeover** — the scroll position *is* the story. It takes the
60
+ visitor's control of the page, so it needs the strongest reason of all.
61
+
62
+ Say the rung in Analysis: "a transition would not sequence these, so this is a
63
+ timeline". A plugin named without that sentence is one nobody justified.
64
+
50
65
  ## Load map
51
66
 
52
67
  The project's own rules come first, then the size tiers in SKILL.md, setup step
@@ -69,6 +84,8 @@ single hover takes [interaction](interaction.md) and nothing else from its row.
69
84
  | Three.js / R3F | [three](../adapter/three.md) or [r3f](../adapter/r3f.md), [performance](performance.md) |
70
85
  | Audit an existing animation | [performance](performance.md), [accessibility](accessibility.md), this project's adapter |
71
86
  | Retime / re-ease | [motion-design](motion-design.md) only |
87
+ | Hover, touch or reduced-motion branching | [devices](devices.md) |
88
+ | Fixing what an audit found | [refine](refine.md) |
72
89
 
73
90
  Do not load a domain file "for completeness". Loading `adapter/three.md` for a
74
91
  button hover is the failure this map exists to prevent.
@@ -4,6 +4,46 @@ A timeline is how choreography stays editable. Five tweens with hand-tuned
4
4
  delays encode the same animation in a form nobody can retime without redoing
5
5
  the arithmetic.
6
6
 
7
+ ## Scene and beats
8
+
9
+ A scene is a set of named beats, and each beat is a label in the code. Writing
10
+ the beats down before the tweens is what stops a timeline becoming a pile of
11
+ numbers nobody can change.
12
+
13
+ Most scenes are four beats or fewer:
14
+
15
+ | Beat | Says |
16
+ |---|---|
17
+ | Entrance | this arrived |
18
+ | Emphasis | this is the one that matters — **at most one per scene** |
19
+ | Pause | read this before the next thing moves |
20
+ | Exit | this is finished, or making room |
21
+
22
+ ```ts
23
+ const tl = gsap.timeline();
24
+
25
+ tl.addLabel("enter")
26
+ .from("[data-heading]", { yPercent: 110, duration: 0.8 })
27
+ .from("[data-lead]", { autoAlpha: 0, y: 18, duration: 0.6 }, "-=0.5")
28
+ .addLabel("emphasis")
29
+ .add(mark()) // a beat of its own, built elsewhere
30
+ .addLabel("rest");
31
+ ```
32
+
33
+ Two rules follow from it:
34
+
35
+ - **A beat that is more than one tween is a function returning its own
36
+ timeline**, added with `.add()`. The scene then reads as its beats, and the
37
+ beat can be retimed, reordered or dropped without touching the rest.
38
+ - **An ambient loop is handed off after the scene, never added inside it.** An
39
+ infinite child makes the parent infinite, so the scene's own `onComplete`
40
+ never runs and anything waiting on it waits forever — the audit reports that
41
+ as `never-completes`. Start the loop from a `.call()` at the end, or from the
42
+ scene's `onComplete`.
43
+
44
+ The numbered beats in an answer's Motion strategy and the labels in its code are
45
+ the same list. If they have drifted apart, the code is not what was designed.
46
+
7
47
  ## Position
8
48
 
9
49
  The third argument places a child on the timeline. This is the whole API.
@@ -53,6 +53,16 @@ const HINT = {
53
53
  "Inside a context these register themselves and are reverted with it. Out here nothing is watching: move it into the useGSAP body, or keep the instance and revert/kill it from the cleanup.",
54
54
  unrevertedContext:
55
55
  "Keep it under a name and revert it when the component goes: `onUnmounted` in Vue, the function returned from `onMount` or `$effect` in Svelte, the `useEffect` cleanup in React, `astro:before-swap` in Astro. Everything created inside the context goes with it.",
56
+ tweenPerFrame:
57
+ "A tween per frame allocates an object per frame, and each one fights the last. Create a `gsap.quickTo()` or `quickSetter()` once outside the callback and call it inside — or, for scroll, scrub a timeline instead of writing values by hand.",
58
+ paintProperty:
59
+ "Filters and shadows repaint the layer every frame, and the cost grows with the painted area. Animate `opacity` on a pre-blurred or pre-shadowed copy, or move a transform instead, and measure before defending anything else.",
60
+ unownedLoop:
61
+ "An infinite repeat keeps the ticker busy while it is off screen. Pause it from an IntersectionObserver, or let a ScrollTrigger's `onToggle` own it, so it costs nothing when nobody can see it.",
62
+ ungatedHover:
63
+ "A tap fires `mouseenter` on a touch device and nothing fires the leave, so the effect sticks. Gate it with `gsap.matchMedia()` on `(hover: hover)`, and decide what a coarse pointer gets instead.",
64
+ delayChain:
65
+ "Delays are a choreography nobody can change: moving one beat means re-adding every number after it. Build a timeline and position each beat with a label or a relative offset — `\"<\"`, `\"-=0.2\"`.",
56
66
  danglingListener:
57
67
  "A listener that survives unmount keeps the component's closure — and its DOM nodes — alive. Name the handler and remove it in the cleanup, or pass `{ signal }` from an AbortController and abort it there.",
58
68
  tweenPerEvent:
@@ -87,6 +97,10 @@ const HINT = {
87
97
  /** Messages that do not vary with what was found. */
88
98
  const MESSAGE = {
89
99
  easedLoop: "An infinite repeat with an ease other than `none`.",
100
+ tweenPerFrame: "A tween is created here on every frame.",
101
+ unownedLoop: "An infinite repeat that nothing pauses.",
102
+ ungatedHover: "A hover animation with no `(hover: hover)` gate.",
103
+ delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
90
104
  easedScrub: "Easing inside a scrubbed ScrollTrigger timeline.",
91
105
  neverCompletesChild:
92
106
  "An endlessly repeating child in a timeline with an `onComplete`, which therefore never fires.",
@@ -414,6 +428,13 @@ function notPerEvent(ast, handler, node) {
414
428
  }
415
429
 
416
430
  /** Listener targets that outlive every component: the window, the document and its root elements. */
431
+ /**
432
+ * Events that fire once and are never removed by anyone: the document's own
433
+ * start-up. The corpus reported two `DOMContentLoaded` handlers in an Astro
434
+ * page as leaks, which is not what a leak is.
435
+ */
436
+ const ONE_SHOT_EVENTS = new Set(["DOMContentLoaded", "load", "pageshow"]);
437
+
417
438
  const GLOBAL_TARGETS = new Set([
418
439
  "window",
419
440
  "document",
@@ -547,6 +568,119 @@ function mountHook(ast, node) {
547
568
  return null;
548
569
  }
549
570
 
571
+ /**
572
+ * Properties whose animation repaints the element rather than moving a layer
573
+ * the compositor already has.
574
+ *
575
+ * `clipPath` is deliberately absent. An inset or circle reveal is the technique
576
+ * this skill recommends for image reveals, it is cheap on a simple shape, and a
577
+ * rule that fires on its own guidance is noise.
578
+ */
579
+ const PAINT_PROPERTIES = new Set([
580
+ "filter",
581
+ "webkitFilter",
582
+ "backdropFilter",
583
+ "boxShadow",
584
+ ]);
585
+
586
+ const HOVER_EVENTS = new Set([
587
+ "pointerenter",
588
+ "pointerover",
589
+ "mouseenter",
590
+ "mouseover",
591
+ ]);
592
+
593
+ /**
594
+ * Every function that runs once per frame: the ticker, R3F's `useFrame`, an
595
+ * `onUpdate`/`onMove`/`onChange` callback, and a function that schedules
596
+ * itself with `requestAnimationFrame`.
597
+ */
598
+ function perFrameScopes(ast) {
599
+ const scopes = [];
600
+ const add = (node) => {
601
+ const fn = node && unwrap(node);
602
+ if (isFunction(fn)) scopes.push(fn);
603
+ };
604
+
605
+ for (const call of findAll(ast, (node) => {
606
+ const name = calleeName(node);
607
+ return name === "gsap.ticker.add" || name === "useFrame";
608
+ })) {
609
+ add(call.arguments[0]);
610
+ }
611
+
612
+ /**
613
+ * `onUpdate`, `onMove` and `onChange` are per-frame in a tween, a
614
+ * ScrollTrigger, an Observer or a Draggable — and nowhere else. Every control
615
+ * and form library in the world has an `onChange`, and the corpus duly
616
+ * reported a Three.js `traverse` inside one of them as a per-frame tween.
617
+ */
618
+ const PER_FRAME_KEYS = new Set(["onUpdate", "onMove", "onChange"]);
619
+ const GSAP_CONFIG = /^(?:gsap\.|ScrollTrigger\.|Observer\.|Draggable\.|ScrollSmoother\.|Flip\.)/;
620
+
621
+ for (const property of findAll(
622
+ ast,
623
+ (node) => node.type === "Property" && PER_FRAME_KEYS.has(keyName(node)),
624
+ )) {
625
+ const owner = ancestorsOf(ast, property).find(
626
+ (node) => node.type === "CallExpression",
627
+ );
628
+ if (!owner) continue;
629
+ const name = calleeName(owner);
630
+ if (tweenMethod(owner) === null && !(name && GSAP_CONFIG.test(name))) continue;
631
+ add(property.value);
632
+ }
633
+
634
+ for (const call of findAll(
635
+ ast,
636
+ (node) => calleeName(node) === "requestAnimationFrame",
637
+ )) {
638
+ const fn = resolveFunction(ast, call.arguments[0]);
639
+ if (isFunction(fn) && contains(fn, call)) scopes.push(fn);
640
+ }
641
+
642
+ return scopes;
643
+ }
644
+
645
+ /** The handlers a hover starts: a listener's callback, or a JSX `on*` prop. */
646
+ function hoverHandlers(ast) {
647
+ const handlers = [];
648
+
649
+ for (const call of findAll(
650
+ ast,
651
+ (node) => listenerMethod(node) === "addEventListener",
652
+ )) {
653
+ const event = staticString(call.arguments[0]);
654
+ if (event === null || !HOVER_EVENTS.has(event)) continue;
655
+ const fn = resolveFunction(ast, call.arguments[1]);
656
+ if (isFunction(fn)) handlers.push(fn);
657
+ }
658
+
659
+ for (const attribute of findAll(
660
+ ast,
661
+ (node) => node.type === "JSXAttribute" && node.name.type === "JSXIdentifier",
662
+ )) {
663
+ if (!/^on(?:Mouse|Pointer)(?:Enter|Over)$/.test(attribute.name.name)) continue;
664
+ if (attribute.value?.type !== "JSXExpressionContainer") continue;
665
+ const fn = resolveFunction(ast, attribute.value.expression);
666
+ if (isFunction(fn)) handlers.push(fn);
667
+ }
668
+
669
+ return handlers;
670
+ }
671
+
672
+ /** Whether anything in the file pauses, kills or reverts what is kept as `name`. */
673
+ function stopped(ast, name) {
674
+ if (!name) return false;
675
+ return (
676
+ findAll(ast, (node) => {
677
+ const method = methodName(node);
678
+ if (method !== "pause" && !TEARDOWN.has(method)) return false;
679
+ return dottedName(unwrap(node.callee).object) === name;
680
+ }).length > 0
681
+ );
682
+ }
683
+
550
684
  export const RULES = [
551
685
  // --- Lifecycle ------------------------------------------------------------
552
686
  {
@@ -687,6 +821,7 @@ export const RULES = [
687
821
  const event = staticString(call.arguments[0]);
688
822
  const handler = unwrap(call.arguments[1]);
689
823
  if (event === null || !/^[\w:-]+$/.test(event) || !handler) return [];
824
+ if (ONE_SHOT_EVENTS.has(event)) return [];
690
825
  const options = unwrap(call.arguments[2]);
691
826
  const once = unwrap(propertyOf(options, "once")?.value);
692
827
  const callee = unwrap(call.callee);
@@ -1184,6 +1319,177 @@ export const RULES = [
1184
1319
  return findings;
1185
1320
  },
1186
1321
  },
1322
+ {
1323
+ id: "tween-per-frame",
1324
+ level: "warn",
1325
+ description:
1326
+ "A tween created every frame — in `onUpdate`, the ticker, an Observer callback, `useFrame` or a `requestAnimationFrame` loop",
1327
+ /**
1328
+ * `gsap.set` is left out: writing a value straight to an element is what a
1329
+ * per-frame callback is for, and `quickSetter` is the faster shape of the
1330
+ * same thing rather than a different one. What this catches is a tween —
1331
+ * an object with its own duration and ease — being built sixty times a
1332
+ * second, each one overwriting the last before it can finish.
1333
+ */
1334
+ test(file) {
1335
+ const scopes = perFrameScopes(file.ast);
1336
+ if (scopes.length === 0) return [];
1337
+
1338
+ const tweens = findAll(file.ast, (node) => {
1339
+ const method = tweenMethod(node);
1340
+ return method !== null && method !== "set";
1341
+ });
1342
+
1343
+ const reported = new Set();
1344
+ const findings = [];
1345
+ for (const scope of scopes) {
1346
+ for (const tween of tweens) {
1347
+ if (!contains(scope, tween) || reported.has(tween.start)) continue;
1348
+ reported.add(tween.start);
1349
+ findings.push({
1350
+ index: tween.start,
1351
+ message: MESSAGE.tweenPerFrame,
1352
+ hint: HINT.tweenPerFrame,
1353
+ });
1354
+ }
1355
+ }
1356
+ return findings;
1357
+ },
1358
+ },
1359
+ {
1360
+ id: "paint-property",
1361
+ level: "warn",
1362
+ description:
1363
+ "Animating `filter`, `backdropFilter` or `boxShadow`, which repaints the element every frame",
1364
+ /** Read exactly as layout-property reads its own keys, and `set` is skipped too. */
1365
+ test(file) {
1366
+ const animates = (node) => {
1367
+ const method = tweenMethod(node);
1368
+ return method !== null && method !== "set";
1369
+ };
1370
+
1371
+ return findAll(file.ast, animates).flatMap((call) => {
1372
+ const seen = new Set();
1373
+ return varsObjects(call, tweenMethod(call))
1374
+ .flatMap((vars) => vars.properties)
1375
+ .filter((property) => {
1376
+ const key = keyName(property);
1377
+ if (!PAINT_PROPERTIES.has(key) || seen.has(key)) return false;
1378
+ seen.add(key);
1379
+ return true;
1380
+ })
1381
+ .map((property) => ({
1382
+ index: property.start,
1383
+ message: `Animating \`${keyName(property)}\` repaints on every frame.`,
1384
+ hint: HINT.paintProperty,
1385
+ }));
1386
+ });
1387
+ },
1388
+ },
1389
+ {
1390
+ id: "unowned-loop",
1391
+ level: "info",
1392
+ description:
1393
+ "An infinite repeat that nothing pauses, which keeps the ticker busy off screen",
1394
+ /**
1395
+ * Info, because "nothing pauses it" is read from one file and a loop can be
1396
+ * owned from elsewhere. A loop is taken as owned when its name is paused,
1397
+ * killed or reverted anywhere here, when the file builds an
1398
+ * IntersectionObserver, when the tween carries a `scrollTrigger` that
1399
+ * toggles it, or when a context holds it — unmount reverts that one.
1400
+ *
1401
+ * The last of those came from a real site's loader, where three loops sit
1402
+ * in a `useGSAP` body inside `matchMedia`. Reporting them would have meant
1403
+ * "nothing stops this" about an animation the component's own unmount stops.
1404
+ * What is left — a loop running while it is off screen but still mounted —
1405
+ * is a measurement, and 3.4's `inspect` is where that belongs.
1406
+ */
1407
+ test(file) {
1408
+ const observed = findAll(
1409
+ file.ast,
1410
+ (node) =>
1411
+ node.type === "NewExpression" &&
1412
+ dottedName(node.callee) === "IntersectionObserver",
1413
+ ).length > 0;
1414
+ if (observed) return [];
1415
+
1416
+ const inside = gsapContexts(file.ast);
1417
+
1418
+ return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
1419
+ if (inside(call)) return [];
1420
+ const vars = ownVars(call, tweenMethod(call));
1421
+ if (!vars) return [];
1422
+ const repeat = propertyOf(vars, "repeat");
1423
+ if (!repeat || numberValue(repeat.value) !== -1) return [];
1424
+ if (propertyOf(vars, "scrollTrigger")) return [];
1425
+ if (stopped(file.ast, keptUnder(file.ast, call)?.name)) return [];
1426
+ return [
1427
+ {
1428
+ index: repeat.start,
1429
+ message: MESSAGE.unownedLoop,
1430
+ hint: HINT.unownedLoop,
1431
+ },
1432
+ ];
1433
+ });
1434
+ },
1435
+ },
1436
+ {
1437
+ id: "ungated-hover",
1438
+ level: "warn",
1439
+ description:
1440
+ "A hover animation with no `(hover: hover)` gate, which a tap starts and nothing ends",
1441
+ /**
1442
+ * The gate is looked for as text, anywhere in the file: a `matchMedia`
1443
+ * condition, a CSS string, a constant. One finding per handler, on its first
1444
+ * tween, because the handler is what needs the gate.
1445
+ */
1446
+ test(file) {
1447
+ if (/\(\s*(?:any-)?hover\s*:\s*hover\s*\)/.test(file.code)) return [];
1448
+
1449
+ return hoverHandlers(file.ast).flatMap((handler) => {
1450
+ const [tween] = findAll(file.ast, (node) => tweenMethod(node) !== null).filter(
1451
+ (node) => contains(handler, node),
1452
+ );
1453
+ if (!tween) return [];
1454
+ return [
1455
+ { index: tween.start, message: MESSAGE.ungatedHover, hint: HINT.ungatedHover },
1456
+ ];
1457
+ });
1458
+ },
1459
+ },
1460
+ {
1461
+ id: "delay-chain",
1462
+ level: "warn",
1463
+ description:
1464
+ "Three or more tweens in one scope sequenced by `delay`, which is a timeline nobody can retime",
1465
+ /**
1466
+ * Three is the threshold because two tweens with delays are a pair, and a
1467
+ * pair is still readable. Counted per scope — the function they share — so
1468
+ * three components each with one delayed tween are not a chain. A tween on a
1469
+ * timeline is not counted: a timeline already has positions.
1470
+ */
1471
+ test(file) {
1472
+ const scopes = new Map();
1473
+
1474
+ for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
1475
+ if (chainStart(call) !== call) continue;
1476
+ const vars = ownVars(call, tweenMethod(call));
1477
+ const delay = vars && propertyOf(vars, "delay");
1478
+ if (!delay || !(numberValue(delay.value) > 0)) continue;
1479
+ const scope = scopeOf(file.ast, call) ?? file.ast;
1480
+ if (!scopes.has(scope)) scopes.set(scope, []);
1481
+ scopes.get(scope).push(call);
1482
+ }
1483
+
1484
+ return [...scopes.values()]
1485
+ .filter((calls) => calls.length >= 3)
1486
+ .map((calls) => ({
1487
+ index: calls[0].start,
1488
+ message: MESSAGE.delayChain,
1489
+ hint: HINT.delayChain,
1490
+ }));
1491
+ },
1492
+ },
1187
1493
  {
1188
1494
  id: "missing-reduced-motion",
1189
1495
  level: "warn",
@@ -1191,23 +1497,28 @@ export const RULES = [
1191
1497
  "An animating file with no `prefers-reduced-motion` branch",
1192
1498
  /**
1193
1499
  * A branch is the query written in code — a string, a template or JSX
1194
- * text. A comment that mentions it is not a branch. A file that only sets
1195
- * values is not animating anything.
1500
+ * text. A comment that mentions it is not a branch.
1501
+ *
1502
+ * Only a file that builds an animation of its own is reported, and the
1503
+ * finding lands on the first one. Three things are not that:
1504
+ * `registerPlugin` and `killTweensOf`, which animate nothing; `set`, which
1505
+ * has no duration; and a method called on a timeline the file was handed,
1506
+ * because a helper that adds beats to its caller's timeline does not own
1507
+ * the reduced-motion decision — the caller does.
1196
1508
  */
1197
1509
  test(file) {
1198
1510
  if (!file.usesGsap) return [];
1199
1511
  if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
1200
1512
  return [];
1201
1513
  }
1202
- if (!findAll(file.ast, (node) => isGsapCall(node)).length) return [];
1203
1514
 
1204
- const first = findAll(
1205
- file.ast,
1206
- (node) =>
1207
- node.type === "MemberExpression" &&
1208
- unwrap(node.object)?.type === "Identifier" &&
1209
- unwrap(node.object).name === "gsap",
1210
- ).sort((a, b) => a.start - b.start)[0];
1515
+ const [first] = findAll(file.ast, (node) => {
1516
+ const method = tweenMethod(node);
1517
+ if (method === null || method === "set") return false;
1518
+ /** Built here, rather than added to something passed in. */
1519
+ return calleeName(node)?.startsWith("gsap.") === true;
1520
+ }).sort((a, b) => a.start - b.start);
1521
+ if (!first) return [];
1211
1522
 
1212
1523
  return [
1213
1524
  {