@mehshekari/gsap-motion 3.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
28
28
  build without warning:
29
29
 
30
30
  ```json
31
- "lint": "eslint && npx @mehshekari/gsap-motion@3.1.0 audit src --quiet"
31
+ "lint": "eslint && npx @mehshekari/gsap-motion@3.1.1 audit src --quiet"
32
32
  ```
33
33
 
34
34
  Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
@@ -38,6 +38,9 @@ The command it adds is `gsap-motion`:
38
38
  "lint": "eslint && gsap-motion audit src --quiet"
39
39
  ```
40
40
 
41
+ The same rules also run inside ESLint, so findings appear in your editor:
42
+ [`@mehshekari/eslint-plugin-gsap-motion`](https://www.npmjs.com/package/@mehshekari/eslint-plugin-gsap-motion).
43
+
41
44
  ## Claude Code plugin
42
45
 
43
46
  The same skill is also a Claude Code plugin:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/gsap-motion",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Install the gsap-creative-animation Agent Skill, and audit GSAP animation for leaks, jank and accessibility failures — with npx.",
5
5
  "keywords": [
6
6
  "gsap",
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
4
4
  license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
5
5
  compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 18+ and nothing else.
6
6
  metadata:
7
- version: "3.1.0"
7
+ version: "3.1.1"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -20,6 +20,10 @@
20
20
  * `not-parsed`, never passed as clean. A wrong finding is a bug in this script;
21
21
  * report it rather than working around it.
22
22
  *
23
+ * What happens to each file — the rules, waivers, `not-parsed` — is in
24
+ * lib/audit.mjs, which the ESLint plugin runs too. This file finds the files,
25
+ * collects what they register between them, and prints.
26
+ *
23
27
  * Usage — paths resolve from the current directory, so run it from the project
24
28
  * root, wherever the skill itself is installed:
25
29
  * node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
@@ -31,8 +35,9 @@
31
35
  import { existsSync } from "node:fs";
32
36
  import { resolve } from "node:path";
33
37
 
38
+ import { check, mentionsGsap, notParsed } from "./lib/audit.mjs";
34
39
  import { pluginRegistrations, RULES } from "./lib/rules.mjs";
35
- import { collect, lineAt, load } from "./lib/source.mjs";
40
+ import { collect, load } from "./lib/source.mjs";
36
41
 
37
42
  /**
38
43
  * The project being audited is wherever this is run from, not wherever the
@@ -57,62 +62,29 @@ for (const path of paths) {
57
62
  }
58
63
  }
59
64
 
60
- // --- Escape hatch ------------------------------------------------------------
61
-
62
- /**
63
- * The same shape many repos already use for their own lint waivers, on
64
- * purpose: one way to say "yes, and here is why", not two.
65
- *
66
- * A hatch is the rule's own id plus `-ok`, on the flagged line or in the eight
67
- * above it, and it waives that rule and nothing else — silencing a shared id
68
- * cannot also silence a layout property on the same line. The lookback is eight
69
- * because a reason usually stands above a whole declaration rather than beside
70
- * one line of it.
71
- *
72
- * Every hatch must carry its reason. A bare token with no sentence after it is
73
- * a rule someone turned off rather than answered, and once this gates a build
74
- * that distinction has to stay visible in review.
75
- */
76
- const LOOKBACK = 8;
77
-
78
- function waived(source, line, ruleId) {
79
- const lines = source.split("\n");
80
- const from = Math.max(0, line - 1 - LOOKBACK);
81
- return lines.slice(from, line).join("\n").includes(`${ruleId}-ok`);
82
- }
83
-
84
65
  // --- Run ---------------------------------------------------------------------
85
66
 
86
67
  const findings = [];
87
68
  const sources = [];
88
69
 
70
+ /** A finding as the report shows it: under the file's display path, no column. */
71
+ const reported = (source, finding) => ({
72
+ rule: finding.rule,
73
+ level: finding.level,
74
+ file: source.display,
75
+ line: finding.line,
76
+ message: finding.message,
77
+ hint: finding.hint,
78
+ });
79
+
89
80
  for (const path of paths) {
90
81
  for (const file of collect(path)) {
91
82
  const source = load(file, ROOT);
83
+ if (!mentionsGsap(source)) continue;
92
84
 
93
- /**
94
- * A file that never mentions GSAP has nothing these rules can say. Checked
95
- * once here rather than in every rule.
96
- */
97
- if (!source.usesGsap && !/\bgsap\./.test(source.code)) continue;
98
-
99
- /**
100
- * What was not checked is reported as not checked. A file that uses GSAP
101
- * and does not parse gets no rule's verdict — a silent skip would read as
102
- * clean.
103
- */
104
85
  if (source.parseError) {
105
- const { line, message } = source.parseError;
106
- if (!waived(source.raw, line, "not-parsed")) {
107
- findings.push({
108
- rule: "not-parsed",
109
- level: "info",
110
- file: source.display,
111
- line,
112
- message: `Not checked: the file could not be parsed (${message}).`,
113
- hint: "No rule ran on this file. If it is valid JavaScript or TypeScript, the audit's parser is wrong — report it with this line.",
114
- });
115
- }
86
+ const finding = notParsed(source);
87
+ if (finding) findings.push(reported(source, finding));
116
88
  continue;
117
89
  }
118
90
 
@@ -132,18 +104,8 @@ for (const source of sources) {
132
104
  source.registeredElsewhere = registered;
133
105
 
134
106
  for (const rule of RULES) {
135
- for (const hit of rule.test(source)) {
136
- const line = lineAt(source.raw, hit.index);
137
- if (waived(source.raw, line, rule.id)) continue;
138
-
139
- findings.push({
140
- rule: rule.id,
141
- level: rule.level,
142
- file: source.display,
143
- line,
144
- message: hit.message,
145
- hint: hit.hint,
146
- });
107
+ for (const finding of check(rule, source)) {
108
+ findings.push(reported(source, finding));
147
109
  }
148
110
  }
149
111
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * One file through the audit, shared by the command line and the ESLint plugin.
3
+ *
4
+ * The runner decides which files to read and how to print them; ESLint decides
5
+ * both for the plugin. What happens to a file in between — whether the rules
6
+ * look at it at all, what a file that does not parse reports, where a finding
7
+ * lands and whether a waiver answers it — is decided here, once, so the two
8
+ * entry points cannot disagree.
9
+ */
10
+ import { lineAt } from "./source.mjs";
11
+
12
+ /**
13
+ * A file that never mentions GSAP has nothing these rules can say. Checked once
14
+ * per file rather than in every rule, and before anything is parsed.
15
+ */
16
+ export function mentionsGsap(source) {
17
+ return source.usesGsap || /\bgsap\./.test(source.code);
18
+ }
19
+
20
+ // --- Escape hatch ------------------------------------------------------------
21
+
22
+ /**
23
+ * The same shape many repos already use for their own lint waivers, on
24
+ * purpose: one way to say "yes, and here is why", not two.
25
+ *
26
+ * A hatch is the rule's own id plus `-ok`, on the flagged line or in the eight
27
+ * above it, and it waives that rule and nothing else — silencing a shared id
28
+ * cannot also silence a layout property on the same line. The lookback is eight
29
+ * because a reason usually stands above a whole declaration rather than beside
30
+ * one line of it.
31
+ *
32
+ * Every hatch must carry its reason. A bare token with no sentence after it is
33
+ * a rule someone turned off rather than answered, and once this gates a build
34
+ * that distinction has to stay visible in review.
35
+ */
36
+ const LOOKBACK = 8;
37
+
38
+ export function waived(raw, line, ruleId) {
39
+ const lines = raw.split("\n");
40
+ const from = Math.max(0, line - 1 - LOOKBACK);
41
+ return lines.slice(from, line).join("\n").includes(`${ruleId}-ok`);
42
+ }
43
+
44
+ // --- Findings ----------------------------------------------------------------
45
+
46
+ /** 0-indexed column of a character offset, as ESLint counts columns. */
47
+ function columnAt(raw, index) {
48
+ return Math.max(0, index - (raw.lastIndexOf("\n", index - 1) + 1));
49
+ }
50
+
51
+ export const NOT_PARSED = "not-parsed";
52
+
53
+ /**
54
+ * What was not checked is reported as not checked. A file that uses GSAP and
55
+ * does not parse gets no rule's verdict — a silent skip would read as clean.
56
+ *
57
+ * Returns null for a file that parsed, and for one whose waiver answers it.
58
+ */
59
+ export function notParsed(source) {
60
+ const error = source.parseError;
61
+ if (!error || waived(source.raw, error.line, NOT_PARSED)) return null;
62
+ return {
63
+ rule: NOT_PARSED,
64
+ level: "info",
65
+ line: error.line,
66
+ column: error.column,
67
+ message: `Not checked: the file could not be parsed (${error.message}).`,
68
+ hint: "No rule ran on this file. If it is valid JavaScript or TypeScript, the audit's parser is wrong — report it with this line.",
69
+ };
70
+ }
71
+
72
+ /**
73
+ * What one rule reports on one parsed file: each finding placed on its line and
74
+ * column, and dropped when a waiver answers it.
75
+ */
76
+ export function check(rule, source) {
77
+ const findings = [];
78
+ for (const hit of rule.test(source)) {
79
+ const line = lineAt(source.raw, hit.index);
80
+ if (waived(source.raw, line, rule.id)) continue;
81
+ findings.push({
82
+ rule: rule.id,
83
+ level: rule.level,
84
+ line,
85
+ column: columnAt(source.raw, hit.index),
86
+ message: hit.message,
87
+ hint: hit.hint,
88
+ });
89
+ }
90
+ return findings;
91
+ }
@@ -498,6 +498,8 @@ export const RULES = [
498
498
  {
499
499
  id: "orphan-tween",
500
500
  level: "error",
501
+ description:
502
+ "A tween created outside `useGSAP`, `gsap.context` or `contextSafe` in React — never reverted, and doubled by StrictMode",
501
503
  /**
502
504
  * A GSAP tween call inside a function but outside every context — module
503
505
  * scope is not the target. Contexts are recognised under the names the file
@@ -537,6 +539,8 @@ export const RULES = [
537
539
  {
538
540
  id: "unmanaged-instance",
539
541
  level: "error",
542
+ description:
543
+ "`matchMedia`, `Observer`, `Draggable`, `ScrollSmoother` or `SplitText` created outside a context and never torn down",
540
544
  /**
541
545
  * Torn down as `tornDown` reads it: kept under a name — through any chain
542
546
  * called on it, or a helper's return — that has `.revert()` or `.kill()`
@@ -574,6 +578,8 @@ export const RULES = [
574
578
  {
575
579
  id: "dangling-listener",
576
580
  level: "warn",
581
+ description:
582
+ "An event listener that is never removed, including inline handlers that cannot be",
577
583
  /**
578
584
  * Adds and removes are paired by event and by the handler's name as
579
585
  * written, across the file — not by counting calls. A handler that is not a
@@ -636,6 +642,8 @@ export const RULES = [
636
642
  {
637
643
  id: "tween-per-event",
638
644
  level: "error",
645
+ description:
646
+ "A new tween allocated on every pointer, scroll or wheel event",
639
647
  /**
640
648
  * A GSAP tween call inside a high-frequency handler, followed by name when
641
649
  * it can be. A debounced one, one in a completion callback, and one behind
@@ -660,6 +668,8 @@ export const RULES = [
660
668
  {
661
669
  id: "state-per-event",
662
670
  level: "error",
671
+ description:
672
+ "React state set in a high-frequency handler — a re-render per frame",
663
673
  /**
664
674
  * A bare `setX(…)` call — not a member call such as `el.style.setProperty`,
665
675
  * which is exactly what a pointer handler should do, and not a timer —
@@ -705,6 +715,8 @@ export const RULES = [
705
715
  {
706
716
  id: "layout-property",
707
717
  level: "warn",
718
+ description:
719
+ "Animating `width`, `height`, `top`, `left`, margins or padding, which forces layout every frame",
708
720
  /**
709
721
  * The layout keys of every vars object a tween carries — both of a
710
722
  * `fromTo`'s — on any receiver, so a timeline child counts. One finding per
@@ -747,6 +759,8 @@ export const RULES = [
747
759
  {
748
760
  id: "trigger-per-item",
749
761
  level: "warn",
762
+ description:
763
+ "A ScrollTrigger created per item in a loop",
750
764
  /**
751
765
  * A `scrollTrigger` property inside a loop body: the callback of `.forEach`
752
766
  * or `.map`, or a `for`, `for…of` or `for…in` statement. One finding per
@@ -788,6 +802,8 @@ export const RULES = [
788
802
  {
789
803
  id: "eased-loop",
790
804
  level: "warn",
805
+ description:
806
+ "An infinite repeat with an ease, which makes its own seam visible",
791
807
  /**
792
808
  * The tween's own vars — a `fromTo`'s to-vars — on any receiver, so a
793
809
  * timeline child counts. Keys at the top level only: a `stagger` object's
@@ -815,6 +831,8 @@ export const RULES = [
815
831
  {
816
832
  id: "eased-scrub",
817
833
  level: "warn",
834
+ description:
835
+ "Easing inside a scrubbed ScrollTrigger, which fights the scrollbar",
818
836
  /**
819
837
  * A GSAP call scrubbed anywhere in its arguments — `scrub: false` aside —
820
838
  * with a string ease other than `"none"` in those arguments or, for a
@@ -870,6 +888,8 @@ export const RULES = [
870
888
  {
871
889
  id: "never-completes",
872
890
  level: "error",
891
+ description:
892
+ "An `onComplete` that can never run: on a timeline holding a `repeat: -1` child, or on a tween or timeline that repeats forever",
873
893
  /**
874
894
  * `onComplete` and `repeat: -1` anywhere in a GSAP call's arguments; for a
875
895
  * timeline, its children and any `eventCallback("onComplete", …)` from
@@ -939,6 +959,8 @@ export const RULES = [
939
959
  {
940
960
  id: "late-transform-origin",
941
961
  level: "warn",
962
+ description:
963
+ "A `fromTo` whose origin is only in its to-vars while its from-vars scale, rotate or skew, which leaves an SVG element offset",
942
964
  /**
943
965
  * Any `.fromTo(target, fromVars, toVars)` whose two vars are object
944
966
  * literals. Keys are read at the top level of each, and a transform's
@@ -986,6 +1008,8 @@ export const RULES = [
986
1008
  {
987
1009
  id: "shared-plugin-id",
988
1010
  level: "error",
1011
+ description:
1012
+ "A hardcoded `#id` in MotionPath or MorphSVG config, which two instances of a component will share",
989
1013
  /**
990
1014
  * Any property naming a target geometry whose value is a fixed `#id`: a
991
1015
  * string or a template literal with nothing interpolated, including the
@@ -1014,6 +1038,8 @@ export const RULES = [
1014
1038
  {
1015
1039
  id: "unregistered-plugin",
1016
1040
  level: "error",
1041
+ description:
1042
+ "A plugin imported and never registered, whose properties are silently ignored",
1017
1043
  /**
1018
1044
  * Registered means named anywhere inside the arguments of a
1019
1045
  * `registerPlugin` call — not mentioned anywhere after one, and under its
@@ -1067,6 +1093,8 @@ export const RULES = [
1067
1093
  {
1068
1094
  id: "missing-reduced-motion",
1069
1095
  level: "warn",
1096
+ description:
1097
+ "An animating file with no `prefers-reduced-motion` branch",
1070
1098
  /**
1071
1099
  * A branch is the query written in code — a string, a template or JSX
1072
1100
  * text. A comment that mentions it is not a branch. A file that only sets
@@ -1101,6 +1129,8 @@ export const RULES = [
1101
1129
  {
1102
1130
  id: "dev-tool-shipped",
1103
1131
  level: "error",
1132
+ description:
1133
+ "`GSDevTools` or `MotionPathHelper` imported statically, or unconditional `markers: true`",
1104
1134
  /**
1105
1135
  * A static import declaration that brings in a dev tool, by a specifier's
1106
1136
  * name or, for a bare or default import, by its path. A type-only import
@@ -1149,6 +1179,8 @@ export const RULES = [
1149
1179
  {
1150
1180
  id: "barrel-import",
1151
1181
  level: "warn",
1182
+ description:
1183
+ "Importing from `gsap/all`, which pulls in every plugin",
1152
1184
  /** An import or re-export whose module is `gsap/all`. */
1153
1185
  test(file) {
1154
1186
  return file.ast.body
@@ -131,9 +131,14 @@ export function lineAt(source, index) {
131
131
  return line;
132
132
  }
133
133
 
134
- /** A file plus everything the rules need, read once. */
135
- export function load(path, root) {
136
- const raw = readFileSync(path, "utf8");
134
+ /**
135
+ * A file plus everything the rules need, from its text.
136
+ *
137
+ * The command line reads the text from disk, through `load`. The ESLint plugin
138
+ * passes the text ESLint already holds, with the file's path, so both entry
139
+ * points run the rules on the same parse of the same characters.
140
+ */
141
+ export function fromText(raw, path, display = path) {
137
142
  const code = stripComments(raw);
138
143
 
139
144
  /**
@@ -151,7 +156,11 @@ export function load(path, root) {
151
156
  parsed = {
152
157
  ast: null,
153
158
  comments: [],
154
- error: { message: error.message, line: error.loc?.line ?? 1 },
159
+ error: {
160
+ message: error.message,
161
+ line: error.loc?.line ?? 1,
162
+ column: error.loc?.column ?? 0,
163
+ },
155
164
  };
156
165
  }
157
166
  }
@@ -169,7 +178,7 @@ export function load(path, root) {
169
178
  return tree().error;
170
179
  },
171
180
  path,
172
- display: relative(root, path).replace(/\\/g, "/"),
181
+ display,
173
182
  raw,
174
183
  code,
175
184
  /**
@@ -187,3 +196,17 @@ export function load(path, root) {
187
196
  usesGsap: /\bfrom ["']gsap(?:\/|["'])/.test(code),
188
197
  };
189
198
  }
199
+
200
+ /** A file on disk, read once, shown by its path from `root`. */
201
+ export function load(path, root) {
202
+ return fromText(
203
+ readFileSync(path, "utf8"),
204
+ path,
205
+ relative(root, path).replace(/\\/g, "/"),
206
+ );
207
+ }
208
+
209
+ /** Whether `collect` reads files with this path's extension. */
210
+ export function hasSourceExtension(path) {
211
+ return SOURCE_EXTENSIONS.has(extname(path));
212
+ }