@mehshekari/gsap-motion 4.5.0 → 4.6.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
@@ -32,7 +32,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
32
32
  build without warning:
33
33
 
34
34
  ```json
35
- "lint": "eslint && npx @mehshekari/gsap-motion@4.5.0 audit src --quiet"
35
+ "lint": "eslint && npx @mehshekari/gsap-motion@4.6.1 audit src --quiet"
36
36
  ```
37
37
 
38
38
  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": "4.5.0",
3
+ "version": "4.6.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 22+ and nothing else.
6
6
  metadata:
7
- version: "4.5.0"
7
+ version: "4.6.1"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -48,9 +48,9 @@ Core principles:
48
48
  [motion-design.md](reference/motion-design.md) and the command's own
49
49
  reference. Nothing else.
50
50
  - **A component or section:** also this project's adapter, from
51
- `package.json`: `next` → [react](adapter/react.md) +
52
- [next](adapter/next.md); `@react-three/fiber` → react +
53
- [r3f](adapter/r3f.md); `three` → [three](adapter/three.md); `nuxt` →
51
+ `package.json`: `react` → [react](adapter/react.md), plus
52
+ [next](adapter/next.md) with `next` or [r3f](adapter/r3f.md) with
53
+ `@react-three/fiber`; `three` → [three](adapter/three.md); `nuxt` →
54
54
  [vue](adapter/vue.md); `svelte` → [svelte](adapter/svelte.md); `astro` →
55
55
  [astro](adapter/astro.md) plus any island's own; nothing detected →
56
56
  [vanilla](adapter/vanilla.md).
@@ -129,9 +129,9 @@ that no source rule can reach.
129
129
  | It says | Read it as |
130
130
  | --- | --- |
131
131
  | **first motion at N ms** | How long the page is still. Late enough and the visitor has scrolled past the thing you built. |
132
- | **N elements moved, M of them GSAP's** | The gap is motion the audit never sees. A surprise there is worth chasing. |
132
+ | **N elements moved, M of them GSAP's** | The rest is split into CSS or Web Animations, and other script writing styles: motion the audit never sees. `--json` lists each element with its kind and box, so a surprise can be found. |
133
133
  | **X% of the screen in motion** | Everything moving at once reads as noise, not as choreography. |
134
- | **seams** | A step far larger than that motion's own rhythm: a loop put back by hand instead of carried. |
134
+ | **seams** | A step far larger than that motion's own rhythm, after which the motion carries on: a loop put back by hand instead of carried. A one-time jump into place is not one. |
135
135
  | **motion over text** | Something painted over a line a reader is in the middle of. Hit-tested, so it is what is really on top. |
136
136
  | **frames over 50ms** | Long Animation Frames, with the sampler's own time taken out of each: the rest is the page's. "Not measured" means the browser has no such API, not that nothing was long. |
137
137
  | **layout shift** | The browser's own shift total, input excluded. Sampling reads layout but never moves it, so this is the page's alone — a pin swapping its position at a section edge shows here. |
@@ -32,8 +32,8 @@
32
32
  *
33
33
  * Exit code is 1 when any error-level finding is reported, 0 otherwise.
34
34
  */
35
- import { existsSync, statSync } from "node:fs";
36
- import { resolve } from "node:path";
35
+ import { existsSync, readFileSync, statSync } from "node:fs";
36
+ import { dirname, join, resolve } from "node:path";
37
37
 
38
38
  import { check, mentionsGsap, notParsed } from "./lib/audit.mjs";
39
39
  import { pluginRegistrations, RULES } from "./lib/rules.mjs";
@@ -62,10 +62,60 @@ for (const path of paths) {
62
62
  }
63
63
  }
64
64
 
65
+ /**
66
+ * `compilerOptions.paths` from the project's tsconfig.json, as
67
+ * `[prefix, [target prefixes]]`: `"@/*": ["./src/*"]` becomes
68
+ * `["@/", ["./src/"]]`, against `baseUrl` when one is set. A tsconfig is JSON
69
+ * with comments and trailing commas, so those are stripped first; one that
70
+ * still does not parse gives no aliases rather than an error.
71
+ */
72
+ function tsconfigPaths(root) {
73
+ try {
74
+ const text = withoutComments(readFileSync(join(root, "tsconfig.json"), "utf8")).replace(/,(\s*[}\]])/g, "$1");
75
+ const { compilerOptions = {} } = JSON.parse(text);
76
+ const base = compilerOptions.baseUrl ?? ".";
77
+ return Object.entries(compilerOptions.paths ?? {}).map(([key, targets]) => [
78
+ key.replace(/\*$/, ""),
79
+ targets.map((target) => join(base, target.replace(/\*$/, ""))),
80
+ ]);
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * JSON with its comments removed, outside strings only. A regex over the whole
88
+ * text took the `/*` in `"**\/*.ts"` — in every Next tsconfig's `include` — for
89
+ * the start of a comment, broke the JSON, and lost every alias.
90
+ */
91
+ function withoutComments(text) {
92
+ let out = "";
93
+ for (let i = 0; i < text.length; i += 1) {
94
+ const char = text[i];
95
+ if (char === '"') {
96
+ const end = text.slice(i + 1).search(/(?<!\\)"/);
97
+ const close = end === -1 ? text.length : i + 1 + end;
98
+ out += text.slice(i, close + 1);
99
+ i = close;
100
+ } else if (char === "/" && text[i + 1] === "/") {
101
+ while (i < text.length && text[i] !== "\n") i += 1;
102
+ out += "\n";
103
+ } else if (char === "/" && text[i + 1] === "*") {
104
+ const end = text.indexOf("*/", i + 2);
105
+ i = end === -1 ? text.length : end + 1;
106
+ } else {
107
+ out += char;
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
65
113
  // --- Run ---------------------------------------------------------------------
66
114
 
67
115
  const findings = [];
68
116
  const sources = [];
117
+ /** Every file read, GSAP or not: the import graph needs callers that never animate. */
118
+ const loaded = [];
69
119
  /**
70
120
  * A file named on the command line that the audit cannot read — a stylesheet,
71
121
  * say. It used to be skipped in silence and the run printed "clean", which
@@ -90,6 +140,7 @@ for (const path of paths) {
90
140
  }
91
141
  for (const file of collect(path)) {
92
142
  const source = load(file, ROOT);
143
+ loaded.push(source);
93
144
  if (!mentionsGsap(source)) continue;
94
145
 
95
146
  if (source.parseError) {
@@ -110,6 +161,50 @@ for (const path of paths) {
110
161
  */
111
162
  const registered = new Set(sources.flatMap((source) => [...pluginRegistrations(source)]));
112
163
 
164
+ /**
165
+ * Who imports each file, among everything read — a caller need not use GSAP
166
+ * itself. Relative imports, and aliases the project's tsconfig maps in
167
+ * `compilerOptions.paths` (`"@/*": ["./src/*"]`).
168
+ *
169
+ * An alias that cannot be resolved may still name the file, and a verdict that
170
+ * rests on some of a file's callers is a guess. A real project imported one
171
+ * helper relatively from one caller and through `@/` from another; only the
172
+ * first was seen. So when an unresolved alias ends in a file's name, that
173
+ * file's importers are marked unknown, and it is judged alone.
174
+ */
175
+ const byPath = new Map(loaded.map((source) => [resolve(source.path), source]));
176
+ const EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", "/index.ts", "/index.tsx", "/index.js"];
177
+ const aliases = tsconfigPaths(ROOT);
178
+ const resolveSpecifier = (from, specifier) => {
179
+ const bases = specifier.startsWith(".")
180
+ ? [resolve(dirname(from), specifier)]
181
+ : aliases.flatMap(([prefix, targets]) =>
182
+ specifier.startsWith(prefix) ? targets.map((target) => resolve(ROOT, target + specifier.slice(prefix.length))) : [],
183
+ );
184
+ for (const base of bases) {
185
+ const target = EXTENSIONS.map((ext) => byPath.get(base + ext)).find(Boolean);
186
+ if (target) return target;
187
+ }
188
+ return null;
189
+ };
190
+ /** A bare package name — `gsap`, `react`, `@scope/pkg` — is not an alias. */
191
+ const looksLikeAlias = (specifier) => /^(?:@\/|~\/|#)/.test(specifier) || aliases.some(([prefix]) => specifier.startsWith(prefix));
192
+ const stem = (path) => path.replace(/\\/g, "/").split("/").at(-1).replace(/\.[^.]+$/, "");
193
+
194
+ for (const source of loaded) source.importers = [];
195
+ for (const source of loaded) {
196
+ for (const [, specifier] of source.code.matchAll(/\bimport\s+(?!type\b)[^;]*?\bfrom\s*["']([^"']+)["']/g)) {
197
+ if (!specifier.startsWith(".") && !looksLikeAlias(specifier)) continue;
198
+ const target = resolveSpecifier(source.path, specifier);
199
+ if (target && target !== source) {
200
+ target.importers.push(source);
201
+ } else if (!target && looksLikeAlias(specifier)) {
202
+ const name = specifier.split("/").at(-1);
203
+ for (const candidate of loaded) if (stem(candidate.path) === name) candidate.importersUnknown = true;
204
+ }
205
+ }
206
+ }
207
+
113
208
  for (const source of sources) {
114
209
  source.registeredElsewhere = registered;
115
210
 
@@ -427,9 +427,18 @@ export const pointAt = (selector) => `(() => {
427
427
  box = el.getBoundingClientRect();
428
428
  }
429
429
  const clamp = (v, max) => Math.min(max - 1, Math.max(0, v));
430
+ /** Where the other matches a pointer could reach are, so the reader can narrow the selector. */
431
+ const others = all
432
+ .filter((other, i) => i !== index && shown(other))
433
+ .slice(0, 4)
434
+ .map((other) => {
435
+ const b = other.getBoundingClientRect();
436
+ return [Math.round(b.x + b.width / 2), Math.round(b.y + b.height / 2)];
437
+ });
430
438
  return {
431
439
  matches: all.length,
432
440
  index,
441
+ others,
433
442
  scrolled: !inView,
434
443
  x: clamp(box.x + box.width / 2, innerWidth),
435
444
  y: clamp(box.y + box.height / 2, innerHeight),
@@ -635,9 +644,13 @@ async function capture(options) {
635
644
  }
636
645
  }
637
646
  const which = box.matches > 1 ? ` (match ${box.index + 1} of ${box.matches})` : "";
647
+ const elsewhere = box.others?.length
648
+ ? `; other matches a pointer could reach at ${box.others.map((p) => p.join(",")).join(" · ")} — narrow the selector to pick one`
649
+ : "";
638
650
  notes.push(
639
651
  `${step.type}: ${step.value}${which} at ${Math.round(box.x)},${Math.round(box.y)}` +
640
- (box.scrolled ? ", scrolled into view first" : ""),
652
+ (box.scrolled ? ", scrolled into view first" : "") +
653
+ elsewhere,
641
654
  );
642
655
  await wait(120);
643
656
  }
@@ -602,10 +602,56 @@ export function gsapContexts(root) {
602
602
  functions.set(name, node.type === "FunctionDeclaration" ? node : unwrap(node.init));
603
603
  }
604
604
 
605
+ /**
606
+ * Whether a function nested in a context body runs while the body runs, and
607
+ * so inside the context. GSAP sets the context only for the duration of the
608
+ * body (Context.add, gsap-core 3.15): a function the body hands to a listener,
609
+ * a timer, a promise or a ref runs later, outside it, and nothing it creates
610
+ * is reverted. A real project built a tween in `pulse.current = () => …`
611
+ * inside useGSAP and called it from pointermove; the audit read it as inside.
612
+ *
613
+ * In place: called right there, an array method's callback, GSAP's own
614
+ * callbacks (`on…` keys, `.add`, `.call`, which GSAP runs in the context),
615
+ * the cleanup the body returns, or a named function only ever called.
616
+ */
617
+ const ITERATORS = new Set(["forEach", "map", "filter", "flatMap", "reduce", "reduceRight", "some", "every", "find", "findIndex", "sort"]);
618
+ const runsInPlace = (fn) => {
619
+ const parent = parentOf(root, fn);
620
+ if (!parent) return false;
621
+ if (parent.type === "CallExpression") {
622
+ if (unwrap(parent.callee) === fn) return true;
623
+ const name = methodName(parent) ?? bareName(parent);
624
+ return ITERATORS.has(name) || name === "add" || name === "call" || isHook(parent) || safe.has(name) || calleeName(parent) === "gsap.context";
625
+ }
626
+ if (parent.type === "Property") return /^on[A-Z]/.test(keyName(parent) ?? "");
627
+ if (parent.type === "ReturnStatement" || (parent.type === "ArrowFunctionExpression" && parent.body === fn)) return true;
628
+ const bound =
629
+ parent.type === "VariableDeclarator" && parent.id.type === "Identifier" && unwrap(parent.init) === fn
630
+ ? parent.id.name
631
+ : fn.type === "FunctionDeclaration"
632
+ ? fn.id?.name
633
+ : null;
634
+ if (!bound) return false;
635
+ const uses = findAll(root, (n) => n.type === "Identifier" && n.name === bound && isReference(root, n));
636
+ return uses.every((id) => {
637
+ const call = parentOf(root, id);
638
+ return call?.type === "CallExpression" && call.callee === id;
639
+ });
640
+ };
641
+ /** Inside `scope`, with no function between that runs later. */
642
+ const runsWithin = (scope, node) => {
643
+ if (!contains(scope, node)) return false;
644
+ for (const ancestor of ancestorsOf(root, node)) {
645
+ if (ancestor === scope) return true;
646
+ if (isFunction(ancestor) && !runsInPlace(ancestor)) return false;
647
+ }
648
+ return false;
649
+ };
650
+
605
651
  const contained = new Set();
606
652
  const inside = (node) =>
607
- scopes.some((argument) => contains(argument, node)) ||
608
- [...contained].some((fn) => contains(fn, node));
653
+ scopes.some((argument) => runsWithin(argument, node)) ||
654
+ [...contained].some((fn) => runsWithin(fn, node));
609
655
  const identifiers = findAll(root, (node) => node.type === "Identifier");
610
656
 
611
657
  for (let changed = true; changed; ) {
@@ -54,7 +54,7 @@ const HINT = {
54
54
  stackedFrom:
55
55
  "A `from` or `fromTo` applies its start state the moment it is built. Built more than once against the same element, the last one wins, and holds that element at its start state until the playhead reaches it — invisible, if the start state hides it. Pass `immediateRender: false`, and set the element's first state yourself with `gsap.set`.",
56
56
  orphanTween:
57
- "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. A helper counts as inside only when every use of it is a call from inside a context. Note that contextSafe keeps every tween it creates until the context reverts: a handler that runs often should replay a timeline built once, or call a quickTo.",
57
+ "Nothing reverts it on unmount, and React StrictMode may run it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. A helper counts as inside only when every use of it is a call from inside a context. Note that contextSafe keeps every tween it creates until the context reverts: a handler that runs often should replay a timeline built once, or call a quickTo.",
58
58
  unmanagedInstance:
59
59
  "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.",
60
60
  unrevertedContext:
@@ -250,7 +250,32 @@ function listenerMethod(node) {
250
250
  * `followNames`, a handler passed by name is followed to its declaration when
251
251
  * the file declares exactly one function of that name.
252
252
  */
253
- function handlersFor(ast, events, { followNames = true, perFrame = false } = {}) {
253
+ function handlersFor(ast, events, options = {}) {
254
+ const direct = directHandlers(ast, events, options);
255
+ /**
256
+ * One hop through a ref: a handler that calls `pulse.current()`, where the
257
+ * file assigns `pulse.current = () => { … }`. A real project kept a tween on
258
+ * a ref and called it from a pointermove listener, and the rule read the
259
+ * listener as clean.
260
+ */
261
+ const viaRef = direct.flatMap((h) =>
262
+ findAll(h.node, (n) => n.type === "CallExpression" && unwrap(n.callee)?.type === "MemberExpression")
263
+ .map((call) => dottedName(unwrap(call.callee)))
264
+ .filter((name) => name?.endsWith(".current"))
265
+ .flatMap((name) =>
266
+ findAll(
267
+ ast,
268
+ (n) =>
269
+ n.type === "AssignmentExpression" &&
270
+ dottedName(unwrap(n.left)) === name &&
271
+ isFunction(unwrap(n.right)),
272
+ ).map((n) => ({ name: h.name, node: unwrap(n.right) })),
273
+ ),
274
+ );
275
+ return [...direct, ...viaRef];
276
+ }
277
+
278
+ function directHandlers(ast, events, { followNames = true, perFrame = false } = {}) {
254
279
  const handler = (expression) => {
255
280
  const node = unwrap(expression);
256
281
  if (node?.type === "CallExpression") return node;
@@ -397,6 +422,28 @@ const readsRef = (node) =>
397
422
  n.property.name === "current",
398
423
  ).length > 0;
399
424
 
425
+ /**
426
+ * Whether a file holds a reduced-motion branch: the query written in code, or
427
+ * asked by name — `useReducedMotion()`, `prefersReducedMotion()`,
428
+ * `isReduced()`, `matchMedia(REDUCED_MOTION_QUERY)` — since it is often written
429
+ * once, in a shared module. "Reduced", not "reduce": an array's `.reduce()` is
430
+ * not one. A comment that mentions it is not a branch.
431
+ */
432
+ function hasReducedBranch(file) {
433
+ if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) return true;
434
+ return (
435
+ findAll(file.ast, (node) => {
436
+ if (node.type !== "CallExpression") return false;
437
+ const name = calleeName(node)?.split(".").at(-1) ?? "";
438
+ if (/reduced/i.test(name)) return true;
439
+ return (
440
+ name === "matchMedia" &&
441
+ node.arguments.some((arg) => arg.type === "Identifier" && /reduced/i.test(arg.name))
442
+ );
443
+ }).length > 0
444
+ );
445
+ }
446
+
400
447
  /** The calls that add a child to a timeline. */
401
448
  const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
402
449
 
@@ -1841,25 +1888,20 @@ export const RULES = [
1841
1888
  */
1842
1889
  test(file) {
1843
1890
  if (!file.usesGsap) return [];
1844
- if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
1845
- return [];
1846
- }
1891
+ if (hasReducedBranch(file)) return [];
1847
1892
  /**
1848
- * The query is often written once, in a shared module, and asked by name:
1849
- * `useReducedMotion()`, `prefersReducedMotion()`, `isReduced()`, or
1850
- * `matchMedia(REDUCED_MOTION_QUERY)`. That is a branch too. "Reduced",
1851
- * not "reduce", so an array's `.reduce()` is not one.
1893
+ * Every caller decides instead: each file that imports this one holds a
1894
+ * branch. Three projects reported a helper flagged while its only caller
1895
+ * returned early under reduced motion. Only the full audit knows the
1896
+ * importers; the editor and the write hook see one file, and report it.
1852
1897
  */
1853
- const asksByName = findAll(file.ast, (node) => {
1854
- if (node.type !== "CallExpression") return false;
1855
- const name = calleeName(node)?.split(".").at(-1) ?? "";
1856
- if (/reduced/i.test(name)) return true;
1857
- return (
1858
- name === "matchMedia" &&
1859
- node.arguments.some((arg) => arg.type === "Identifier" && /reduced/i.test(arg.name))
1860
- );
1861
- });
1862
- if (asksByName.length) return [];
1898
+ if (
1899
+ file.importers?.length &&
1900
+ !file.importersUnknown &&
1901
+ file.importers.every((importer) => !importer.parseError && hasReducedBranch(importer))
1902
+ ) {
1903
+ return [];
1904
+ }
1863
1905
 
1864
1906
  const [first] = findAll(file.ast, (node) => {
1865
1907
  const method = tweenMethod(node);
@@ -249,7 +249,14 @@ export function fromText(raw, path, display = path) {
249
249
  */
250
250
  usesGsap:
251
251
  /\bfrom ["']gsap(?:\/|["'])/.test(code) ||
252
- /\bimport\s+(?!type\b)(?:gsap\b|\{[^}]*\bgsap\b[^}]*\})[^;]*?\bfrom\s*["']/.test(code),
252
+ /\bimport\s+(?!type\b)(?:gsap\b|\{[^}]*\bgsap\b[^}]*\})[^;]*?\bfrom\s*["']/.test(code) ||
253
+ /**
254
+ * Loaded on demand: `loadGsap().then(({ gsap }) => …)` or
255
+ * `const { gsap } = await import(…)`. A real project's brand loader did
256
+ * this, and was the one file in eleven the lifecycle rules skipped.
257
+ */
258
+ /\(\s*\{[^}]*\bgsap\b[^}]*\}\s*\)\s*=>/.test(code) ||
259
+ /\{[^}]*\bgsap\b[^}]*\}\s*=\s*await\s+import\s*\(/.test(code),
253
260
  };
254
261
  }
255
262
 
@@ -134,8 +134,15 @@ export function seams(track, { factor = 6, minimum = 4, least = 16 } = {}) {
134
134
  /** A still element has no normal step to be unlike, so nothing is a seam. */
135
135
  if (!median) return [];
136
136
 
137
+ /**
138
+ * A loop carries on after its seam; a placement does not. A dev-only badge
139
+ * on a real page jumped once, 1000-1500px into a corner, and was reported as
140
+ * a tear. So a jump counts only when a step of ordinary size follows it.
141
+ */
142
+ const resumes = (index) => steps.slice(index + 1).some((step) => step.delta >= median / 2);
143
+
137
144
  return steps
138
- .filter((step) => step.delta > median * factor && step.delta >= least)
145
+ .filter((step, index) => step.delta > median * factor && step.delta >= least && resumes(index))
139
146
  .map((step) => ({ at: step.at, jump: Math.round(step.delta * 10) / 10 }));
140
147
  }
141
148
 
@@ -274,6 +281,29 @@ export function longFrameSummary(longFrames, cost = []) {
274
281
  };
275
282
  }
276
283
 
284
+ /**
285
+ * Every element that moved, once each, with what moved it and where it first
286
+ * was: `[{ key, kind, box }]`, at most `limit`. A count alone left a project
287
+ * unable to find the thirteen non-GSAP elements still moving under reduced
288
+ * motion. `kind` is "gsap", "css" (CSS animation, transition or Web Animation)
289
+ * or "script"; a recording made before kinds were read says "gsap" or "other".
290
+ */
291
+ export function movingElements(log, { limit = 50 } = {}) {
292
+ const seen = new Map();
293
+ for (const frame of log) {
294
+ for (const moved of frame.moved) {
295
+ const id = moved.id ?? moved.key;
296
+ if (seen.has(id)) continue;
297
+ seen.set(id, {
298
+ key: moved.key,
299
+ kind: moved.kind ?? (moved.gsap ? "gsap" : "other"),
300
+ box: moved.box ?? null,
301
+ });
302
+ }
303
+ }
304
+ return [...seen.values()].slice(0, limit);
305
+ }
306
+
277
307
  /** Each element and the moment it first moved, in that order. */
278
308
  export function starts(log) {
279
309
  const first = new Map();
@@ -527,6 +557,11 @@ const RECORDER = `(() => {
527
557
  id: idOf(el),
528
558
  key: keyOf(el),
529
559
  gsap: Boolean(el._gsap),
560
+ /**
561
+ * What is moving it: GSAP's cache, a running CSS animation, transition
562
+ * or Web Animation, or neither — a script writing styles itself.
563
+ */
564
+ kind: el._gsap ? "gsap" : el.getAnimations && el.getAnimations().length ? "css" : "script",
530
565
  box: state.screen,
531
566
  x: state.x,
532
567
  opacity: state.o,
@@ -744,6 +779,7 @@ export function conclude(result) {
744
779
  gsapMovers: new Set(
745
780
  log.flatMap((frame) => frame.moved.filter((m) => m.gsap).map((m) => m.id ?? m.key)),
746
781
  ).size,
782
+ movingElements: movingElements(log),
747
783
  seams: seamEvents(torn),
748
784
  overText,
749
785
  began: began.slice(0, 8),
@@ -781,6 +817,7 @@ export function probe(kind) {
781
817
  starts,
782
818
  firstMotion,
783
819
  longFrameSummary,
820
+ movingElements,
784
821
  conclude,
785
822
  };
786
823
  return `() => {
@@ -818,9 +855,16 @@ export function report(result, options) {
818
855
  lines.push(" or it needs a scroll or a pointer to begin");
819
856
  } else {
820
857
  lines.push(` first motion at ${found.firstMotion}ms`);
858
+ const by = (kind) => found.movingElements.filter((element) => element.kind === kind).length;
859
+ const rest = [
860
+ by("css") && `${by("css")} CSS or Web Animations`,
861
+ by("script") && `${by("script")} written by other script`,
862
+ by("other") && `${by("other")} not GSAP's`,
863
+ ].filter(Boolean);
821
864
  lines.push(
822
865
  ` ${found.movers} elements moved, ${found.gsapMovers} of them GSAP's` +
823
- `${found.movers > found.gsapMovers ? " (the rest is CSS, Web Animations or other script, which the source audit cannot see)" : ""}`,
866
+ `${rest.length ? `; ${rest.join(", ")}, which the source audit cannot see` : ""}` +
867
+ `${found.movers > found.movingElements.length ? ` (kinds read for the first ${found.movingElements.length})` : ""}`,
824
868
  );
825
869
  if (found.spread) lines.push(` they started over a span of ${found.spread}ms`);
826
870
  }