@mehshekari/gsap-motion 3.4.0 → 3.4.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 +1 -1
- package/skill/scripts/capture-motion.mjs +94 -9
- package/skill/scripts/explain-motion.mjs +30 -9
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
30
30
|
build without warning:
|
|
31
31
|
|
|
32
32
|
```json
|
|
33
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@3.4.
|
|
33
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@3.4.1 audit src --quiet"
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
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 18+ and nothing else.
|
|
6
6
|
metadata:
|
|
7
|
-
version: "3.4.
|
|
7
|
+
version: "3.4.1"
|
|
8
8
|
verified-gsap: "3.15"
|
|
9
9
|
verified-gsap-react: "2.1"
|
|
10
10
|
---
|
|
@@ -257,14 +257,35 @@ export function parseOptions(argv) {
|
|
|
257
257
|
|
|
258
258
|
/**
|
|
259
259
|
* The timeline GSAP is running, read from the page itself rather than guessed
|
|
260
|
-
* from the source.
|
|
261
|
-
*
|
|
260
|
+
* from the source.
|
|
261
|
+
*
|
|
262
|
+
* A bundled app never gives the page a gsap global. GSAP installs its exports
|
|
263
|
+
* into a private object, and the branch of its installer that would reach
|
|
264
|
+
* window cannot be taken, because that object is truthy from the start. So a
|
|
265
|
+
* page can be full of GSAP with nothing to ask. Presence is read instead from
|
|
266
|
+
* the traces it leaves — the version it announces, and the cache it hangs on
|
|
267
|
+
* every element it touches — and a timeline that cannot be read is reported as
|
|
268
|
+
* unreadable, never as an animation that never ran.
|
|
262
269
|
*/
|
|
263
270
|
const READ_TIMELINE = `(() => {
|
|
264
|
-
|
|
265
|
-
|
|
271
|
+
const handle =
|
|
272
|
+
typeof gsap !== "undefined"
|
|
273
|
+
? gsap
|
|
274
|
+
: window.GreenSockGlobals && window.GreenSockGlobals.gsap
|
|
275
|
+
? window.GreenSockGlobals.gsap
|
|
276
|
+
: null;
|
|
277
|
+
const version = (window.gsapVersions || [])[0] || null;
|
|
278
|
+
const controlled = [...document.querySelectorAll("*")].filter((el) => el._gsap).length;
|
|
279
|
+
if (!handle) {
|
|
280
|
+
if (!version && !controlled) return null;
|
|
281
|
+
return { readable: false, version, controlled };
|
|
282
|
+
}
|
|
283
|
+
const children = handle.globalTimeline.getChildren(true, true, true);
|
|
266
284
|
return {
|
|
267
|
-
|
|
285
|
+
readable: true,
|
|
286
|
+
version,
|
|
287
|
+
controlled,
|
|
288
|
+
time: handle.globalTimeline.time(),
|
|
268
289
|
children: children.map((child) => ({
|
|
269
290
|
targets: (child.targets?.() ?? []).map((target) =>
|
|
270
291
|
target?.id ? "#" + target.id :
|
|
@@ -283,6 +304,27 @@ const READ_TIMELINE = `(() => {
|
|
|
283
304
|
};
|
|
284
305
|
})()`;
|
|
285
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Frames the page actually drove.
|
|
309
|
+
*
|
|
310
|
+
* Chrome's own Frames metric reads 0 in headless even while a tween runs at a
|
|
311
|
+
* steady 60fps, so it cannot be reported as a frame count. A requestAnimationFrame
|
|
312
|
+
* counter, installed before the page's own scripts, counts the frames the page
|
|
313
|
+
* was given — which is the number a reader wants when asking whether motion
|
|
314
|
+
* was smooth.
|
|
315
|
+
*/
|
|
316
|
+
const COUNT_FRAMES = `(() => {
|
|
317
|
+
if (window.__gsapMotionFrames !== undefined) return true;
|
|
318
|
+
window.__gsapMotionFrames = 0;
|
|
319
|
+
window.__gsapMotionStart = performance.now();
|
|
320
|
+
const step = () => {
|
|
321
|
+
window.__gsapMotionFrames += 1;
|
|
322
|
+
requestAnimationFrame(step);
|
|
323
|
+
};
|
|
324
|
+
requestAnimationFrame(step);
|
|
325
|
+
return true;
|
|
326
|
+
})()`;
|
|
327
|
+
|
|
286
328
|
/** The live box of a selector, so a pointer lands on an element that is moving. */
|
|
287
329
|
const boxOf = (selector) => `(() => {
|
|
288
330
|
const element = document.querySelector(${JSON.stringify(selector)});
|
|
@@ -295,6 +337,17 @@ const boxOf = (selector) => `(() => {
|
|
|
295
337
|
|
|
296
338
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
297
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Whether a box can be pointed at.
|
|
342
|
+
*
|
|
343
|
+
* A selector can match an element that styles say is visible and that layout
|
|
344
|
+
* gives no box at all — a duplicate inside a collapsed container, say. Its
|
|
345
|
+
* rect is every zero, so a pointer aimed at its centre lands in the corner of
|
|
346
|
+
* the viewport and hits whatever is there. Found on a real site, where a
|
|
347
|
+
* header held two copies of the same button and the first had no box.
|
|
348
|
+
*/
|
|
349
|
+
export const pointable = (box) => Boolean(box && box.width > 0 && box.height > 0);
|
|
350
|
+
|
|
298
351
|
async function capture(options) {
|
|
299
352
|
const executable = findChrome(options.chrome);
|
|
300
353
|
if (!executable) {
|
|
@@ -343,6 +396,8 @@ async function capture(options) {
|
|
|
343
396
|
|
|
344
397
|
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
345
398
|
|
|
399
|
+
await call("Page.addScriptToEvaluateOnNewDocument", { source: COUNT_FRAMES });
|
|
400
|
+
|
|
346
401
|
const before = await call("Performance.getMetrics");
|
|
347
402
|
await call("Page.navigate", { url: options.url });
|
|
348
403
|
await wait(options.wait);
|
|
@@ -373,6 +428,12 @@ async function capture(options) {
|
|
|
373
428
|
notes.push(`${step.type}: nothing matched ${step.value}`);
|
|
374
429
|
continue;
|
|
375
430
|
}
|
|
431
|
+
if (!pointable(box)) {
|
|
432
|
+
notes.push(
|
|
433
|
+
`${step.type}: ${step.value} matched an element with no box on screen, so nothing was done`,
|
|
434
|
+
);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
376
437
|
/**
|
|
377
438
|
* Two moves, because a pointer that is already there never enters; and
|
|
378
439
|
* the box is read now, since whatever is animating has moved since the
|
|
@@ -423,6 +484,9 @@ async function capture(options) {
|
|
|
423
484
|
}
|
|
424
485
|
|
|
425
486
|
const timeline = await evaluate(READ_TIMELINE);
|
|
487
|
+
const drawn = await evaluate(
|
|
488
|
+
"({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
|
|
489
|
+
);
|
|
426
490
|
const after = await call("Performance.getMetrics");
|
|
427
491
|
|
|
428
492
|
const delta = (name) => {
|
|
@@ -431,11 +495,17 @@ async function capture(options) {
|
|
|
431
495
|
return end - start;
|
|
432
496
|
};
|
|
433
497
|
|
|
434
|
-
|
|
498
|
+
/** The window the page was actually watched for, not the one that was asked for. */
|
|
499
|
+
const span = Math.round(
|
|
500
|
+
drawn.since === null
|
|
501
|
+
? (options.at.at(-1) ?? 0) + options.wait
|
|
502
|
+
: await evaluate(`performance.now() - ${drawn.since}`),
|
|
503
|
+
);
|
|
435
504
|
const measured = {
|
|
436
505
|
layouts: delta("LayoutCount"),
|
|
437
506
|
recalcs: delta("RecalcStyleCount"),
|
|
438
|
-
frames:
|
|
507
|
+
frames: drawn.frames,
|
|
508
|
+
fps: drawn.frames && span ? Number(((drawn.frames / span) * 1000).toFixed(1)) : null,
|
|
439
509
|
layoutSeconds: Number(delta("LayoutDuration").toFixed(3)),
|
|
440
510
|
recalcSeconds: Number(delta("RecalcStyleDuration").toFixed(3)),
|
|
441
511
|
overMs: span,
|
|
@@ -449,7 +519,7 @@ async function capture(options) {
|
|
|
449
519
|
|
|
450
520
|
// --- Reporting ---------------------------------------------------------------
|
|
451
521
|
|
|
452
|
-
function report(result, options) {
|
|
522
|
+
export function report(result, options) {
|
|
453
523
|
const lines = [`Watched ${result.url}`];
|
|
454
524
|
|
|
455
525
|
const emulated = [
|
|
@@ -469,6 +539,17 @@ function report(result, options) {
|
|
|
469
539
|
lines.push("", "Timeline, as the page is running it");
|
|
470
540
|
if (result.timeline === null) {
|
|
471
541
|
lines.push(" no GSAP on the page — nothing was animating, or it never loaded");
|
|
542
|
+
} else if (result.timeline.readable === false) {
|
|
543
|
+
const { version, controlled } = result.timeline;
|
|
544
|
+
lines.push(
|
|
545
|
+
` GSAP${version ? ` ${version}` : ""} is running, but this page keeps it to itself,` +
|
|
546
|
+
" so its timeline cannot be read from outside.",
|
|
547
|
+
);
|
|
548
|
+
lines.push(
|
|
549
|
+
` ${controlled} element${controlled === 1 ? " carries" : "s carry"} GSAP's cache,` +
|
|
550
|
+
" so it has touched the page.",
|
|
551
|
+
);
|
|
552
|
+
lines.push(" To read the timeline here, call gsap.install(window) in development.");
|
|
472
553
|
} else if (result.timeline.children.length === 0) {
|
|
473
554
|
lines.push(" GSAP is loaded, and its global timeline has no children right now");
|
|
474
555
|
} else {
|
|
@@ -486,8 +567,12 @@ function report(result, options) {
|
|
|
486
567
|
|
|
487
568
|
const { measured } = result;
|
|
488
569
|
lines.push("", `Browser work over ${measured.overMs}ms`);
|
|
570
|
+
/** A rate only means something when frames were counted at all. */
|
|
571
|
+
const rate = measured.fps === null ? "" : ` (${measured.fps}/s)`;
|
|
572
|
+
const drew =
|
|
573
|
+
measured.frames === null ? "frames not counted" : `${measured.frames} frames${rate}`;
|
|
489
574
|
lines.push(
|
|
490
|
-
` ${
|
|
575
|
+
` ${drew} · ${measured.layouts} layouts (${measured.layoutSeconds}s)` +
|
|
491
576
|
` · ${measured.recalcs} style recalculations (${measured.recalcSeconds}s)`,
|
|
492
577
|
);
|
|
493
578
|
lines.push("", "These are observations. What they mean is yours to judge.");
|
|
@@ -29,10 +29,12 @@ import { connect, findChrome } from "./capture-motion.mjs";
|
|
|
29
29
|
import { check, mentionsGsap } from "./lib/audit.mjs";
|
|
30
30
|
import {
|
|
31
31
|
calleeName,
|
|
32
|
+
contains,
|
|
32
33
|
dottedName,
|
|
33
34
|
findAll,
|
|
34
35
|
keptUnder,
|
|
35
36
|
methodName,
|
|
37
|
+
scopeOf,
|
|
36
38
|
propertyOf,
|
|
37
39
|
staticString,
|
|
38
40
|
timelineLinks,
|
|
@@ -185,6 +187,8 @@ function readFile(file) {
|
|
|
185
187
|
// Scenes: a timeline, and the beats on it in order.
|
|
186
188
|
const timelines = findAll(file.ast, (node) => calleeName(node) === "gsap.timeline");
|
|
187
189
|
const claimed = new Set();
|
|
190
|
+
/** Which function holds each scene, since a file often has two called `tl`. */
|
|
191
|
+
const owners = new Map();
|
|
188
192
|
|
|
189
193
|
for (const timeline of timelines) {
|
|
190
194
|
const beats = [];
|
|
@@ -192,7 +196,9 @@ function readFile(file) {
|
|
|
192
196
|
claimed.add(link);
|
|
193
197
|
const method = methodName(link);
|
|
194
198
|
if (method === "addLabel") {
|
|
195
|
-
beats.push({
|
|
199
|
+
beats.push({
|
|
200
|
+
label: staticString(link.arguments[0]) ?? textOf(file, link.arguments[0], 24) ?? "(label)",
|
|
201
|
+
});
|
|
196
202
|
continue;
|
|
197
203
|
}
|
|
198
204
|
if (!["to", "from", "fromTo", "set", "call", "add"].includes(method)) continue;
|
|
@@ -209,19 +215,31 @@ function readFile(file) {
|
|
|
209
215
|
});
|
|
210
216
|
}
|
|
211
217
|
|
|
212
|
-
|
|
218
|
+
const scene = {
|
|
213
219
|
line: lineAt(file.raw, timeline.start),
|
|
214
220
|
name: keptUnder(file.ast, timeline)?.name ?? null,
|
|
215
221
|
repeat: valueOf(varsOf(timeline), "repeat"),
|
|
216
222
|
why: reasonFor(file, timeline),
|
|
217
223
|
beats,
|
|
218
|
-
}
|
|
224
|
+
};
|
|
225
|
+
map.scenes.push(scene);
|
|
226
|
+
owners.set(scene, scopeOf(file.ast, timeline));
|
|
219
227
|
}
|
|
220
228
|
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
229
|
+
/**
|
|
230
|
+
* The scene a beat belongs to. A name alone is not enough: two timelines in
|
|
231
|
+
* one file are often both `tl`, and handing one scene's beats to the other is
|
|
232
|
+
* the kind of wrong a map must not be. The owner is the enclosing function.
|
|
233
|
+
*/
|
|
234
|
+
const sceneFor = (name, node) => {
|
|
235
|
+
const holding = map.scenes.filter((scene) => {
|
|
236
|
+
if (scene.name !== name) return false;
|
|
237
|
+
const owner = owners.get(scene);
|
|
238
|
+
/** A timeline at module scope owns the whole file; otherwise its function must hold the beat. */
|
|
239
|
+
return !owner || contains(owner, node);
|
|
240
|
+
});
|
|
241
|
+
return holding.at(-1) ?? null;
|
|
242
|
+
};
|
|
225
243
|
|
|
226
244
|
// Beats added to a timeline this file was handed: the helper shape, where the
|
|
227
245
|
// caller owns the timeline and this file only fills it.
|
|
@@ -232,14 +250,17 @@ function readFile(file) {
|
|
|
232
250
|
if (claimed.has(call) || timelines.includes(call)) continue;
|
|
233
251
|
const owner = dottedName(unwrap(call.callee).object);
|
|
234
252
|
if (!owner || owner === "gsap" || owner.startsWith("gsap.")) continue;
|
|
235
|
-
const own =
|
|
253
|
+
const own = sceneFor(owner, call);
|
|
236
254
|
if (!own && !handed.has(owner)) handed.set(owner, []);
|
|
237
255
|
|
|
238
256
|
const method = methodName(call);
|
|
239
257
|
const vars = varsOf(call);
|
|
240
258
|
(own ? own.beats : handed.get(owner)).push(
|
|
241
259
|
method === "addLabel"
|
|
242
|
-
? {
|
|
260
|
+
? {
|
|
261
|
+
label:
|
|
262
|
+
staticString(call.arguments[0]) ?? textOf(file, call.arguments[0], 24) ?? "(label)",
|
|
263
|
+
}
|
|
243
264
|
: {
|
|
244
265
|
line: lineAt(file.raw, startOf(call)),
|
|
245
266
|
method,
|