@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 +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +37 -34
- package/skill/reference/devices.md +110 -0
- package/skill/reference/motion-design.md +8 -5
- package/skill/reference/project-rules.md +16 -0
- package/skill/reference/refine.md +84 -0
- package/skill/reference/routing.md +17 -0
- package/skill/reference/timeline.md +40 -0
- package/skill/scripts/lib/rules.mjs +321 -10
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.
|
|
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
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.
|
|
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.
|
|
22
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
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.
|
|
84
|
-
|
|
85
|
-
- **
|
|
86
|
-
- **A description
|
|
87
|
-
|
|
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** —
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
164
|
-
|
|
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,
|
|
168
|
-
#
|
|
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
|
|
181
|
-
|
|
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
|
|
185
|
-
whatever `if` surrounds it
|
|
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.
|
|
90
|
-
|
|
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
|
|
109
|
-
|
|
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
|
|
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.
|
|
1195
|
-
*
|
|
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
|
-
|
|
1206
|
-
(
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
{
|