@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +24 -0
  3. package/README.md +59 -0
  4. package/bin/gsap-motion.mjs +304 -0
  5. package/package.json +48 -0
  6. package/skill/LICENSE +21 -0
  7. package/skill/NOTICE.md +24 -0
  8. package/skill/SKILL.md +181 -0
  9. package/skill/example/cinematic-intro.md +202 -0
  10. package/skill/example/magnetic-button.md +101 -0
  11. package/skill/example/svg-loader.md +180 -0
  12. package/skill/preset/card-stack.md +175 -0
  13. package/skill/preset/cinematic.md +140 -0
  14. package/skill/preset/cursor.md +184 -0
  15. package/skill/preset/loader.md +172 -0
  16. package/skill/preset/magnetic.md +155 -0
  17. package/skill/preset/marquee.md +177 -0
  18. package/skill/preset/page-transition.md +151 -0
  19. package/skill/preset/reveal.md +146 -0
  20. package/skill/preset/scroll-storytelling.md +176 -0
  21. package/skill/reference/accessibility.md +143 -0
  22. package/skill/reference/core-gsap.md +233 -0
  23. package/skill/reference/flip.md +220 -0
  24. package/skill/reference/frameworks.md +153 -0
  25. package/skill/reference/interaction.md +183 -0
  26. package/skill/reference/motion-design.md +144 -0
  27. package/skill/reference/performance.md +216 -0
  28. package/skill/reference/project-rules.md +178 -0
  29. package/skill/reference/react-nextjs.md +234 -0
  30. package/skill/reference/routing.md +119 -0
  31. package/skill/reference/scrolltrigger.md +191 -0
  32. package/skill/reference/svg.md +201 -0
  33. package/skill/reference/text.md +150 -0
  34. package/skill/reference/three-r3f.md +161 -0
  35. package/skill/reference/timeline.md +147 -0
  36. package/skill/scripts/audit-gsap.mjs +157 -0
  37. package/skill/scripts/audit-svg.mjs +358 -0
  38. package/skill/scripts/lib/rules.mjs +555 -0
  39. package/skill/scripts/lib/source.mjs +238 -0
  40. package/skill/template/useScrollScene.ts +126 -0
  41. 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
+ ];