@mehshekari/gsap-motion 2.1.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/LICENSE +21 -0
- package/NOTICE.md +24 -0
- package/README.md +59 -0
- package/bin/gsap-motion.mjs +304 -0
- package/package.json +48 -0
- package/skill/LICENSE +21 -0
- package/skill/NOTICE.md +24 -0
- package/skill/SKILL.md +181 -0
- package/skill/example/cinematic-intro.md +202 -0
- package/skill/example/magnetic-button.md +101 -0
- package/skill/example/svg-loader.md +180 -0
- package/skill/preset/card-stack.md +175 -0
- package/skill/preset/cinematic.md +140 -0
- package/skill/preset/cursor.md +184 -0
- package/skill/preset/loader.md +172 -0
- package/skill/preset/magnetic.md +155 -0
- package/skill/preset/marquee.md +177 -0
- package/skill/preset/page-transition.md +151 -0
- package/skill/preset/reveal.md +146 -0
- package/skill/preset/scroll-storytelling.md +176 -0
- package/skill/reference/accessibility.md +143 -0
- package/skill/reference/core-gsap.md +233 -0
- package/skill/reference/flip.md +220 -0
- package/skill/reference/frameworks.md +153 -0
- package/skill/reference/interaction.md +183 -0
- package/skill/reference/motion-design.md +144 -0
- package/skill/reference/performance.md +216 -0
- package/skill/reference/project-rules.md +178 -0
- package/skill/reference/react-nextjs.md +234 -0
- package/skill/reference/routing.md +119 -0
- package/skill/reference/scrolltrigger.md +191 -0
- package/skill/reference/svg.md +201 -0
- package/skill/reference/text.md +150 -0
- package/skill/reference/three-r3f.md +161 -0
- package/skill/reference/timeline.md +147 -0
- package/skill/scripts/audit-gsap.mjs +157 -0
- package/skill/scripts/audit-svg.mjs +358 -0
- package/skill/scripts/lib/rules.mjs +555 -0
- package/skill/scripts/lib/source.mjs +238 -0
- package/skill/template/useScrollScene.ts +126 -0
- package/skill/template/useTimeline.ts +110 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GSAP audit's rule registry.
|
|
3
|
+
*
|
|
4
|
+
* Every rule here earns its place by catching something that is **silent** —
|
|
5
|
+
* no error, no warning, often no visible symptom until much later or on a
|
|
6
|
+
* slower device. Craft questions (is this ease right, is the rhythm good) are
|
|
7
|
+
* deliberately absent: they are not mechanically decidable, and pretending
|
|
8
|
+
* otherwise would fill the report with noise that hides the real findings.
|
|
9
|
+
*
|
|
10
|
+
* Each rule: `{ id, level, test(file) -> findings[] }` where a finding is
|
|
11
|
+
* `{ index, message, hint }`. `level` is "error" (a bug), "warn" (a likely
|
|
12
|
+
* bug or a real cost) or "info" (worth a look, higher false-positive rate).
|
|
13
|
+
*
|
|
14
|
+
* Every rule has fixtures in `scripts/test/rules.test.mjs`: a case it must fire
|
|
15
|
+
* on and a case it must stay quiet on. A wrong finding in either direction is
|
|
16
|
+
* fixed by adding the case there first and watching it fail.
|
|
17
|
+
*/
|
|
18
|
+
import { blockAfter, parenSpan, within } from "./source.mjs";
|
|
19
|
+
|
|
20
|
+
/** Properties whose animation forces the browser to re-run layout. */
|
|
21
|
+
const LAYOUT_PROPERTIES = [
|
|
22
|
+
"left",
|
|
23
|
+
"top",
|
|
24
|
+
"right",
|
|
25
|
+
"bottom",
|
|
26
|
+
"width",
|
|
27
|
+
"height",
|
|
28
|
+
"margin",
|
|
29
|
+
"marginTop",
|
|
30
|
+
"marginLeft",
|
|
31
|
+
"marginRight",
|
|
32
|
+
"marginBottom",
|
|
33
|
+
"padding",
|
|
34
|
+
"paddingTop",
|
|
35
|
+
"paddingLeft",
|
|
36
|
+
"fontSize",
|
|
37
|
+
"lineHeight",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const TRANSFORM_FOR = {
|
|
41
|
+
left: "x",
|
|
42
|
+
right: "x",
|
|
43
|
+
top: "y",
|
|
44
|
+
bottom: "y",
|
|
45
|
+
width: "scaleX (with transformOrigin)",
|
|
46
|
+
height: "scaleY (with transformOrigin)",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The caveat that makes the swap above safe, and the reason this is spelled
|
|
51
|
+
* out rather than left as "use a transform".
|
|
52
|
+
*
|
|
53
|
+
* `top: 50%` is half the **containing block**. `y: "50%"` and `yPercent: 50`
|
|
54
|
+
* are half the **element's own** size. On a 1px rule those are 0.5px, so the
|
|
55
|
+
* naive swap silently turns a full-height travel into nothing — which is worse
|
|
56
|
+
* than the layout cost it was meant to fix.
|
|
57
|
+
*
|
|
58
|
+
* Two ways out: give the moving element the same height as its container and
|
|
59
|
+
* translate that (the percentages then agree, and nothing has to be measured),
|
|
60
|
+
* or measure the container once and translate in pixels.
|
|
61
|
+
*/
|
|
62
|
+
const PERCENT_CAVEAT =
|
|
63
|
+
"Careful with percentages: `top: 50%` is half the containing block, `yPercent: 50` is half the element itself. Either wrap it in a full-height box and translate that — the percentages then agree — or measure the container once and translate in pixels.";
|
|
64
|
+
|
|
65
|
+
/** Events that fire many times a second. Global: every occurrence is scanned. */
|
|
66
|
+
const HOT_EVENTS =
|
|
67
|
+
/(pointermove|mousemove|touchmove|onMouseMove|onPointerMove|"scroll"|'scroll'|wheel)/g;
|
|
68
|
+
|
|
69
|
+
/** Every `gsap.to/from/fromTo/set(` call site, as offsets. */
|
|
70
|
+
const tweenCalls = (code) => [
|
|
71
|
+
...code.matchAll(/\bgsap\.(to|from|fromTo|set|timeline)\s*\(/g),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const find = (code, pattern) => [...code.matchAll(pattern)];
|
|
75
|
+
|
|
76
|
+
/** An import from `gsap/*` that is a plugin needing `registerPlugin`. */
|
|
77
|
+
const PLUGIN_NAME =
|
|
78
|
+
/(?:Plugin|Trigger|Text|Flip|Observer|Draggable|Ease|Smoother|Bounce|Wiggle)$/;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether the instance constructed at `index` is kept under a name and
|
|
82
|
+
* reverted or killed through that name.
|
|
83
|
+
*
|
|
84
|
+
* This used to ask whether `.revert(` or `.kill(` appeared anywhere in the
|
|
85
|
+
* file, so one unrelated `tl.kill()` exempted every instance beside it.
|
|
86
|
+
*/
|
|
87
|
+
function tornDown(code, index) {
|
|
88
|
+
const kept = code
|
|
89
|
+
.slice(Math.max(0, index - 200), index)
|
|
90
|
+
.match(/([\w$][\w$.]*)\s*=\s*$/);
|
|
91
|
+
if (!kept) return false;
|
|
92
|
+
const name = kept[1].replace(/[.$]/g, "\\$&");
|
|
93
|
+
return new RegExp(
|
|
94
|
+
`(?<![\\w$.])${name}\\s*\\??\\.\\s*(?:revert|kill)\\s*\\(`,
|
|
95
|
+
).test(code);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every call to `method` (`addEventListener` / `removeEventListener`), with
|
|
100
|
+
* the event name and handler read out of its arguments.
|
|
101
|
+
*
|
|
102
|
+
* Pairing by event and handler rather than counting calls: two adds and two
|
|
103
|
+
* removes used to pass even when both removes were for the same event, and an
|
|
104
|
+
* inline arrow — which no remove can ever reach — passed whenever any remove
|
|
105
|
+
* existed. A dynamic event name cannot be judged statically, so it is skipped.
|
|
106
|
+
*/
|
|
107
|
+
function listenerCalls(code, method) {
|
|
108
|
+
return find(code, new RegExp(`\\b${method}\\s*\\(`, "g")).map((m) => {
|
|
109
|
+
const open = m.index + m[0].length - 1;
|
|
110
|
+
const span = parenSpan(code, open);
|
|
111
|
+
const args = span ? code.slice(open + 1, span[1]) : "";
|
|
112
|
+
const event = args.match(/^\s*(["'`])([\w:-]+)\1\s*,\s*/);
|
|
113
|
+
if (!event) return { index: m.index, event: null };
|
|
114
|
+
|
|
115
|
+
const rest = args.slice(event[0].length);
|
|
116
|
+
const inline = /^(?:async\b|function\b|\(|[\w$]+\s*=>)/.test(rest);
|
|
117
|
+
return {
|
|
118
|
+
index: m.index,
|
|
119
|
+
event: event[2],
|
|
120
|
+
inline,
|
|
121
|
+
handler: inline ? null : (rest.match(/^[\w$.]+/)?.[0] ?? null),
|
|
122
|
+
/** Removed by an AbortController, or by the browser after one call. */
|
|
123
|
+
selfCleaning: /\bsignal\b|\bonce\s*:\s*true/.test(rest),
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The argument spans of the `.to(` / `.from(` / `.fromTo(` calls chained on
|
|
130
|
+
* from `cursor`, following the chain through labels and other calls.
|
|
131
|
+
*/
|
|
132
|
+
function chainedTweens(code, cursor) {
|
|
133
|
+
const spans = [];
|
|
134
|
+
for (;;) {
|
|
135
|
+
const next = code.slice(cursor).match(/^\s*\.\s*([\w$]+)\s*\(/);
|
|
136
|
+
if (!next) return spans;
|
|
137
|
+
const open = cursor + next[0].length - 1;
|
|
138
|
+
const span = parenSpan(code, open);
|
|
139
|
+
if (!span) return spans;
|
|
140
|
+
if (["to", "from", "fromTo"].includes(next[1])) spans.push([open, span[1]]);
|
|
141
|
+
cursor = span[1] + 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const RULES = [
|
|
146
|
+
// --- Lifecycle ------------------------------------------------------------
|
|
147
|
+
{
|
|
148
|
+
id: "orphan-tween",
|
|
149
|
+
level: "error",
|
|
150
|
+
test(file) {
|
|
151
|
+
if (!file.isReact || !file.usesGsap) return [];
|
|
152
|
+
|
|
153
|
+
return tweenCalls(file.code)
|
|
154
|
+
.filter((m) => m[1] !== "set" && !within(file.contexts, m.index))
|
|
155
|
+
.filter((m) => {
|
|
156
|
+
/**
|
|
157
|
+
* Only tweens inside some function — module-scope constants are not
|
|
158
|
+
* the target here.
|
|
159
|
+
*
|
|
160
|
+
* This used to read `\b(useEffect|useLayoutEffect|function|=>)\s*[({]`.
|
|
161
|
+
* `\b` needs a word character beside it and `=>` has none, so the
|
|
162
|
+
* arrow alternative could never match: every tween in an arrow-function
|
|
163
|
+
* handler, which is most of React, was silently exempt. `function`
|
|
164
|
+
* only matched the anonymous `function (`, never `function Name(`.
|
|
165
|
+
*/
|
|
166
|
+
const before = file.code.slice(0, m.index);
|
|
167
|
+
return /(?:\bfunction\b[^(]*\(|=>)/.test(before.slice(-400));
|
|
168
|
+
})
|
|
169
|
+
.map((m) => ({
|
|
170
|
+
index: m.index,
|
|
171
|
+
message: `\`gsap.${m[1]}\` is created outside any useGSAP/gsap.context scope.`,
|
|
172
|
+
hint: "Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. This check does not follow calls: a helper that tweens reads as outside even when only the body calls it — keep the measuring in the helper and the tween in the body.",
|
|
173
|
+
}));
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
/**
|
|
177
|
+
* GSAP's own teardown is wider than it is usually given credit for, and
|
|
178
|
+
* these rules are narrow because of it.
|
|
179
|
+
*
|
|
180
|
+
* While a context's function runs, `_context` points at that context, and
|
|
181
|
+
* `MatchMedia`, `Observer`, `Draggable`, `SplitText` and `ScrollTrigger` all
|
|
182
|
+
* register themselves with it on construction (the `_context(this)` call in
|
|
183
|
+
* each constructor — verified against gsap 3.15). `context.revert()` then reverts
|
|
184
|
+
* them. So anything built **inside** a `useGSAP` body is already handled,
|
|
185
|
+
* and demanding a manual `.kill()` there would be cargo cult.
|
|
186
|
+
*
|
|
187
|
+
* Built outside one — module scope, a plain `useEffect`, a handler that never
|
|
188
|
+
* went through `contextSafe` — nothing is watching, and it is a real leak.
|
|
189
|
+
*/
|
|
190
|
+
{
|
|
191
|
+
id: "unmanaged-instance",
|
|
192
|
+
level: "error",
|
|
193
|
+
test(file) {
|
|
194
|
+
const constructors =
|
|
195
|
+
/\b(?:gsap\.(matchMedia)|(Observer|Draggable|ScrollSmoother)\.create|new (SplitText))\s*\(/g;
|
|
196
|
+
|
|
197
|
+
return find(file.code, constructors)
|
|
198
|
+
.filter((m) => !within(file.contexts, m.index))
|
|
199
|
+
.filter((m) => !tornDown(file.code, m.index))
|
|
200
|
+
.map((m) => ({
|
|
201
|
+
index: m.index,
|
|
202
|
+
message: `\`${m[1] ?? m[2] ?? m[3]}\` is created outside any useGSAP/gsap.context scope and never torn down.`,
|
|
203
|
+
hint: "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.",
|
|
204
|
+
}));
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "dangling-listener",
|
|
209
|
+
level: "warn",
|
|
210
|
+
test(file) {
|
|
211
|
+
const removed = new Set(
|
|
212
|
+
listenerCalls(file.code, "removeEventListener")
|
|
213
|
+
.filter((call) => call.handler)
|
|
214
|
+
.map((call) => `${call.event}|${call.handler}`),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return listenerCalls(file.code, "addEventListener")
|
|
218
|
+
.filter((call) => call.event && !call.selfCleaning)
|
|
219
|
+
.filter(
|
|
220
|
+
(call) => call.inline || !removed.has(`${call.event}|${call.handler}`),
|
|
221
|
+
)
|
|
222
|
+
.map((call) => ({
|
|
223
|
+
index: call.index,
|
|
224
|
+
message: call.inline
|
|
225
|
+
? `An inline \`${call.event}\` listener, which no removeEventListener can reach.`
|
|
226
|
+
: `\`${call.event}\` listener \`${call.handler}\` is added and never removed.`,
|
|
227
|
+
hint: "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.",
|
|
228
|
+
}));
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// --- Cost -----------------------------------------------------------------
|
|
233
|
+
{
|
|
234
|
+
id: "tween-per-event",
|
|
235
|
+
level: "error",
|
|
236
|
+
test(file) {
|
|
237
|
+
const findings = [];
|
|
238
|
+
|
|
239
|
+
for (const handler of find(file.code, HOT_EVENTS)) {
|
|
240
|
+
const span = blockAfter(file.code, handler.index);
|
|
241
|
+
if (!span) continue;
|
|
242
|
+
|
|
243
|
+
for (const call of tweenCalls(file.code)) {
|
|
244
|
+
if (call[1] === "set" || !within([span], call.index)) continue;
|
|
245
|
+
findings.push({
|
|
246
|
+
index: call.index,
|
|
247
|
+
message: `\`gsap.${call[1]}\` inside a high-frequency handler (${handler[1]}).`,
|
|
248
|
+
hint: "One tween allocated per event, each fighting the last. Create a `gsap.quickTo()` once and call it from the handler.",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return findings;
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "state-per-event",
|
|
258
|
+
level: "error",
|
|
259
|
+
test(file) {
|
|
260
|
+
if (!file.isReact) return [];
|
|
261
|
+
const findings = [];
|
|
262
|
+
|
|
263
|
+
for (const handler of find(file.code, HOT_EVENTS)) {
|
|
264
|
+
const span = blockAfter(file.code, handler.index);
|
|
265
|
+
if (!span) continue;
|
|
266
|
+
|
|
267
|
+
for (const setter of find(file.code, /\bset[A-Z]\w*\s*\(/g)) {
|
|
268
|
+
if (!within([span], setter.index)) continue;
|
|
269
|
+
findings.push({
|
|
270
|
+
index: setter.index,
|
|
271
|
+
message: `React state setter inside a ${handler[1]} handler.`,
|
|
272
|
+
hint: "A re-render per frame so one element can move a few pixels. State owns what exists; GSAP owns how it moves — use a ref and quickTo.",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return findings;
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "layout-property",
|
|
282
|
+
level: "warn",
|
|
283
|
+
test(file) {
|
|
284
|
+
const pattern = new RegExp(
|
|
285
|
+
`\\b(${LAYOUT_PROPERTIES.join("|")})\\s*:\\s*(?!["'\\s]*$)`,
|
|
286
|
+
"g",
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
return tweenCalls(file.code).flatMap((call) => {
|
|
290
|
+
const span = blockAfter(file.code, call.index);
|
|
291
|
+
if (!span) return [];
|
|
292
|
+
const body = file.code.slice(span[0], span[1]);
|
|
293
|
+
|
|
294
|
+
return [...body.matchAll(pattern)]
|
|
295
|
+
.filter((m) => !/^\s*(?:\/\/|\*)/.test(m[0]))
|
|
296
|
+
.map((m) => {
|
|
297
|
+
const swap = TRANSFORM_FOR[m[1]];
|
|
298
|
+
/**
|
|
299
|
+
* Whether the flagged value is a percentage of something.
|
|
300
|
+
*
|
|
301
|
+
* Braces are allowed through rather than treated as a terminator:
|
|
302
|
+
* the value is very often a template literal, and `` `${n}%` ``
|
|
303
|
+
* carries a closing brace immediately before the `%` that was
|
|
304
|
+
* meant to be found. The comma and the newline are what actually
|
|
305
|
+
* end a value here.
|
|
306
|
+
*/
|
|
307
|
+
const percent = new RegExp(`\\b${m[1]}\\s*:[^,\\n]{0,80}%`).test(
|
|
308
|
+
body,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
index: span[0] + m.index,
|
|
313
|
+
message: `Animating \`${m[1]}\` forces layout on every frame.`,
|
|
314
|
+
hint: swap
|
|
315
|
+
? `Use \`${swap}\` instead — a compositor-only property.` +
|
|
316
|
+
(percent ? ` ${PERCENT_CAVEAT}` : "")
|
|
317
|
+
: "Prefer transform and opacity; they never touch layout.",
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: "trigger-per-item",
|
|
325
|
+
level: "warn",
|
|
326
|
+
test(file) {
|
|
327
|
+
return find(file.code, /\.(forEach|map)\s*\(/g)
|
|
328
|
+
.flatMap((loop) => {
|
|
329
|
+
const span = blockAfter(file.code, loop.index);
|
|
330
|
+
if (!span) return [];
|
|
331
|
+
const body = file.code.slice(span[0], span[1]);
|
|
332
|
+
const hit = body.indexOf("scrollTrigger");
|
|
333
|
+
return hit === -1
|
|
334
|
+
? []
|
|
335
|
+
: [
|
|
336
|
+
{
|
|
337
|
+
index: span[0] + hit,
|
|
338
|
+
message: "A ScrollTrigger created per item in a loop.",
|
|
339
|
+
hint: "Each carries its own start/end maths on every scroll frame. If the items belong to one visual moment, use one trigger with a stagger. Per-item is right only when they genuinely reveal independently — pinned card stacks, for instance.",
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
})
|
|
343
|
+
.slice(0, 1);
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
// --- Correctness that is invisible ---------------------------------------
|
|
348
|
+
{
|
|
349
|
+
id: "eased-loop",
|
|
350
|
+
level: "warn",
|
|
351
|
+
test(file) {
|
|
352
|
+
return tweenCalls(file.code).flatMap((call) => {
|
|
353
|
+
const span = blockAfter(file.code, call.index);
|
|
354
|
+
if (!span) return [];
|
|
355
|
+
const body = file.code.slice(span[0], span[1]);
|
|
356
|
+
|
|
357
|
+
if (!/repeat\s*:\s*-1/.test(body)) return [];
|
|
358
|
+
/** yoyo reverses rather than restarting, so there is no seam to hide. */
|
|
359
|
+
if (/yoyo\s*:\s*true/.test(body)) return [];
|
|
360
|
+
if (/ease\s*:\s*["']none["']/.test(body)) return [];
|
|
361
|
+
if (!/ease\s*:/.test(body)) return [];
|
|
362
|
+
|
|
363
|
+
return [
|
|
364
|
+
{
|
|
365
|
+
index: span[0] + body.indexOf("ease"),
|
|
366
|
+
message: "An infinite repeat with an ease other than `none`.",
|
|
367
|
+
hint: "The tween decelerates into its own restart, so the loop seam becomes visible. Use `ease: \"none\"`, or `yoyo: true` if it should breathe.",
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
id: "eased-scrub",
|
|
375
|
+
level: "warn",
|
|
376
|
+
test(file) {
|
|
377
|
+
if (!/scrub\s*:/.test(file.code)) return [];
|
|
378
|
+
|
|
379
|
+
const eased = (text) => text.search(/\bease\s*:\s*["'`](?!none["'`])/);
|
|
380
|
+
const findings = [];
|
|
381
|
+
const report = (index) =>
|
|
382
|
+
findings.push({
|
|
383
|
+
index,
|
|
384
|
+
message: "Easing inside a scrubbed ScrollTrigger timeline.",
|
|
385
|
+
hint: "The scrollbar is the playhead, so an ease fights the visitor's own scrolling and reads as lag. Use `ease: \"none\"` on the children and let `scrub: 1` do the smoothing.",
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Whitespace is allowed around the dot because a formatter breaks a
|
|
390
|
+
* three-call chain as `gsap\n .timeline(…)\n .to(…)`. The eased child of
|
|
391
|
+
* a scrubbed timeline is the common case, and it used to be missed: only
|
|
392
|
+
* the timeline's own vars were read, and they rarely carry the ease.
|
|
393
|
+
*/
|
|
394
|
+
for (const call of find(
|
|
395
|
+
file.code,
|
|
396
|
+
/\bgsap\s*\.\s*(to|from|fromTo|timeline)\s*\(/g,
|
|
397
|
+
)) {
|
|
398
|
+
const open = call.index + call[0].length - 1;
|
|
399
|
+
const span = parenSpan(file.code, open);
|
|
400
|
+
if (!span) continue;
|
|
401
|
+
const args = file.code.slice(open, span[1]);
|
|
402
|
+
if (!/scrub\s*:/.test(args)) continue;
|
|
403
|
+
|
|
404
|
+
const own = eased(args);
|
|
405
|
+
if (own !== -1) report(open + own);
|
|
406
|
+
if (call[1] !== "timeline") continue;
|
|
407
|
+
|
|
408
|
+
const children = chainedTweens(file.code, span[1] + 1);
|
|
409
|
+
const kept = file.code
|
|
410
|
+
.slice(Math.max(0, call.index - 80), call.index)
|
|
411
|
+
.match(/([\w$]+)\s*=\s*$/);
|
|
412
|
+
if (kept) {
|
|
413
|
+
const name = kept[1].replace(/\$/g, "\\$");
|
|
414
|
+
const uses = new RegExp(
|
|
415
|
+
`(?<![\\w$.])${name}(?=\\s*\\.\\s*(?:to|from|fromTo)\\s*\\()`,
|
|
416
|
+
"g",
|
|
417
|
+
);
|
|
418
|
+
for (const use of find(file.code, uses)) {
|
|
419
|
+
children.push(
|
|
420
|
+
...chainedTweens(file.code, use.index + use[0].length),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (const [start, end] of children) {
|
|
426
|
+
const at = eased(file.code.slice(start, end));
|
|
427
|
+
if (at !== -1) report(start + at);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return findings.slice(0, 1);
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: "shared-plugin-id",
|
|
436
|
+
level: "error",
|
|
437
|
+
test(file) {
|
|
438
|
+
if (!file.isReact) return [];
|
|
439
|
+
|
|
440
|
+
return find(
|
|
441
|
+
file.code,
|
|
442
|
+
/\b(?:path|shape|align)\s*:\s*["'](#[A-Za-z][\w-]*)["']/g,
|
|
443
|
+
).map((m) => ({
|
|
444
|
+
index: m.index,
|
|
445
|
+
message: `Hardcoded id \`${m[1]}\` in plugin config.`,
|
|
446
|
+
hint: "useGSAP scopes the selectors it resolves itself, but MotionPath and MorphSVG resolve their own config against the whole document. Mount this component twice and both instances drive the first one's geometry, silently. Namespace it with useId().",
|
|
447
|
+
}));
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
id: "unregistered-plugin",
|
|
452
|
+
level: "error",
|
|
453
|
+
test(file) {
|
|
454
|
+
/**
|
|
455
|
+
* The identifiers actually passed to `registerPlugin`, across every
|
|
456
|
+
* call. This used to be "does the name appear anywhere after the first
|
|
457
|
+
* registerPlugin", so a `ScrollTrigger.refresh()` below a registration
|
|
458
|
+
* that never mentioned it counted as registering it — and an alias
|
|
459
|
+
* (`ScrollTrigger as ST`) registered by its alias counted as missing.
|
|
460
|
+
*/
|
|
461
|
+
const registered = new Set(
|
|
462
|
+
find(file.code, /\bregisterPlugin\s*\(/g).flatMap((m) => {
|
|
463
|
+
const open = m.index + m[0].length - 1;
|
|
464
|
+
const span = parenSpan(file.code, open);
|
|
465
|
+
return span
|
|
466
|
+
? file.code
|
|
467
|
+
.slice(open + 1, span[1])
|
|
468
|
+
.split(/[^\w$]+/)
|
|
469
|
+
.filter(Boolean)
|
|
470
|
+
: [];
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
return find(
|
|
475
|
+
file.code,
|
|
476
|
+
/\bimport\s*\{([^}]*)\}\s*from\s*["']gsap\/[\w/-]+["']/g,
|
|
477
|
+
).flatMap((m) =>
|
|
478
|
+
m[1]
|
|
479
|
+
.split(",")
|
|
480
|
+
.map((specifier) => specifier.trim())
|
|
481
|
+
.filter((specifier) => specifier && !/^type\s/.test(specifier))
|
|
482
|
+
.flatMap((specifier) => {
|
|
483
|
+
const [imported, local = imported] = specifier.split(/\s+as\s+/);
|
|
484
|
+
if (!PLUGIN_NAME.test(imported) || registered.has(local)) return [];
|
|
485
|
+
return [
|
|
486
|
+
{
|
|
487
|
+
index: m.index,
|
|
488
|
+
message: `\`${imported}\` is imported but never passed to \`gsap.registerPlugin\`.`,
|
|
489
|
+
hint: "An unregistered plugin's properties are ignored without an error — the animation simply does nothing.",
|
|
490
|
+
},
|
|
491
|
+
];
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: "missing-reduced-motion",
|
|
498
|
+
level: "warn",
|
|
499
|
+
test(file) {
|
|
500
|
+
if (!file.usesGsap) return [];
|
|
501
|
+
if (/prefers-reduced-motion/.test(file.code)) return [];
|
|
502
|
+
/** A file that only sets values is not animating anything. */
|
|
503
|
+
if (!tweenCalls(file.code).some((m) => m[1] !== "set")) return [];
|
|
504
|
+
|
|
505
|
+
return [
|
|
506
|
+
{
|
|
507
|
+
index: file.code.search(/\bgsap\./),
|
|
508
|
+
message: "No `prefers-reduced-motion` branch in an animating file.",
|
|
509
|
+
hint: "Reduced motion is a design, not an off switch: decide what the motion was saying and say it without the travel. Note that the reduced branch must set the END state — a `from` that never runs leaves its target invisible.",
|
|
510
|
+
},
|
|
511
|
+
];
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// --- Shipping -------------------------------------------------------------
|
|
516
|
+
{
|
|
517
|
+
id: "dev-tool-shipped",
|
|
518
|
+
level: "error",
|
|
519
|
+
test(file) {
|
|
520
|
+
const findings = [];
|
|
521
|
+
|
|
522
|
+
for (const m of find(
|
|
523
|
+
file.code,
|
|
524
|
+
/^\s*import\b[^\n]*\b(MotionPathHelper|GSDevTools)\b/gm,
|
|
525
|
+
)) {
|
|
526
|
+
findings.push({
|
|
527
|
+
index: m.index,
|
|
528
|
+
message: `\`${m[1]}\` is statically imported.`,
|
|
529
|
+
hint: "A static import ships regardless of any `if` around the call. Load it with a dynamic `import()` behind a NODE_ENV check, or bake its output in and delete it.",
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (const m of find(file.code, /markers\s*:\s*true/g)) {
|
|
534
|
+
findings.push({
|
|
535
|
+
index: m.index,
|
|
536
|
+
message: "`markers: true` is unconditional.",
|
|
537
|
+
hint: 'Gate it: `markers: process.env.NODE_ENV === "development"`.',
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return findings;
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
id: "barrel-import",
|
|
546
|
+
level: "warn",
|
|
547
|
+
test(file) {
|
|
548
|
+
return find(file.code, /from\s*["']gsap\/all["']/g).map((m) => ({
|
|
549
|
+
index: m.index,
|
|
550
|
+
message: "Importing from `gsap/all` pulls in every plugin.",
|
|
551
|
+
hint: "Import each from its own subpath — `gsap/ScrollTrigger` — so the bundler can drop the ones this route does not use.",
|
|
552
|
+
}));
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
];
|