@mehshekari/gsap-motion 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
28
28
  build without warning:
29
29
 
30
30
  ```json
31
- "lint": "eslint && npx @mehshekari/gsap-motion@2.1.0 audit src --quiet"
31
+ "lint": "eslint && npx @mehshekari/gsap-motion@3.0.0 audit src --quiet"
32
32
  ```
33
33
 
34
34
  Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/gsap-motion",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "Install the gsap-creative-animation Agent Skill, and audit GSAP animation for leaks, jank and accessibility failures — with npx.",
5
5
  "keywords": [
6
6
  "gsap",
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
4
4
  license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
5
5
  compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 18+ and nothing else.
6
6
  metadata:
7
- version: "2.1.0"
7
+ version: "3.0.0"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -160,8 +160,9 @@ Both scripts catch failures that are **silent**. Run them from the project
160
160
  root; `<skill-dir>` is where this skill is installed.
161
161
 
162
162
  ```bash
163
- # Leaks, per-event tweens, layout properties, eased loops, shared plugin ids,
164
- # unregistered plugins, dev tooling left in. Exit 1 on an error.
163
+ # Leaks, per-event tweens, layout properties, eased loops, an onComplete that
164
+ # never fires, shared plugin ids, unregistered plugins, dev tooling left in.
165
+ # Exit 1 on an error.
165
166
  node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
166
167
  node <skill-dir>/scripts/audit-gsap.mjs src --quiet # errors only
167
168
  node <skill-dir>/scripts/audit-gsap.mjs src --json # machine-readable
@@ -119,10 +119,15 @@ tl.addLabel("hold")
119
119
  ```
120
120
 
121
121
  Get this wrong and nothing visibly breaks: the ceiling still lifts the curtain,
122
- just late, on every visit. That is exactly why it is worth checking.
123
-
124
- **The page renders underneath the whole time.** The curtain only covers. A
125
- JavaScript failure leaves the visitor on the site, not behind a curtain.
122
+ just late, on every visit. That is exactly why it is worth checking, and why
123
+ `audit-gsap` reports it as an error, `never-completes`.
124
+
125
+ **The page renders underneath, and the curtain has a CSS exit.** The curtain
126
+ only covers. But it is in the server-rendered HTML, so a visitor whose
127
+ JavaScript never runs would get the curtain and nothing able to remove it — the
128
+ ceiling is JavaScript too. A CSS animation that hides it a little after the
129
+ ceiling is the exit that needs no script; see
130
+ [cinematic.md](../preset/cinematic.md#without-javascript).
126
131
 
127
132
  **Session gating in the layout effect, behind storage's real failures.**
128
133
  Storage cannot be read during render without the server and client
@@ -108,7 +108,39 @@ return () => window.clearTimeout(ceiling);
108
108
 
109
109
  Keep the total under ~3s. Play it once per session, not once per route — an
110
110
  intro on every navigation is an obstacle. Render the page underneath the whole
111
- time so a JavaScript failure cannot trap the visitor behind a curtain.
111
+ time, so the curtain only ever covers.
112
+
113
+ ## Without JavaScript
114
+
115
+ Rendering the page underneath is necessary, and not enough. A curtain rendered
116
+ on the server — which a client component is, too — is in the HTML, so a visitor
117
+ whose JavaScript never runs gets the curtain and nothing able to remove it: a
118
+ blocked script, a chunk that fails to load after a deploy, a hydration error.
119
+ The ceiling above is JavaScript as well.
120
+
121
+ Give the curtain an exit that needs no script — a CSS animation that hides it a
122
+ little after the JavaScript ceiling:
123
+
124
+ ```css
125
+ @keyframes curtain-exit {
126
+ to { opacity: 0; visibility: hidden; }
127
+ }
128
+
129
+ [data-curtain] {
130
+ animation: curtain-exit 0.3s ease-out 5s forwards;
131
+ }
132
+ ```
133
+
134
+ - **It outranks GSAP.** Animations beat inline styles in the cascade, so once it
135
+ has run, an `autoAlpha` tween cannot bring the curtain back — verified in
136
+ Chrome. On the normal path the curtain is gone long before the delay ends.
137
+ - **The delay counts from the HTML, not from hydration.** Set it beyond the
138
+ JavaScript ceiling plus a slow hydration. At worst, a page that took that
139
+ long to become interactive plays its intro under an invisible curtain.
140
+ - **`visibility: hidden` is the part that matters.** It removes the curtain from
141
+ hit-testing, so the page is clickable, not just visible.
142
+ - A `<noscript>` style only covers JavaScript that is switched off, not
143
+ JavaScript that loaded and failed. Use the animation.
112
144
 
113
145
  ## Reduced motion
114
146
 
@@ -116,10 +116,13 @@ return () => window.clearTimeout(ceiling);
116
116
 
117
117
  A timeline containing an infinitely repeating child **never completes**, so its
118
118
  `onComplete` never fires. Give the timeline a finite beat of its own to end on,
119
- or drive the handover from elsewhere.
119
+ or drive the handover from elsewhere. `audit-gsap` reports it as
120
+ `never-completes`.
120
121
 
121
- Render the real page underneath the whole time, so a visitor whose JavaScript
122
- fails gets the site rather than a permanent curtain.
122
+ Render the real page underneath the whole time. That alone does not save a
123
+ visitor whose JavaScript never runs: a server-rendered curtain is then all they
124
+ get, and the ceiling is JavaScript too. Give the curtain a CSS exit as well —
125
+ see [cinematic.md](cinematic.md#without-javascript).
123
126
 
124
127
  ## Exit
125
128
 
@@ -69,6 +69,11 @@ rather than fighting over `transform`:
69
69
  transformOrigin: "50% 100%" }
70
70
  ```
71
71
 
72
+ **On SVG, the origin comes with the first transform.** One that arrives after
73
+ the element is already scaled, rotated or skewed — in a `fromTo`'s to-vars —
74
+ leaves it offset, silently. Put it in the from-vars; see
75
+ [svg.md](svg.md#transform-origin).
76
+
72
77
  `xPercent`/`yPercent` are percentages of the element's own size, which is what
73
78
  centres something without knowing its dimensions.
74
79
 
@@ -117,9 +122,6 @@ gsap.utils.selector(ref); // scoped selector function
117
122
  gsap.utils.shuffle(items);
118
123
  ```
119
124
 
120
- `mapRange` and `wrap` are the two that most often replace ten lines of
121
- hand-rolled maths in a scroll or marquee.
122
-
123
125
  ## `quickTo` and `quickSetter`
124
126
 
125
127
  For values driven by a high-frequency event — pointer move, scroll, rAF —
@@ -228,6 +230,3 @@ question — see [motion-design.md](motion-design.md).
228
230
  CustomEase.create("brand", "M0,0 C0.16,1 0.3,1 1,1");
229
231
  gsap.to(el, { y: 0, ease: "brand" });
230
232
  ```
231
-
232
- `CustomWiggle` and `CustomBounce` register named eases the same way, for shake
233
- and impact without hand-built keyframes.
@@ -168,6 +168,38 @@ rendered. `ease: "none"` on anything looping.
168
168
  To author a path by eye, use `MotionPathHelper` in development only and bake the
169
169
  `d` it gives you. It is an editor — never ship it.
170
170
 
171
+ ## Transform origin
172
+
173
+ Set `transformOrigin` — or `svgOrigin` — no later than the first scale, rotation
174
+ or skew. When the origin of an SVG element that is already transformed changes,
175
+ GSAP adds a translate that holds the element where it is (`smoothOrigin`), and
176
+ that translate stays after the tween.
177
+
178
+ A `fromTo` renders its from-vars first, so this is the shape that breaks:
179
+
180
+ ```ts
181
+ // ✗ ends a whole radius up and left of where it should be
182
+ gsap.fromTo(dot, { scale: 0 }, { scale: 1, transformOrigin: "50% 50%" });
183
+
184
+ // ✓
185
+ gsap.fromTo(dot, { scale: 0, transformOrigin: "50% 50%" }, { scale: 1 });
186
+ ```
187
+
188
+ Measured in gsap 3.15 in Chrome, with the origin only in the to-vars:
189
+
190
+ | From-vars | The element ends |
191
+ |---|---|
192
+ | `scale: 0` | offset by the origin's distance from its own top-left corner |
193
+ | `scale: 0.94` | offset by that distance × 0.06 — a few pixels, easy to miss |
194
+ | `rotation: 90`, `skewX: 20` | offset too |
195
+ | `x: 20`, `scale: 1` | where it should — a translation or an identity start is safe |
196
+
197
+ A `gsap.set(el, { scale: 0 })` followed by a `to` that brings the origin does the
198
+ same. HTML elements are unaffected, and a mask or clip path makes no difference.
199
+ Any one of these fixes it: the origin in the from-vars, a `gsap.set` of the
200
+ origin before the tween, or `smoothOrigin: false`. `audit-gsap` reports the
201
+ `fromTo` shape as `late-transform-origin`.
202
+
171
203
  ## Masks and clip paths
172
204
 
173
205
  Often better than either plugin, and cheaper. A mask reveal is a transform on a
@@ -184,8 +216,8 @@ rect inside a `<mask>`:
184
216
  gsap.from("[data-wipe]", { scaleX: 0, transformOrigin: "0% 50%", duration: 0.8 });
185
217
  ```
186
218
 
187
- Greyscale in a mask is an alpha stop, not a colour — the one literal this
188
- repo's colour rule allows.
219
+ Greyscale in a mask is an alpha stop, not a colour — the one literal a
220
+ token-only colour rule has no reason to refuse.
189
221
 
190
222
  ## Choosing
191
223
 
@@ -86,7 +86,8 @@ tl.from("[data-mark]", { ... }) // plays once
86
86
  `repeat: -1` on the timeline replays the entrance too. And a timeline containing
87
87
  an infinitely repeating child **never completes**, so its `onComplete` never
88
88
  fires — if something downstream waits on that, give the timeline a finite beat
89
- of its own to end on, or drive the handover from elsewhere.
89
+ of its own to end on, or drive the handover from elsewhere. `audit-gsap`
90
+ reports it as `never-completes`.
90
91
 
91
92
  ## Paused timelines as state
92
93
 
@@ -126,10 +126,11 @@ function listenerCalls(code, method) {
126
126
  }
127
127
 
128
128
  /**
129
- * The argument spans of the `.to(` / `.from(` / `.fromTo(` calls chained on
130
- * from `cursor`, following the chain through labels and other calls.
129
+ * The argument spans of the `.to(` / `.from(` / `.fromTo(` calls — or of
130
+ * whichever `methods` are asked for — chained on from `cursor`, following the
131
+ * chain through labels and other calls.
131
132
  */
132
- function chainedTweens(code, cursor) {
133
+ function chainedTweens(code, cursor, methods = ["to", "from", "fromTo"]) {
133
134
  const spans = [];
134
135
  for (;;) {
135
136
  const next = code.slice(cursor).match(/^\s*\.\s*([\w$]+)\s*\(/);
@@ -137,11 +138,83 @@ function chainedTweens(code, cursor) {
137
138
  const open = cursor + next[0].length - 1;
138
139
  const span = parenSpan(code, open);
139
140
  if (!span) return spans;
140
- if (["to", "from", "fromTo"].includes(next[1])) spans.push([open, span[1]]);
141
+ if (methods.includes(next[1])) spans.push([open, span[1]]);
141
142
  cursor = span[1] + 1;
142
143
  }
143
144
  }
144
145
 
146
+ /**
147
+ * What is added to the timeline created by the `gsap.timeline(` match `call`,
148
+ * whose arguments close at `close`: the argument spans of its children, and
149
+ * whether an `onComplete` is attached through `eventCallback`.
150
+ *
151
+ * Both are read from the chain on the call itself and from every chain on the
152
+ * variable it was kept in — including one that starts with a label, which is
153
+ * how a sequence usually opens. Those uses are read only up to the next
154
+ * declaration of the same name, so two functions that each keep a `tl` do not
155
+ * lend each other their children.
156
+ */
157
+ function timelineCalls(code, call, close) {
158
+ const CHILDREN = ["to", "from", "fromTo", "add"];
159
+ const children = chainedTweens(code, close + 1, CHILDREN);
160
+ const callbacks = chainedTweens(code, close + 1, ["eventCallback"]);
161
+
162
+ const kept = code
163
+ .slice(Math.max(0, call.index - 80), call.index)
164
+ .match(/([\w$]+)\s*=\s*$/);
165
+ if (kept) {
166
+ const name = kept[1].replace(/\$/g, "\\$");
167
+ const redeclared = code
168
+ .slice(close)
169
+ .search(new RegExp(`\\b(?:const|let|var)\\s+${name}\\b`));
170
+ const end = redeclared === -1 ? code.length : close + redeclared;
171
+ const uses = new RegExp(`(?<![\\w$.])${name}(?=\\s*\\.\\s*[\\w$]+\\s*\\()`, "g");
172
+
173
+ for (const use of code.slice(close, end).matchAll(uses)) {
174
+ const cursor = close + use.index + use[0].length;
175
+ children.push(...chainedTweens(code, cursor, CHILDREN));
176
+ callbacks.push(...chainedTweens(code, cursor, ["eventCallback"]));
177
+ }
178
+ }
179
+
180
+ const completes = callbacks.some(([open, end]) =>
181
+ /^\(\s*["'`]onComplete["'`]/.test(code.slice(open, end)),
182
+ );
183
+ return { children, completes };
184
+ }
185
+
186
+ /**
187
+ * The top-level arguments of the call whose parentheses span `[open, close]`,
188
+ * as `[start, end]` pairs. A comma inside brackets, braces or a string does not
189
+ * separate arguments.
190
+ */
191
+ function callArgs(code, open, close) {
192
+ const args = [];
193
+ let depth = 0;
194
+ let quote = "";
195
+ let start = open + 1;
196
+
197
+ for (let i = open + 1; i < close; i += 1) {
198
+ const c = code[i];
199
+
200
+ if (quote) {
201
+ if (c === "\\") i += 1;
202
+ else if (c === quote) quote = "";
203
+ continue;
204
+ }
205
+ if (c === '"' || c === "'" || c === "`") quote = c;
206
+ else if ("([{".includes(c)) depth += 1;
207
+ else if (")]}".includes(c)) depth -= 1;
208
+ else if (c === "," && depth === 0) {
209
+ args.push([start, i]);
210
+ start = i + 1;
211
+ }
212
+ }
213
+
214
+ if (code.slice(start, close).trim()) args.push([start, close]);
215
+ return args;
216
+ }
217
+
145
218
  export const RULES = [
146
219
  // --- Lifecycle ------------------------------------------------------------
147
220
  {
@@ -431,6 +504,125 @@ export const RULES = [
431
504
  return findings.slice(0, 1);
432
505
  },
433
506
  },
507
+ /**
508
+ * An `onComplete` that can never run. A `repeat: -1` tween never completes,
509
+ * and a timeline holding one is given an effectively infinite duration —
510
+ * 1e10 seconds, measured in gsap 3.15, where neither callback fired — so its
511
+ * `onComplete` never fires either, and a short tween added after the loop
512
+ * does not change that.
513
+ *
514
+ * Nothing reports it. Whatever the callback was meant to start simply does not
515
+ * start: behind a ceiling timer the visitor waits for the ceiling on every
516
+ * visit, and without one the curtain never lifts. The guidance described this
517
+ * for a whole release and it still shipped, which is why it is a rule.
518
+ */
519
+ {
520
+ id: "never-completes",
521
+ level: "error",
522
+ test(file) {
523
+ /** `-1` exactly: the boundary after it rejects `-10`. */
524
+ const endless = /\brepeat\s*:\s*-1\b/;
525
+ const findings = [];
526
+
527
+ for (const call of find(
528
+ file.code,
529
+ /\bgsap\s*\.\s*(to|from|fromTo|timeline)\s*\(/g,
530
+ )) {
531
+ const open = call.index + call[0].length - 1;
532
+ const span = parenSpan(file.code, open);
533
+ if (!span) continue;
534
+ const args = file.code.slice(open, span[1]);
535
+ const waits = /\bonComplete\b/.test(args);
536
+
537
+ const own = args.search(endless);
538
+ if (waits && own !== -1) {
539
+ findings.push({
540
+ index: open + own,
541
+ message: `\`gsap.${call[1]}\` repeats forever, so its \`onComplete\` can never run.`,
542
+ hint: "An endless repeat never completes. Use `onRepeat` for something that should happen every cycle, or give it a finite `repeat`.",
543
+ });
544
+ continue;
545
+ }
546
+ if (call[1] !== "timeline") continue;
547
+
548
+ const { children, completes } = timelineCalls(file.code, call, span[1]);
549
+ if (!waits && !completes) continue;
550
+
551
+ for (const [start, end] of children) {
552
+ const at = file.code.slice(start, end).search(endless);
553
+ if (at === -1) continue;
554
+ findings.push({
555
+ index: start + at,
556
+ message:
557
+ "An endlessly repeating child in a timeline with an `onComplete`, which therefore never fires.",
558
+ hint: 'The child gives the timeline an effectively infinite duration, and a tween added after it does not end it. Hand over at a position instead — `.call(fn, [], "label+=0.5")` fires when the playhead passes — or run the loop as its own tween outside the timeline.',
559
+ });
560
+ break;
561
+ }
562
+ }
563
+
564
+ return findings;
565
+ },
566
+ },
567
+ /**
568
+ * A transform origin that arrives after the transform it belongs to.
569
+ *
570
+ * When the origin of an SVG element that is already scaled, rotated or
571
+ * skewed changes, GSAP's `smoothOrigin` adds a translate that holds the
572
+ * element where it is. A `fromTo` renders its from-vars first, so an origin
573
+ * given only in its to-vars lands on an element already at its starting
574
+ * transform, and the translate outlives the tween. Measured in gsap 3.15 in
575
+ * Chrome: a circle grown from 0 about its centre ends a whole radius up and
576
+ * left; rotation and skew offset it too; a translation, an identity start or
577
+ * an HTML element does not.
578
+ *
579
+ * `warn`, not `error`: nothing here can tell an SVG target from an HTML one.
580
+ */
581
+ {
582
+ id: "late-transform-origin",
583
+ level: "warn",
584
+ test(file) {
585
+ const ORIGIN = /\b(?:transformOrigin|svgOrigin)\s*:/;
586
+ const TRANSFORM = /\b(scale[XY]?|rotation|rotate|skew[XY])\s*:\s*([^,}\n]+)/g;
587
+ /** Whether a from-value leaves the element untransformed. */
588
+ const identity = (prop, value) => {
589
+ const v = value.trim().replace(/^["'`]|["'`]$/g, "");
590
+ return prop.startsWith("scale")
591
+ ? /^1(?:\.0+)?$/.test(v)
592
+ : /^-?0(?:\.0+)?(?:deg|rad)?$/.test(v);
593
+ };
594
+
595
+ return find(file.code, /\.\s*fromTo\s*\(/g).flatMap((call) => {
596
+ const open = call.index + call[0].length - 1;
597
+ const span = parenSpan(file.code, open);
598
+ if (!span) return [];
599
+ const [, from, to] = callArgs(file.code, open, span[1]);
600
+ if (!from || !to) return [];
601
+
602
+ const fromVars = file.code.slice(from[0], from[1]);
603
+ const toVars = file.code.slice(to[0], to[1]);
604
+ const origin = toVars.search(ORIGIN);
605
+ if (origin === -1 || ORIGIN.test(fromVars)) return [];
606
+ if (/\bsmoothOrigin\s*:\s*false\b/.test(toVars)) return [];
607
+ if (
608
+ [...fromVars.matchAll(TRANSFORM)].every(([, prop, value]) =>
609
+ identity(prop, value),
610
+ )
611
+ ) {
612
+ return [];
613
+ }
614
+
615
+ return [
616
+ {
617
+ index: to[0] + origin,
618
+ message:
619
+ "The transform origin is only in this `fromTo`'s to-vars, while its from-vars scale, rotate or skew.",
620
+ hint: "On an SVG element GSAP holds the element in place when its origin changes, and that correction outlives the tween: it ends offset, silently — a circle grown from 0 about its centre lands a whole radius off. Put `transformOrigin` or `svgOrigin` in the from-vars, or set it before the tween. HTML elements are unaffected.",
621
+ },
622
+ ];
623
+ });
624
+ },
625
+ },
434
626
  {
435
627
  id: "shared-plugin-id",
436
628
  level: "error",