@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 +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +4 -4
- package/skill/reference/refine.md +2 -2
- package/skill/scripts/audit-gsap.mjs +97 -2
- package/skill/scripts/capture-motion.mjs +14 -1
- package/skill/scripts/lib/ast.mjs +48 -2
- package/skill/scripts/lib/rules.mjs +61 -19
- package/skill/scripts/lib/source.mjs +8 -1
- package/skill/scripts/review-motion.mjs +46 -2
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.
|
|
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
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.
|
|
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`: `
|
|
52
|
-
[next](adapter/next.md)
|
|
53
|
-
|
|
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
|
|
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) =>
|
|
608
|
-
[...contained].some((fn) =>
|
|
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
|
|
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,
|
|
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 (
|
|
1845
|
-
return [];
|
|
1846
|
-
}
|
|
1891
|
+
if (hasReducedBranch(file)) return [];
|
|
1847
1892
|
/**
|
|
1848
|
-
*
|
|
1849
|
-
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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
|
-
`${
|
|
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
|
}
|