@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 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.0 audit src --quiet"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/gsap-motion",
3
- "version": "3.4.0",
3
+ "version": "3.4.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.4.0"
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. Without GSAP on the page this returns null, which is a
261
- * finding of its own: the animation never started.
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
- if (typeof gsap === "undefined") return null;
265
- const children = gsap.globalTimeline.getChildren(true, true, true);
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
- time: gsap.globalTimeline.time(),
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
- const span = (options.at.at(-1) ?? 0) + options.wait;
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: delta("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
- ` ${measured.frames} frames · ${measured.layouts} layouts (${measured.layoutSeconds}s)` +
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({ label: staticString(link.arguments[0]) ?? "(label)" });
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
- map.scenes.push({
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
- /** The name each scene is kept under, so its own beats find their way home. */
222
- const byName = new Map(
223
- map.scenes.filter((scene) => scene.name).map((scene) => [scene.name, scene]),
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 = byName.get(owner);
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
- ? { label: staticString(call.arguments[0]) ?? "(label)" }
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,