@mehshekari/gsap-motion 4.4.2 → 4.5.0

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.4.2 audit src --quiet"
35
+ "lint": "eslint && npx @mehshekari/gsap-motion@4.5.0 audit src --quiet"
36
36
  ```
37
37
 
38
38
  Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
@@ -74,12 +74,14 @@ Commands
74
74
  --morph <a> <b> Compare a morph pair
75
75
  --hues Also report hard-coded hues
76
76
  inspect <url> Watch a page animate and report what it did
77
- --at 0,300,900 When to capture, in ms after the page settles
77
+ --at 0,300,900 When to capture, in ms after --wait
78
+ --wait 400 A fixed wait after navigation; it does not detect a loader
78
79
  --scroll <px|sel> Scroll before capturing
79
80
  --hover <selector> Move the pointer onto an element
80
81
  --click <selector> Click an element
81
82
  --reduced --dark Emulate a preference
82
83
  --mobile 390x844 with touch input
84
+ --viewport 1440x900 The window's size; every report prints the one used
83
85
  --cpu 4 Throttle the CPU by this factor
84
86
  --headed A visible Chrome on the GPU, for WebGL pages
85
87
  --seek <name> Call window.<name>(t) before each capture; --at is
@@ -96,6 +98,7 @@ Commands
96
98
  --scroll <px|sel> Scroll, then watch again
97
99
  --reduced --dark Emulate a preference
98
100
  --mobile 390x844 with touch input
101
+ --viewport 1440x900 The window's size; every report prints the one used
99
102
  --cpu 4 Throttle the CPU by this factor
100
103
  --headed A visible Chrome on the GPU, for WebGL pages
101
104
  --out <dir> Save a frame alongside the measurements
@@ -297,7 +300,15 @@ function doctor(args = []) {
297
300
  if (provider) report("info", `No separate copy for ${scope}`);
298
301
  else report("info", `Not installed for ${scope}`, `${command} installs it at ${location}`);
299
302
  } else if (installed.version === bundled.version) {
300
- report("ok", `Installed for ${scope}: ${installed.version}`);
303
+ if (provider) {
304
+ report(
305
+ "warn",
306
+ `Installed for ${scope}: ${installed.version}`,
307
+ `The ${provider} plugin provides it too, so the skill is listed twice. Remove ${location}.`,
308
+ );
309
+ } else {
310
+ report("ok", `Installed for ${scope}: ${installed.version}`);
311
+ }
301
312
  } else {
302
313
  report(
303
314
  "warn",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/gsap-motion",
3
- "version": "4.4.2",
3
+ "version": "4.5.0",
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.4.2"
7
+ version: "4.5.0"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -34,8 +34,8 @@ body re-runs on change) and `revertOnUpdate` (the previous run reverts first).
34
34
 
35
35
  A tween created *outside* the body — in a handler, a promise, a callback — is
36
36
  never reverted. Wrap that function in `contextSafe`, from the same hook, and the
37
- context owns it again. Only wrap functions that *create* animations: playing an
38
- existing timeline creates nothing. Inside `mm.add`, use its own: the callback's
37
+ context owns it — with every tween it makes, killed ones too, until it reverts.
38
+ A frequent handler replays a timeline built in the body, or calls a `quickTo`. Inside `mm.add`, use its own: the callback's
39
39
  second argument, `(context, contextSafe) =>`. `contextSafe` runs during render, so reading a
40
40
  ref inside the callback trips React's "cannot access refs during render" rule —
41
41
  build the timeline in the body, keep it on a ref, and expose a `useCallback`
@@ -103,8 +103,7 @@ onMouseMove = (e) => moveX(e.clientX);
103
103
  ```
104
104
 
105
105
  State is for *what exists* — is the modal open, which tab is active. GSAP owns
106
- *how it moves*. Crossing that line is the most common performance bug in
107
- animated React.
106
+ *how it moves*.
108
107
 
109
108
  ## Page and route changes
110
109
 
@@ -75,6 +75,15 @@ Reach the state first, and change the conditions that usually break things:
75
75
  - `--reduced`, `--dark`, `--mobile`, `--cpu 4`. `--mobile` makes input touch,
76
76
  so it cannot be combined with `--hover`: a coarse pointer has no hover, which
77
77
  is the thing worth knowing.
78
+ - `--viewport 1440x900` for a desktop layout. Without it the window is whatever
79
+ headless Chrome opens at — about 760×480 — where many sites switch to their
80
+ compact layout. Every report prints the viewport it ran at.
81
+ - `--wait` is a fixed wait after navigation; it does not detect a loader. Raise
82
+ it, or use `--seek`.
83
+ - **A step that did nothing fails the run.** Nothing matched, no match on
84
+ screen, or an invalid selector: it is listed last, under *Not done*, and the
85
+ run exits 3 — 2 for an invalid selector. A hidden match is passed over for
86
+ the next one, and one off screen is scrolled to first.
78
87
  - **Cost on a production build, not the dev server.** In development React's
79
88
  StrictMode mounts every effect twice — two contexts, two WebGL contexts — and
80
89
  the bundle is unminified, so what a dev server measures is not what ships.
@@ -32,12 +32,12 @@
32
32
  *
33
33
  * Exit code is 1 when any error-level finding is reported, 0 otherwise.
34
34
  */
35
- import { existsSync } from "node:fs";
35
+ import { existsSync, statSync } from "node:fs";
36
36
  import { 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";
40
- import { collect, load } from "./lib/source.mjs";
40
+ import { collect, hasSourceExtension, load } from "./lib/source.mjs";
41
41
 
42
42
  /**
43
43
  * The project being audited is wherever this is run from, not wherever the
@@ -66,6 +66,12 @@ for (const path of paths) {
66
66
 
67
67
  const findings = [];
68
68
  const sources = [];
69
+ /**
70
+ * A file named on the command line that the audit cannot read — a stylesheet,
71
+ * say. It used to be skipped in silence and the run printed "clean", which
72
+ * reads as "checked and fine" about a file nothing looked at.
73
+ */
74
+ const notScanned = [];
69
75
 
70
76
  /** A finding as the report shows it: under the file's display path, no column. */
71
77
  const reported = (source, finding) => ({
@@ -78,6 +84,10 @@ const reported = (source, finding) => ({
78
84
  });
79
85
 
80
86
  for (const path of paths) {
87
+ if (statSync(path).isFile() && !hasSourceExtension(path)) {
88
+ notScanned.push(path);
89
+ continue;
90
+ }
81
91
  for (const file of collect(path)) {
82
92
  const source = load(file, ROOT);
83
93
  if (!mentionsGsap(source)) continue;
@@ -124,8 +134,14 @@ findings.sort(
124
134
 
125
135
  const shown = quiet ? findings.filter((f) => f.level === "error") : findings;
126
136
 
137
+ const READS = "the audit reads .ts, .tsx, .js, .jsx and .mjs, and the scripts of .vue, .svelte and .astro files";
138
+ if (notScanned.length === paths.length) {
139
+ for (const path of notScanned) console.error(`Not scanned: ${path} — ${READS}.`);
140
+ process.exit(2);
141
+ }
142
+
127
143
  if (asJson) {
128
- console.log(JSON.stringify({ findings: shown }, null, 2));
144
+ console.log(JSON.stringify({ findings: shown, notScanned }, null, 2));
129
145
  } else if (!shown.length) {
130
146
  console.log(
131
147
  findings.length
@@ -157,4 +173,8 @@ if (asJson) {
157
173
  );
158
174
  }
159
175
 
176
+ if (!asJson) {
177
+ for (const path of notScanned) console.log(`Not scanned: ${path} — ${READS}.`);
178
+ }
179
+
160
180
  process.exit(findings.some((f) => f.level === "error") ? 1 : 0);
@@ -17,8 +17,11 @@
17
17
  * Usage, from the project root:
18
18
  * node <skill-dir>/scripts/capture-motion.mjs <url> [options]
19
19
  *
20
- * --at 0,300,900 when to capture, in ms after the page settles
21
- * --wait 400 settle time before the first capture (default 400)
20
+ * --at 0,300,900 when to capture, in ms after --wait
21
+ * --wait 400 a fixed wait after navigation, before any step or
22
+ * capture (default 400). It does not detect a loader.
23
+ * --viewport 1440x900 the window's size; the default is whatever headless
24
+ * Chrome opens at, and every report prints it
22
25
  * --out <dir> where frames go (default .gsap-motion/inspect)
23
26
  * --scroll <px|sel> scroll before capturing: a number, or to a selector
24
27
  * --hover <selector> move the pointer onto an element, at its live box
@@ -188,6 +191,7 @@ export function parseOptions(argv) {
188
191
  reduced: false,
189
192
  dark: false,
190
193
  mobile: false,
194
+ viewport: null,
191
195
  cpu: 1,
192
196
  json: false,
193
197
  chrome: null,
@@ -242,6 +246,12 @@ export function parseOptions(argv) {
242
246
  case "--mobile":
243
247
  options.mobile = true;
244
248
  break;
249
+ case "--viewport": {
250
+ const size = parseViewport(value());
251
+ if (size) options.viewport = size;
252
+ else options.errors.push("--viewport takes a width and height, such as --viewport 1440x900");
253
+ break;
254
+ }
245
255
  case "--cpu": {
246
256
  const rate = asNumber(value());
247
257
  if (rate === null || rate < 1) options.errors.push("--cpu takes a factor of 1 or more");
@@ -285,6 +295,10 @@ export function parseOptions(argv) {
285
295
  options.errors.push("--seek needs --at, in seconds of the timeline, such as --at 3,3.3,3.6");
286
296
  }
287
297
 
298
+ if (options.mobile && options.viewport) {
299
+ options.errors.push("--mobile sets its own viewport; drop one of --mobile and --viewport.");
300
+ }
301
+
288
302
  /** A coarse pointer has no hover; emulating both would measure something no device is. */
289
303
  if (options.mobile && options.steps.some((step) => step.type === "hover")) {
290
304
  options.errors.push(
@@ -382,14 +396,97 @@ const SETTLED = "new Promise((done) => requestAnimationFrame(() => requestAnimat
382
396
  export const frameName = (at, options) =>
383
397
  options.seek ? `frame-${at.toFixed(3).padStart(8, "0")}s.png` : `frame-${String(at).padStart(5, "0")}ms.png`;
384
398
 
385
- /** The live box of a selector, so a pointer lands on an element that is moving. */
386
- const boxOf = (selector) => `(() => {
387
- const element = document.querySelector(${JSON.stringify(selector)});
388
- if (!element) return null;
389
- const box = element.getBoundingClientRect();
390
- return { x: box.x + box.width / 2, y: box.y + box.height / 2, width: box.width, height: box.height };
399
+ /**
400
+ * The first match a pointer can reach, read live so it lands on an element that
401
+ * is moving. Three real pages defeated "the first match": a header holding two
402
+ * copies of a button with the first in a collapsed container, five links of
403
+ * which only the last was on screen, and a match scrolled out of the viewport,
404
+ * where the pointer landed at a negative y and the run still said it clicked.
405
+ * So every match is tried in order, one with no box or hidden is passed over,
406
+ * and one outside the viewport is scrolled into it first. An invalid selector
407
+ * is said to be one, rather than matching nothing.
408
+ */
409
+ export const pointAt = (selector) => `(() => {
410
+ let all;
411
+ try {
412
+ all = [...document.querySelectorAll(${JSON.stringify(selector)})];
413
+ } catch {
414
+ return { invalid: true };
415
+ }
416
+ const shown = (el) => {
417
+ const box = el.getBoundingClientRect();
418
+ return box.width > 0 && box.height > 0 && getComputedStyle(el).visibility !== "hidden";
419
+ };
420
+ const index = all.findIndex(shown);
421
+ if (index === -1) return { matches: all.length, index };
422
+ const el = all[index];
423
+ let box = el.getBoundingClientRect();
424
+ const inView = box.bottom > 0 && box.right > 0 && box.top < innerHeight && box.left < innerWidth;
425
+ if (!inView) {
426
+ el.scrollIntoView({ block: "center", behavior: "instant" });
427
+ box = el.getBoundingClientRect();
428
+ }
429
+ const clamp = (v, max) => Math.min(max - 1, Math.max(0, v));
430
+ return {
431
+ matches: all.length,
432
+ index,
433
+ scrolled: !inView,
434
+ x: clamp(box.x + box.width / 2, innerWidth),
435
+ y: clamp(box.y + box.height / 2, innerHeight),
436
+ width: box.width,
437
+ height: box.height,
438
+ };
391
439
  })()`;
392
440
 
441
+ /**
442
+ * Scrolls instantly, then reports where the page is once two frames have drawn.
443
+ * A page with `scroll-behavior: smooth` used to animate the jump, so the
444
+ * position was read as 0 while it travelled, and frames were captured mid-way.
445
+ */
446
+ export function scrollExpression(value) {
447
+ const pixels = /^\d+$/.test(String(value)) ? Number(value) : null;
448
+ const move =
449
+ pixels === null
450
+ ? `let el;
451
+ try { el = document.querySelector(${JSON.stringify(value)}); } catch { done({ invalid: true }); return; }
452
+ if (!el) { done({ matches: 0 }); return; }
453
+ el.scrollIntoView({ block: "center", behavior: "instant" });`
454
+ : `window.scrollTo({ top: ${pixels}, behavior: "instant" });`;
455
+ return `new Promise((done) => {
456
+ ${move}
457
+ requestAnimationFrame(() => requestAnimationFrame(() => done({ y: Math.round(window.scrollY) })));
458
+ })`;
459
+ }
460
+
461
+ /** What a step that did nothing says, or null when it did something. */
462
+ export function undone(step, result) {
463
+ if (result?.invalid) return `${step.type}: ${step.value} is not a valid selector`;
464
+ if (!result || result.matches === 0) return `${step.type}: nothing matched ${step.value}`;
465
+ if (result.index === -1) {
466
+ const n = result.matches;
467
+ return `${step.type}: ${step.value} matched ${n} element${n === 1 ? "" : "s"}, none with a box on screen`;
468
+ }
469
+ return null;
470
+ }
471
+
472
+ /**
473
+ * The exit code a run earns: 2 for an invalid selector, 3 when a step asked for
474
+ * did nothing, 0 otherwise. A step that did nothing used to leave a note in the
475
+ * middle of the report and exit 0, which a script — or a reader with `| tail` —
476
+ * took as done.
477
+ */
478
+ export const exitFor = (notDone) =>
479
+ notDone.some((line) => line.endsWith("is not a valid selector")) ? 2 : notDone.length ? 3 : 0;
480
+
481
+ /** `--viewport 1440x900`, as numbers, or null. */
482
+ export function parseViewport(text) {
483
+ const match = /^(\d{2,5})x(\d{2,5})$/.exec(String(text ?? ""));
484
+ return match ? { width: Number(match[1]), height: Number(match[2]) } : null;
485
+ }
486
+
487
+ /** What the page measured itself at, for every report: a default nobody chose is still a size. */
488
+ export const VIEWPORT = "({ width: innerWidth, height: innerHeight })";
489
+
393
490
  // --- Where output goes -------------------------------------------------------
394
491
 
395
492
  /**
@@ -483,6 +580,9 @@ async function capture(options) {
483
580
  });
484
581
  await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
485
582
  }
583
+ if (options.viewport) {
584
+ await call("Emulation.setDeviceMetricsOverride", { ...options.viewport, deviceScaleFactor: 1, mobile: false });
585
+ }
486
586
 
487
587
  if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
488
588
 
@@ -492,36 +592,20 @@ async function capture(options) {
492
592
  await call("Page.navigate", { url: options.url });
493
593
  await wait(options.wait);
494
594
 
595
+ const notDone = [];
495
596
  for (const step of options.steps) {
496
597
  if (step.type === "scroll") {
497
- const pixels = asNumber(step.value);
498
- const moved = await evaluate(
499
- pixels === null
500
- ? `(() => {
501
- const element = document.querySelector(${JSON.stringify(step.value)});
502
- if (!element) return null;
503
- element.scrollIntoView({ block: "center" });
504
- return Math.round(window.scrollY);
505
- })()`
506
- : `(window.scrollTo(0, ${pixels}), Math.round(window.scrollY))`,
507
- );
508
- notes.push(
509
- moved === null
510
- ? `scroll: nothing matched ${step.value}`
511
- : `scroll: to ${moved}px`,
512
- );
598
+ const moved = await evaluate(scrollExpression(step.value));
599
+ const missed = undone(step, moved);
600
+ if (missed) notDone.push(missed);
601
+ else notes.push(`scroll: to ${moved.y}px`);
513
602
  }
514
603
 
515
604
  if (step.type === "hover" || step.type === "click") {
516
- const box = await evaluate(boxOf(step.value));
517
- if (!box) {
518
- notes.push(`${step.type}: nothing matched ${step.value}`);
519
- continue;
520
- }
521
- if (!pointable(box)) {
522
- notes.push(
523
- `${step.type}: ${step.value} matched an element with no box on screen, so nothing was done`,
524
- );
605
+ const box = await evaluate(pointAt(step.value));
606
+ const missed = undone(step, box) ?? (pointable(box) ? null : undone(step, { ...box, index: -1 }));
607
+ if (missed) {
608
+ notDone.push(missed);
525
609
  continue;
526
610
  }
527
611
  /**
@@ -550,8 +634,10 @@ async function capture(options) {
550
634
  });
551
635
  }
552
636
  }
637
+ const which = box.matches > 1 ? ` (match ${box.index + 1} of ${box.matches})` : "";
553
638
  notes.push(
554
- `${step.type}: ${step.value} at ${Math.round(box.x)},${Math.round(box.y)}`,
639
+ `${step.type}: ${step.value}${which} at ${Math.round(box.x)},${Math.round(box.y)}` +
640
+ (box.scrolled ? ", scrolled into view first" : ""),
555
641
  );
556
642
  await wait(120);
557
643
  }
@@ -587,6 +673,7 @@ async function capture(options) {
587
673
  "({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
588
674
  );
589
675
  const canvases = await evaluate("document.querySelectorAll(\"canvas\").length");
676
+ const viewport = await evaluate(VIEWPORT);
590
677
  const after = await call("Performance.getMetrics");
591
678
 
592
679
  const delta = (name) => {
@@ -611,7 +698,7 @@ async function capture(options) {
611
698
  overMs: span,
612
699
  };
613
700
 
614
- return { url: options.url, frames, timeline, measured, notes, canvases, chrome: executable };
701
+ return { url: options.url, frames, timeline, measured, notes, notDone, canvases, viewport, chrome: executable };
615
702
  } finally {
616
703
  await browser.close();
617
704
  }
@@ -631,6 +718,7 @@ export function report(result, options) {
631
718
  options.seek && `seeking with window.${options.seek}`,
632
719
  ].filter(Boolean);
633
720
  if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
721
+ if (result.viewport) lines.push(` viewport: ${result.viewport.width}x${result.viewport.height}`);
634
722
  for (const note of result.notes) lines.push(` ${note}`);
635
723
  const gpu = gpuNote(result.canvases, options);
636
724
  if (gpu) lines.push(` ${gpu}`);
@@ -681,6 +769,11 @@ export function report(result, options) {
681
769
  ` · ${measured.recalcs} style recalculations (${measured.recalcSeconds}s)`,
682
770
  );
683
771
  lines.push("", "These are observations. What they mean is yours to judge.");
772
+ /** Last, so a reader who sees only the end still sees it. */
773
+ if (result.notDone?.length) {
774
+ lines.push("", "Not done — the run exits non-zero");
775
+ for (const line of result.notDone) lines.push(` ${line}`);
776
+ }
684
777
 
685
778
  return lines.join("\n");
686
779
  }
@@ -704,6 +797,7 @@ async function main(argv) {
704
797
  try {
705
798
  const result = await capture(options);
706
799
  console.log(options.json ? JSON.stringify(result, null, 2) : report(result, options));
800
+ process.exitCode = exitFor(result.notDone);
707
801
  } catch (error) {
708
802
  console.error(error.message);
709
803
  process.exit(1);
@@ -307,7 +307,7 @@ export const contains = (outer, inner) =>
307
307
  * returns `null`: an ambiguous handler is left unread rather than guessed at.
308
308
  */
309
309
  export function resolveFunction(root, node) {
310
- const target = unwrap(node);
310
+ const target = wrappedFunction(unwrap(node));
311
311
  if (isFunction(target)) return target;
312
312
  if (target?.type !== "Identifier") return null;
313
313
 
@@ -318,13 +318,34 @@ export function resolveFunction(root, node) {
318
318
  (n.type === "VariableDeclarator" &&
319
319
  n.id.type === "Identifier" &&
320
320
  n.id.name === target.name &&
321
- isFunction(unwrap(n.init))),
321
+ isFunction(wrappedFunction(unwrap(n.init)))),
322
322
  );
323
323
  if (declarations.length !== 1) return null;
324
324
  const [declaration] = declarations;
325
325
  return declaration.type === "FunctionDeclaration"
326
326
  ? declaration
327
- : unwrap(declaration.init);
327
+ : wrappedFunction(unwrap(declaration.init));
328
+ }
329
+
330
+ /**
331
+ * The function inside a call that hands it back to run later, unchanged in
332
+ * what it does: `contextSafe(fn)`, `ctx.add(null, fn)` and `useCallback(fn, deps)`.
333
+ * The handler the skill recommends is wrapped in contextSafe, and following
334
+ * names stopped at the wrapper, so a tween per pointer event went unreported.
335
+ * Anything else is returned as it was.
336
+ */
337
+ function wrappedFunction(node) {
338
+ if (node?.type !== "CallExpression") return node;
339
+ const name = calleeName(node)?.split(".").at(-1);
340
+ if (name === "contextSafe" || name === "useCallback") return unwrap(node.arguments[0]);
341
+ if (
342
+ name === "add" &&
343
+ unwrap(node.arguments[0])?.type === "Literal" &&
344
+ unwrap(node.arguments[0]).value === null
345
+ ) {
346
+ return unwrap(node.arguments[1]);
347
+ }
348
+ return node;
328
349
  }
329
350
 
330
351
  const PARENTS = new WeakMap();
@@ -54,13 +54,15 @@ 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.",
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.",
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:
61
61
  "Keep it under a name and revert it when the component goes: `onUnmounted` in Vue, the function returned from `onMount` or `$effect` in Svelte, the `useEffect` cleanup in React, `astro:before-swap` in Astro. Everything created inside the context goes with it.",
62
62
  tweenPerFrame:
63
63
  "A tween per frame allocates an object per frame, and each one fights the last. Create a `gsap.quickTo()` or `quickSetter()` once outside the callback and call it inside — or, for scroll, scrub a timeline instead of writing values by hand.",
64
+ refReadInCleanup:
65
+ "So nothing is killed, and the tweens outlive the component. Capture the element in the effect body — `const el = ref.current` — and kill through that in the cleanup, or build the tweens in a useGSAP or gsap.context body, whose revert needs no ref.",
64
66
  allocationPerFrame:
65
67
  "Every frame's object is garbage by the next, and the collector reclaims it in a pause that lands as a dropped frame. Build it once, outside the callback — at module scope, or in a ref or useMemo — and reuse it with `.set()` or `.copy()`.",
66
68
  layoutReadPerFrame:
@@ -96,7 +98,7 @@ const HINT = {
96
98
  unregisteredPlugin:
97
99
  "An unregistered plugin's properties are ignored without an error — the animation simply does nothing.",
98
100
  missingReducedMotion:
99
- "Reduced motion is a design, not an off switch: decide what the motion was saying and say it without the travel. Note that the reduced branch must set the END state — a `from` that never runs leaves its target invisible.",
101
+ "Reduced motion is a design, not an off switch: decide what the motion was saying and say it without the travel. Note that the reduced branch must set the END state — a `from` that never runs leaves its target invisible. If the branch lives in the module that calls this one, waive it here and name that caller.",
100
102
  devToolImport:
101
103
  "A static import ships regardless of any `if` around the call. Load it with a dynamic `import()` behind a NODE_ENV check, or bake its output in and delete it.",
102
104
  markers: 'Gate it: `markers: process.env.NODE_ENV === "development"`.',
@@ -310,6 +312,91 @@ function debounced(handler, node) {
310
312
  );
311
313
  }
312
314
 
315
+ /**
316
+ * Whether a tween's target is a local the enclosing effect captured, and that
317
+ * effect's code kills that local's tweens: `const el = ref.current` in the body,
318
+ * `gsap.killTweensOf(el)` in the cleanup. Found on the corpus, where it was
319
+ * reported as an orphan.
320
+ *
321
+ * Only a captured local counts. `killTweensOf(ref.current)` in a passive
322
+ * cleanup kills nothing at unmount — React has already cleared the ref — and
323
+ * that is `ref-read-in-cleanup`'s to say.
324
+ */
325
+ function killedThroughCapture(ast, node) {
326
+ if (!isGsapCall(node)) return false;
327
+ const target = unwrap(node.arguments[0]);
328
+ if (target?.type !== "Identifier") return false;
329
+
330
+ const effect = ancestorsOf(ast, node).find((fn) => {
331
+ if (!isFunction(fn)) return false;
332
+ const call = parentOf(ast, fn);
333
+ return (
334
+ call?.type === "CallExpression" &&
335
+ call.arguments[0] === fn &&
336
+ EFFECTS.has(calleeName(call)?.split(".").at(-1))
337
+ );
338
+ });
339
+ if (!effect) return false;
340
+
341
+ const captured = findAll(
342
+ effect,
343
+ (n) => n.type === "VariableDeclarator" && n.id.type === "Identifier" && n.id.name === target.name,
344
+ ).length > 0;
345
+ if (!captured) return false;
346
+
347
+ /**
348
+ * In the cleanup — the function the effect returns — and nowhere else. A
349
+ * kill at the top of the body stops the previous run's tweens before
350
+ * starting new ones; it does nothing at unmount. Nine true corpus findings
351
+ * did exactly that, and the first version of this check let them go.
352
+ */
353
+ const cleanups = cleanupsOf(ast, effect);
354
+
355
+ const names = (arg) => {
356
+ const value = unwrap(arg);
357
+ if (value?.type === "Identifier") return [value.name];
358
+ if (value?.type === "ArrayExpression") return value.elements.flatMap((e) => (e ? names(e) : []));
359
+ return [];
360
+ };
361
+ return cleanups.some(
362
+ (cleanup) =>
363
+ findAll(
364
+ cleanup,
365
+ (n) =>
366
+ n.type === "CallExpression" &&
367
+ calleeName(n)?.endsWith("killTweensOf") &&
368
+ n.arguments.some((arg) => names(arg).includes(target.name)),
369
+ ).length > 0,
370
+ );
371
+ }
372
+
373
+ const EFFECTS = new Set(["useEffect", "useLayoutEffect"]);
374
+
375
+ /** The functions an effect callback returns: its cleanups. */
376
+ function cleanupsOf(ast, effect) {
377
+ const cleanups = findAll(
378
+ effect,
379
+ (n) => n.type === "ReturnStatement" && n.argument && scopeOf(ast, n) === effect,
380
+ )
381
+ .map((n) => unwrap(n.argument))
382
+ .filter(isFunction);
383
+ if (effect.type === "ArrowFunctionExpression" && effect.expression && isFunction(unwrap(effect.body))) {
384
+ cleanups.push(unwrap(effect.body));
385
+ }
386
+ return cleanups;
387
+ }
388
+
389
+ /** Whether `node` reads a ref: a `.current` anywhere inside it. */
390
+ const readsRef = (node) =>
391
+ findAll(
392
+ node,
393
+ (n) =>
394
+ n.type === "MemberExpression" &&
395
+ !n.computed &&
396
+ n.property.type === "Identifier" &&
397
+ n.property.name === "current",
398
+ ).length > 0;
399
+
313
400
  /** The calls that add a child to a timeline. */
314
401
  const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
315
402
 
@@ -341,6 +428,22 @@ function tornDown(ast, node) {
341
428
  const kept = keptUnder(ast, node);
342
429
  if (endsUnder(kept?.name)) return true;
343
430
 
431
+ /**
432
+ * A second name for it: `active = timeline`, then `active?.kill()` in the
433
+ * cleanup. Found on the corpus, in a preview whose timeline was built in a
434
+ * helper and handed to a variable the effect owns.
435
+ */
436
+ if (kept?.name) {
437
+ const aliases = findAll(ast, (n) => {
438
+ const from = n.type === "AssignmentExpression" ? n.right : n.type === "VariableDeclarator" ? n.init : null;
439
+ const to = n.type === "AssignmentExpression" ? n.left : n.type === "VariableDeclarator" ? n.id : null;
440
+ return unwrap(from)?.type === "Identifier" && unwrap(from).name === kept.name && to?.type === "Identifier";
441
+ }).map((n) => (n.type === "AssignmentExpression" ? n.left.name : n.id.name));
442
+ if (aliases.some(endsUnder)) return true;
443
+ }
444
+
445
+ if (killedThroughCapture(ast, node)) return true;
446
+
344
447
  const fn = scopeOf(ast, node);
345
448
  if (!isFunction(fn)) return false;
346
449
 
@@ -886,6 +989,62 @@ export const RULES = [
886
989
  });
887
990
  },
888
991
  },
992
+ {
993
+ id: "ref-read-in-cleanup",
994
+ level: "warn",
995
+ description:
996
+ "`killTweensOf` given a ref's `.current` in a `useEffect` cleanup, where React has already cleared it at unmount",
997
+ /**
998
+ * At unmount React detaches a DOM ref before it runs passive cleanups, so
999
+ * `ref.current` is null there and the kill finds nothing. A corpus project
1000
+ * did this in three cleanups, and fifteen tweens outlived their component.
1001
+ *
1002
+ * Narrow on purpose. Only `useEffect`: when a layout cleanup runs relative
1003
+ * to ref detachment is not something this rule is sure of. Only
1004
+ * `killTweensOf`, whose argument is the element: a ref that holds a timeline
1005
+ * — `tlRef.current?.kill()` — is not cleared by React, and is correct.
1006
+ */
1007
+ test(file) {
1008
+ if (!file.isReact) return [];
1009
+ const effects = findAll(
1010
+ file.ast,
1011
+ (n) => n.type === "CallExpression" && calleeName(n)?.split(".").at(-1) === "useEffect",
1012
+ )
1013
+ .map((call) => unwrap(call.arguments[0]))
1014
+ .filter(isFunction);
1015
+
1016
+ return effects.flatMap((effect) =>
1017
+ cleanupsOf(file.ast, effect).flatMap((cleanup) => {
1018
+ /** A local the cleanup itself reads from a ref: `const root = rootRef.current`. */
1019
+ const fromRef = new Set(
1020
+ findAll(
1021
+ cleanup,
1022
+ (n) =>
1023
+ n.type === "VariableDeclarator" &&
1024
+ n.id.type === "Identifier" &&
1025
+ n.init &&
1026
+ readsRef(n.init),
1027
+ ).map((n) => n.id.name),
1028
+ );
1029
+ const viaRef = (arg) =>
1030
+ readsRef(arg) ||
1031
+ findAll(arg, (n) => n.type === "Identifier" && fromRef.has(n.name)).length > 0;
1032
+
1033
+ return findAll(
1034
+ cleanup,
1035
+ (n) =>
1036
+ n.type === "CallExpression" &&
1037
+ calleeName(n)?.endsWith("killTweensOf") &&
1038
+ n.arguments.some(viaRef),
1039
+ ).map((call) => ({
1040
+ index: call.start,
1041
+ message: "`killTweensOf` reads a ref in a useEffect cleanup, which React has already cleared at unmount.",
1042
+ hint: HINT.refReadInCleanup,
1043
+ }));
1044
+ }),
1045
+ );
1046
+ },
1047
+ },
889
1048
  {
890
1049
  id: "dangling-listener",
891
1050
  level: "warn",
@@ -1685,6 +1844,22 @@ export const RULES = [
1685
1844
  if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
1686
1845
  return [];
1687
1846
  }
1847
+ /**
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.
1852
+ */
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 [];
1688
1863
 
1689
1864
  const [first] = findAll(file.ast, (node) => {
1690
1865
  const method = tweenMethod(node);
@@ -241,7 +241,15 @@ export function fromText(raw, path, display = path) {
241
241
  /\bfrom ["']react["']/.test(code) ||
242
242
  /\bfrom ["']@gsap\/react["']/.test(code),
243
243
  isClient: /^\s*["']use client["']/m.test(code),
244
- usesGsap: /\bfrom ["']gsap(?:\/|["'])/.test(code),
244
+ /**
245
+ * GSAP itself, or a binding named `gsap` from anywhere: a project's own
246
+ * wrapper (`import { gsap } from "@/core/motion/gsap"`) is common, and
247
+ * reading only the package left orphan-tween and missing-reduced-motion
248
+ * blind to every file in a real project that used one.
249
+ */
250
+ usesGsap:
251
+ /\bfrom ["']gsap(?:\/|["'])/.test(code) ||
252
+ /\bimport\s+(?!type\b)(?:gsap\b|\{[^}]*\bgsap\b[^}]*\})[^;]*?\bfrom\s*["']/.test(code),
245
253
  };
246
254
  }
247
255
 
@@ -27,7 +27,16 @@ import { writeFileSync } from "node:fs";
27
27
  import { join, resolve } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
29
 
30
- import { connect, findChrome, gpuNote, prepareOut } from "./capture-motion.mjs";
30
+ import {
31
+ connect,
32
+ exitFor,
33
+ findChrome,
34
+ gpuNote,
35
+ parseViewport,
36
+ prepareOut,
37
+ scrollExpression,
38
+ undone,
39
+ } from "./capture-motion.mjs";
31
40
 
32
41
  // --- What a recording means ---------------------------------------------------
33
42
 
@@ -201,7 +210,7 @@ export function afterScroll(found, scrolledAt, { settle = 300 } = {}) {
201
210
  */
202
211
  export function seamEvents(torn, { window = 50 } = {}) {
203
212
  const flat = torn.flatMap((element) =>
204
- element.seams.map((seam) => ({ key: element.key, at: seam.at, jump: seam.jump })),
213
+ element.seams.map((seam) => ({ key: element.key, box: element.box, at: seam.at, jump: seam.jump })),
205
214
  );
206
215
  flat.sort((a, b) => a.at - b.at);
207
216
 
@@ -217,6 +226,7 @@ export function seamEvents(torn, { window = 50 } = {}) {
217
226
  events.push({
218
227
  at: seam.at,
219
228
  elements: new Set([seam.key]),
229
+ box: seam.box,
220
230
  least: seam.jump,
221
231
  most: seam.jump,
222
232
  });
@@ -226,6 +236,12 @@ export function seamEvents(torn, { window = 50 } = {}) {
226
236
  at: event.at,
227
237
  count: event.elements.size,
228
238
  example: [...event.elements][0],
239
+ /**
240
+ * Where the example was on screen, as [x, y, width, height]. A key like
241
+ * `div.absolute` names hundreds of elements in a Tailwind project; a real
242
+ * report could not find the one that tore.
243
+ */
244
+ box: event.box ?? null,
229
245
  least: event.least,
230
246
  most: event.most,
231
247
  }));
@@ -282,6 +298,7 @@ export function parseOptions(argv) {
282
298
  watch: 3000,
283
299
  scroll: null,
284
300
  mobile: false,
301
+ viewport: null,
285
302
  reduced: false,
286
303
  dark: false,
287
304
  cpu: 1,
@@ -337,6 +354,12 @@ export function parseOptions(argv) {
337
354
  case "--mobile":
338
355
  options.mobile = true;
339
356
  break;
357
+ case "--viewport": {
358
+ const size = parseViewport(next());
359
+ if (size) options.viewport = size;
360
+ else options.errors.push("--viewport takes a width and height, such as --viewport 1440x900.");
361
+ break;
362
+ }
340
363
  case "--reduced":
341
364
  options.reduced = true;
342
365
  break;
@@ -364,6 +387,9 @@ export function parseOptions(argv) {
364
387
  if (options.url === null && options.probe === null) {
365
388
  options.errors.push("No URL. Pass the page to review.");
366
389
  }
390
+ if (options.mobile && options.viewport) {
391
+ options.errors.push("--mobile sets its own viewport; drop one of --mobile and --viewport.");
392
+ }
367
393
  return options;
368
394
  }
369
395
 
@@ -606,6 +632,9 @@ async function watch(options) {
606
632
  });
607
633
  await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
608
634
  }
635
+ if (options.viewport) {
636
+ await call("Emulation.setDeviceMetricsOverride", { ...options.viewport, deviceScaleFactor: 1, mobile: false });
637
+ }
609
638
  if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
610
639
 
611
640
  await call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDER });
@@ -614,19 +643,13 @@ async function watch(options) {
614
643
  await call("Page.navigate", { url: options.url });
615
644
  await wait(options.watch);
616
645
 
646
+ const notDone = [];
617
647
  if (options.scroll !== null) {
618
- const pixels = asNumber(options.scroll);
619
- const moved = await evaluate(
620
- pixels === null
621
- ? `(() => {
622
- const element = document.querySelector(${JSON.stringify(options.scroll)});
623
- if (!element) return null;
624
- element.scrollIntoView({ block: "center" });
625
- return Math.round(window.scrollY);
626
- })()`
627
- : `(window.scrollTo(0, ${pixels}), Math.round(window.scrollY))`,
628
- );
629
- notes.push(moved === null ? `scroll: nothing matched ${options.scroll}` : `scroll: to ${moved}px`);
648
+ const step = { type: "scroll", value: options.scroll };
649
+ const moved = await evaluate(scrollExpression(options.scroll));
650
+ const missed = undone(step, moved);
651
+ if (missed) notDone.push(missed);
652
+ else notes.push(`scroll: to ${moved.y}px`);
630
653
  await wait(options.watch);
631
654
  }
632
655
 
@@ -658,6 +681,7 @@ async function watch(options) {
658
681
  recalcs: delta("RecalcStyleCount"),
659
682
  shot,
660
683
  notes,
684
+ notDone,
661
685
  };
662
686
  } finally {
663
687
  await browser.close();
@@ -678,6 +702,7 @@ export function conclude(result) {
678
702
  /** One track per element, to find the loops that tear. */
679
703
  const tracks = new Map();
680
704
  const named = new Map();
705
+ const boxes = new Map();
681
706
  for (const frame of log) {
682
707
  for (const moved of frame.moved) {
683
708
  const id = moved.id ?? moved.key;
@@ -685,6 +710,7 @@ export function conclude(result) {
685
710
  track.push({ at: frame.at, x: moved.x });
686
711
  tracks.set(id, track);
687
712
  named.set(id, moved.key);
713
+ boxes.set(id, moved.box);
688
714
  }
689
715
  }
690
716
  const scrolledAt = log.filter((frame) => frame.scrolled).map((frame) => frame.at);
@@ -694,7 +720,12 @@ export function conclude(result) {
694
720
  if (track.length < 20) continue;
695
721
  const found = afterScroll(seams(track), scrolledAt);
696
722
  if (found.length) {
697
- torn.push({ key: named.get(key) ?? key, seams: found.slice(0, 3), frames: track.length });
723
+ torn.push({
724
+ key: named.get(key) ?? key,
725
+ box: boxes.get(key) ?? null,
726
+ seams: found.slice(0, 3),
727
+ frames: track.length,
728
+ });
698
729
  }
699
730
  }
700
731
 
@@ -775,6 +806,8 @@ export function report(result, options) {
775
806
  options.headed && "headed, on the GPU",
776
807
  ].filter(Boolean);
777
808
  if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
809
+ const { viewport } = result.recorded;
810
+ if (viewport) lines.push(` viewport: ${viewport.width}x${viewport.height}`);
778
811
  for (const note of result.notes) lines.push(` ${note}`);
779
812
  const gpu = gpuNote(found.canvases, options);
780
813
  if (gpu) lines.push(` ${gpu}`);
@@ -787,7 +820,7 @@ export function report(result, options) {
787
820
  lines.push(` first motion at ${found.firstMotion}ms`);
788
821
  lines.push(
789
822
  ` ${found.movers} elements moved, ${found.gsapMovers} of them GSAP's` +
790
- `${found.movers > found.gsapMovers ? " (the rest is CSS, which the source audit cannot see)" : ""}`,
823
+ `${found.movers > found.gsapMovers ? " (the rest is CSS, Web Animations or other script, which the source audit cannot see)" : ""}`,
791
824
  );
792
825
  if (found.spread) lines.push(` they started over a span of ${found.spread}ms`);
793
826
  }
@@ -838,9 +871,10 @@ export function report(result, options) {
838
871
  lines.push("", "Seams — a step far larger than the motion's own rhythm");
839
872
  for (const event of found.seams.slice(0, 6)) {
840
873
  const size = event.least === event.most ? `${event.most}px` : `${event.least}-${event.most}px`;
874
+ const where = event.box ? ` at ${event.box.map(Math.round).join(",")}` : "";
841
875
  lines.push(
842
876
  ` ${event.at}ms ${event.count === 1 ? event.example : `${event.count} elements (${event.example})`}` +
843
- ` jumps ${size}`,
877
+ `${where} jumps ${size}`,
844
878
  );
845
879
  }
846
880
  }
@@ -855,6 +889,11 @@ export function report(result, options) {
855
889
  if (result.shot) lines.push("", `Frame: ${result.shot}`);
856
890
 
857
891
  lines.push("", "These are measurements. Composition, rhythm and taste are yours to judge.");
892
+ /** Last, so a reader who sees only the end still sees it. */
893
+ if (result.notDone?.length) {
894
+ lines.push("", "Not done — the run exits non-zero");
895
+ for (const line of result.notDone) lines.push(` ${line}`);
896
+ }
858
897
  return lines.join("\n");
859
898
  }
860
899
 
@@ -877,9 +916,10 @@ async function main(argv) {
877
916
  const result = await watch(options);
878
917
  console.log(
879
918
  options.json
880
- ? JSON.stringify({ url: result.url, ...conclude(result) }, null, 2)
919
+ ? JSON.stringify({ url: result.url, ...conclude(result), notDone: result.notDone }, null, 2)
881
920
  : report(result, options),
882
921
  );
922
+ process.exitCode = exitFor(result.notDone);
883
923
  } catch (error) {
884
924
  console.error(error.message);
885
925
  process.exit(1);