@mehshekari/gsap-motion 4.4.2 → 4.6.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 +1 -1
- package/bin/gsap-motion.mjs +13 -2
- package/package.json +1 -1
- package/skill/SKILL.md +4 -4
- package/skill/adapter/react.md +3 -4
- package/skill/reference/refine.md +11 -2
- package/skill/scripts/audit-gsap.mjs +43 -4
- package/skill/scripts/capture-motion.mjs +142 -35
- package/skill/scripts/lib/ast.mjs +72 -5
- package/skill/scripts/lib/rules.mjs +217 -4
- package/skill/scripts/lib/source.mjs +9 -1
- package/skill/scripts/review-motion.mjs +103 -19
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.0 audit src --quiet"
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
package/bin/gsap-motion.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
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.0"
|
|
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).
|
package/skill/adapter/react.md
CHANGED
|
@@ -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
|
|
38
|
-
|
|
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*.
|
|
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.
|
|
@@ -120,9 +129,9 @@ that no source rule can reach.
|
|
|
120
129
|
| It says | Read it as |
|
|
121
130
|
| --- | --- |
|
|
122
131
|
| **first motion at N ms** | How long the page is still. Late enough and the visitor has scrolled past the thing you built. |
|
|
123
|
-
| **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. |
|
|
124
133
|
| **X% of the screen in motion** | Everything moving at once reads as noise, not as choreography. |
|
|
125
|
-
| **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. |
|
|
126
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. |
|
|
127
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. |
|
|
128
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,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";
|
|
36
|
-
import { resolve } from "node:path";
|
|
35
|
+
import { existsSync, statSync } from "node:fs";
|
|
36
|
+
import { dirname, 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,14 @@ for (const path of paths) {
|
|
|
66
66
|
|
|
67
67
|
const findings = [];
|
|
68
68
|
const sources = [];
|
|
69
|
+
/** Every file read, GSAP or not: the import graph needs callers that never animate. */
|
|
70
|
+
const loaded = [];
|
|
71
|
+
/**
|
|
72
|
+
* A file named on the command line that the audit cannot read — a stylesheet,
|
|
73
|
+
* say. It used to be skipped in silence and the run printed "clean", which
|
|
74
|
+
* reads as "checked and fine" about a file nothing looked at.
|
|
75
|
+
*/
|
|
76
|
+
const notScanned = [];
|
|
69
77
|
|
|
70
78
|
/** A finding as the report shows it: under the file's display path, no column. */
|
|
71
79
|
const reported = (source, finding) => ({
|
|
@@ -78,8 +86,13 @@ const reported = (source, finding) => ({
|
|
|
78
86
|
});
|
|
79
87
|
|
|
80
88
|
for (const path of paths) {
|
|
89
|
+
if (statSync(path).isFile() && !hasSourceExtension(path)) {
|
|
90
|
+
notScanned.push(path);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
81
93
|
for (const file of collect(path)) {
|
|
82
94
|
const source = load(file, ROOT);
|
|
95
|
+
loaded.push(source);
|
|
83
96
|
if (!mentionsGsap(source)) continue;
|
|
84
97
|
|
|
85
98
|
if (source.parseError) {
|
|
@@ -100,6 +113,22 @@ for (const path of paths) {
|
|
|
100
113
|
*/
|
|
101
114
|
const registered = new Set(sources.flatMap((source) => [...pluginRegistrations(source)]));
|
|
102
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Who imports each file, among everything read — a caller need not use GSAP
|
|
118
|
+
* itself. Relative imports only: an alias such as `@/…` is not resolved, and a
|
|
119
|
+
* file whose importers are unknown is judged on its own, as before.
|
|
120
|
+
*/
|
|
121
|
+
const byPath = new Map(loaded.map((source) => [resolve(source.path), source]));
|
|
122
|
+
const EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", "/index.ts", "/index.tsx", "/index.js"];
|
|
123
|
+
for (const source of loaded) source.importers = [];
|
|
124
|
+
for (const source of loaded) {
|
|
125
|
+
for (const [, specifier] of source.code.matchAll(/\bimport\s+(?!type\b)[^;]*?\bfrom\s*["'](\.{1,2}\/[^"']+)["']/g)) {
|
|
126
|
+
const base = resolve(dirname(source.path), specifier);
|
|
127
|
+
const target = EXTENSIONS.map((ext) => byPath.get(base + ext)).find(Boolean);
|
|
128
|
+
if (target && target !== source) target.importers.push(source);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
for (const source of sources) {
|
|
104
133
|
source.registeredElsewhere = registered;
|
|
105
134
|
|
|
@@ -124,8 +153,14 @@ findings.sort(
|
|
|
124
153
|
|
|
125
154
|
const shown = quiet ? findings.filter((f) => f.level === "error") : findings;
|
|
126
155
|
|
|
156
|
+
const READS = "the audit reads .ts, .tsx, .js, .jsx and .mjs, and the scripts of .vue, .svelte and .astro files";
|
|
157
|
+
if (notScanned.length === paths.length) {
|
|
158
|
+
for (const path of notScanned) console.error(`Not scanned: ${path} — ${READS}.`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
|
|
127
162
|
if (asJson) {
|
|
128
|
-
console.log(JSON.stringify({ findings: shown }, null, 2));
|
|
163
|
+
console.log(JSON.stringify({ findings: shown, notScanned }, null, 2));
|
|
129
164
|
} else if (!shown.length) {
|
|
130
165
|
console.log(
|
|
131
166
|
findings.length
|
|
@@ -157,4 +192,8 @@ if (asJson) {
|
|
|
157
192
|
);
|
|
158
193
|
}
|
|
159
194
|
|
|
195
|
+
if (!asJson) {
|
|
196
|
+
for (const path of notScanned) console.log(`Not scanned: ${path} — ${READS}.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
160
199
|
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
|
|
21
|
-
* --wait 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,106 @@ 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
|
-
/**
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
+
/** 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
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
matches: all.length,
|
|
440
|
+
index,
|
|
441
|
+
others,
|
|
442
|
+
scrolled: !inView,
|
|
443
|
+
x: clamp(box.x + box.width / 2, innerWidth),
|
|
444
|
+
y: clamp(box.y + box.height / 2, innerHeight),
|
|
445
|
+
width: box.width,
|
|
446
|
+
height: box.height,
|
|
447
|
+
};
|
|
391
448
|
})()`;
|
|
392
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Scrolls instantly, then reports where the page is once two frames have drawn.
|
|
452
|
+
* A page with `scroll-behavior: smooth` used to animate the jump, so the
|
|
453
|
+
* position was read as 0 while it travelled, and frames were captured mid-way.
|
|
454
|
+
*/
|
|
455
|
+
export function scrollExpression(value) {
|
|
456
|
+
const pixels = /^\d+$/.test(String(value)) ? Number(value) : null;
|
|
457
|
+
const move =
|
|
458
|
+
pixels === null
|
|
459
|
+
? `let el;
|
|
460
|
+
try { el = document.querySelector(${JSON.stringify(value)}); } catch { done({ invalid: true }); return; }
|
|
461
|
+
if (!el) { done({ matches: 0 }); return; }
|
|
462
|
+
el.scrollIntoView({ block: "center", behavior: "instant" });`
|
|
463
|
+
: `window.scrollTo({ top: ${pixels}, behavior: "instant" });`;
|
|
464
|
+
return `new Promise((done) => {
|
|
465
|
+
${move}
|
|
466
|
+
requestAnimationFrame(() => requestAnimationFrame(() => done({ y: Math.round(window.scrollY) })));
|
|
467
|
+
})`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** What a step that did nothing says, or null when it did something. */
|
|
471
|
+
export function undone(step, result) {
|
|
472
|
+
if (result?.invalid) return `${step.type}: ${step.value} is not a valid selector`;
|
|
473
|
+
if (!result || result.matches === 0) return `${step.type}: nothing matched ${step.value}`;
|
|
474
|
+
if (result.index === -1) {
|
|
475
|
+
const n = result.matches;
|
|
476
|
+
return `${step.type}: ${step.value} matched ${n} element${n === 1 ? "" : "s"}, none with a box on screen`;
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The exit code a run earns: 2 for an invalid selector, 3 when a step asked for
|
|
483
|
+
* did nothing, 0 otherwise. A step that did nothing used to leave a note in the
|
|
484
|
+
* middle of the report and exit 0, which a script — or a reader with `| tail` —
|
|
485
|
+
* took as done.
|
|
486
|
+
*/
|
|
487
|
+
export const exitFor = (notDone) =>
|
|
488
|
+
notDone.some((line) => line.endsWith("is not a valid selector")) ? 2 : notDone.length ? 3 : 0;
|
|
489
|
+
|
|
490
|
+
/** `--viewport 1440x900`, as numbers, or null. */
|
|
491
|
+
export function parseViewport(text) {
|
|
492
|
+
const match = /^(\d{2,5})x(\d{2,5})$/.exec(String(text ?? ""));
|
|
493
|
+
return match ? { width: Number(match[1]), height: Number(match[2]) } : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** What the page measured itself at, for every report: a default nobody chose is still a size. */
|
|
497
|
+
export const VIEWPORT = "({ width: innerWidth, height: innerHeight })";
|
|
498
|
+
|
|
393
499
|
// --- Where output goes -------------------------------------------------------
|
|
394
500
|
|
|
395
501
|
/**
|
|
@@ -483,6 +589,9 @@ async function capture(options) {
|
|
|
483
589
|
});
|
|
484
590
|
await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
|
485
591
|
}
|
|
592
|
+
if (options.viewport) {
|
|
593
|
+
await call("Emulation.setDeviceMetricsOverride", { ...options.viewport, deviceScaleFactor: 1, mobile: false });
|
|
594
|
+
}
|
|
486
595
|
|
|
487
596
|
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
488
597
|
|
|
@@ -492,36 +601,20 @@ async function capture(options) {
|
|
|
492
601
|
await call("Page.navigate", { url: options.url });
|
|
493
602
|
await wait(options.wait);
|
|
494
603
|
|
|
604
|
+
const notDone = [];
|
|
495
605
|
for (const step of options.steps) {
|
|
496
606
|
if (step.type === "scroll") {
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
|
|
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
|
-
);
|
|
607
|
+
const moved = await evaluate(scrollExpression(step.value));
|
|
608
|
+
const missed = undone(step, moved);
|
|
609
|
+
if (missed) notDone.push(missed);
|
|
610
|
+
else notes.push(`scroll: to ${moved.y}px`);
|
|
513
611
|
}
|
|
514
612
|
|
|
515
613
|
if (step.type === "hover" || step.type === "click") {
|
|
516
|
-
const box = await evaluate(
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
);
|
|
614
|
+
const box = await evaluate(pointAt(step.value));
|
|
615
|
+
const missed = undone(step, box) ?? (pointable(box) ? null : undone(step, { ...box, index: -1 }));
|
|
616
|
+
if (missed) {
|
|
617
|
+
notDone.push(missed);
|
|
525
618
|
continue;
|
|
526
619
|
}
|
|
527
620
|
/**
|
|
@@ -550,8 +643,14 @@ async function capture(options) {
|
|
|
550
643
|
});
|
|
551
644
|
}
|
|
552
645
|
}
|
|
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
|
+
: "";
|
|
553
650
|
notes.push(
|
|
554
|
-
`${step.type}: ${step.value} at ${Math.round(box.x)},${Math.round(box.y)}
|
|
651
|
+
`${step.type}: ${step.value}${which} at ${Math.round(box.x)},${Math.round(box.y)}` +
|
|
652
|
+
(box.scrolled ? ", scrolled into view first" : "") +
|
|
653
|
+
elsewhere,
|
|
555
654
|
);
|
|
556
655
|
await wait(120);
|
|
557
656
|
}
|
|
@@ -587,6 +686,7 @@ async function capture(options) {
|
|
|
587
686
|
"({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
|
|
588
687
|
);
|
|
589
688
|
const canvases = await evaluate("document.querySelectorAll(\"canvas\").length");
|
|
689
|
+
const viewport = await evaluate(VIEWPORT);
|
|
590
690
|
const after = await call("Performance.getMetrics");
|
|
591
691
|
|
|
592
692
|
const delta = (name) => {
|
|
@@ -611,7 +711,7 @@ async function capture(options) {
|
|
|
611
711
|
overMs: span,
|
|
612
712
|
};
|
|
613
713
|
|
|
614
|
-
return { url: options.url, frames, timeline, measured, notes, canvases, chrome: executable };
|
|
714
|
+
return { url: options.url, frames, timeline, measured, notes, notDone, canvases, viewport, chrome: executable };
|
|
615
715
|
} finally {
|
|
616
716
|
await browser.close();
|
|
617
717
|
}
|
|
@@ -631,6 +731,7 @@ export function report(result, options) {
|
|
|
631
731
|
options.seek && `seeking with window.${options.seek}`,
|
|
632
732
|
].filter(Boolean);
|
|
633
733
|
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
734
|
+
if (result.viewport) lines.push(` viewport: ${result.viewport.width}x${result.viewport.height}`);
|
|
634
735
|
for (const note of result.notes) lines.push(` ${note}`);
|
|
635
736
|
const gpu = gpuNote(result.canvases, options);
|
|
636
737
|
if (gpu) lines.push(` ${gpu}`);
|
|
@@ -681,6 +782,11 @@ export function report(result, options) {
|
|
|
681
782
|
` · ${measured.recalcs} style recalculations (${measured.recalcSeconds}s)`,
|
|
682
783
|
);
|
|
683
784
|
lines.push("", "These are observations. What they mean is yours to judge.");
|
|
785
|
+
/** Last, so a reader who sees only the end still sees it. */
|
|
786
|
+
if (result.notDone?.length) {
|
|
787
|
+
lines.push("", "Not done — the run exits non-zero");
|
|
788
|
+
for (const line of result.notDone) lines.push(` ${line}`);
|
|
789
|
+
}
|
|
684
790
|
|
|
685
791
|
return lines.join("\n");
|
|
686
792
|
}
|
|
@@ -704,6 +810,7 @@ async function main(argv) {
|
|
|
704
810
|
try {
|
|
705
811
|
const result = await capture(options);
|
|
706
812
|
console.log(options.json ? JSON.stringify(result, null, 2) : report(result, options));
|
|
813
|
+
process.exitCode = exitFor(result.notDone);
|
|
707
814
|
} catch (error) {
|
|
708
815
|
console.error(error.message);
|
|
709
816
|
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();
|
|
@@ -581,10 +602,56 @@ export function gsapContexts(root) {
|
|
|
581
602
|
functions.set(name, node.type === "FunctionDeclaration" ? node : unwrap(node.init));
|
|
582
603
|
}
|
|
583
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
|
+
|
|
584
651
|
const contained = new Set();
|
|
585
652
|
const inside = (node) =>
|
|
586
|
-
scopes.some((argument) =>
|
|
587
|
-
[...contained].some((fn) =>
|
|
653
|
+
scopes.some((argument) => runsWithin(argument, node)) ||
|
|
654
|
+
[...contained].some((fn) => runsWithin(fn, node));
|
|
588
655
|
const identifiers = findAll(root, (node) => node.type === "Identifier");
|
|
589
656
|
|
|
590
657
|
for (let changed = true; changed; ) {
|
|
@@ -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
|
|
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:
|
|
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"`.',
|
|
@@ -248,7 +250,32 @@ function listenerMethod(node) {
|
|
|
248
250
|
* `followNames`, a handler passed by name is followed to its declaration when
|
|
249
251
|
* the file declares exactly one function of that name.
|
|
250
252
|
*/
|
|
251
|
-
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 } = {}) {
|
|
252
279
|
const handler = (expression) => {
|
|
253
280
|
const node = unwrap(expression);
|
|
254
281
|
if (node?.type === "CallExpression") return node;
|
|
@@ -310,6 +337,113 @@ function debounced(handler, node) {
|
|
|
310
337
|
);
|
|
311
338
|
}
|
|
312
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Whether a tween's target is a local the enclosing effect captured, and that
|
|
342
|
+
* effect's code kills that local's tweens: `const el = ref.current` in the body,
|
|
343
|
+
* `gsap.killTweensOf(el)` in the cleanup. Found on the corpus, where it was
|
|
344
|
+
* reported as an orphan.
|
|
345
|
+
*
|
|
346
|
+
* Only a captured local counts. `killTweensOf(ref.current)` in a passive
|
|
347
|
+
* cleanup kills nothing at unmount — React has already cleared the ref — and
|
|
348
|
+
* that is `ref-read-in-cleanup`'s to say.
|
|
349
|
+
*/
|
|
350
|
+
function killedThroughCapture(ast, node) {
|
|
351
|
+
if (!isGsapCall(node)) return false;
|
|
352
|
+
const target = unwrap(node.arguments[0]);
|
|
353
|
+
if (target?.type !== "Identifier") return false;
|
|
354
|
+
|
|
355
|
+
const effect = ancestorsOf(ast, node).find((fn) => {
|
|
356
|
+
if (!isFunction(fn)) return false;
|
|
357
|
+
const call = parentOf(ast, fn);
|
|
358
|
+
return (
|
|
359
|
+
call?.type === "CallExpression" &&
|
|
360
|
+
call.arguments[0] === fn &&
|
|
361
|
+
EFFECTS.has(calleeName(call)?.split(".").at(-1))
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
if (!effect) return false;
|
|
365
|
+
|
|
366
|
+
const captured = findAll(
|
|
367
|
+
effect,
|
|
368
|
+
(n) => n.type === "VariableDeclarator" && n.id.type === "Identifier" && n.id.name === target.name,
|
|
369
|
+
).length > 0;
|
|
370
|
+
if (!captured) return false;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* In the cleanup — the function the effect returns — and nowhere else. A
|
|
374
|
+
* kill at the top of the body stops the previous run's tweens before
|
|
375
|
+
* starting new ones; it does nothing at unmount. Nine true corpus findings
|
|
376
|
+
* did exactly that, and the first version of this check let them go.
|
|
377
|
+
*/
|
|
378
|
+
const cleanups = cleanupsOf(ast, effect);
|
|
379
|
+
|
|
380
|
+
const names = (arg) => {
|
|
381
|
+
const value = unwrap(arg);
|
|
382
|
+
if (value?.type === "Identifier") return [value.name];
|
|
383
|
+
if (value?.type === "ArrayExpression") return value.elements.flatMap((e) => (e ? names(e) : []));
|
|
384
|
+
return [];
|
|
385
|
+
};
|
|
386
|
+
return cleanups.some(
|
|
387
|
+
(cleanup) =>
|
|
388
|
+
findAll(
|
|
389
|
+
cleanup,
|
|
390
|
+
(n) =>
|
|
391
|
+
n.type === "CallExpression" &&
|
|
392
|
+
calleeName(n)?.endsWith("killTweensOf") &&
|
|
393
|
+
n.arguments.some((arg) => names(arg).includes(target.name)),
|
|
394
|
+
).length > 0,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const EFFECTS = new Set(["useEffect", "useLayoutEffect"]);
|
|
399
|
+
|
|
400
|
+
/** The functions an effect callback returns: its cleanups. */
|
|
401
|
+
function cleanupsOf(ast, effect) {
|
|
402
|
+
const cleanups = findAll(
|
|
403
|
+
effect,
|
|
404
|
+
(n) => n.type === "ReturnStatement" && n.argument && scopeOf(ast, n) === effect,
|
|
405
|
+
)
|
|
406
|
+
.map((n) => unwrap(n.argument))
|
|
407
|
+
.filter(isFunction);
|
|
408
|
+
if (effect.type === "ArrowFunctionExpression" && effect.expression && isFunction(unwrap(effect.body))) {
|
|
409
|
+
cleanups.push(unwrap(effect.body));
|
|
410
|
+
}
|
|
411
|
+
return cleanups;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Whether `node` reads a ref: a `.current` anywhere inside it. */
|
|
415
|
+
const readsRef = (node) =>
|
|
416
|
+
findAll(
|
|
417
|
+
node,
|
|
418
|
+
(n) =>
|
|
419
|
+
n.type === "MemberExpression" &&
|
|
420
|
+
!n.computed &&
|
|
421
|
+
n.property.type === "Identifier" &&
|
|
422
|
+
n.property.name === "current",
|
|
423
|
+
).length > 0;
|
|
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
|
+
|
|
313
447
|
/** The calls that add a child to a timeline. */
|
|
314
448
|
const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
|
|
315
449
|
|
|
@@ -341,6 +475,22 @@ function tornDown(ast, node) {
|
|
|
341
475
|
const kept = keptUnder(ast, node);
|
|
342
476
|
if (endsUnder(kept?.name)) return true;
|
|
343
477
|
|
|
478
|
+
/**
|
|
479
|
+
* A second name for it: `active = timeline`, then `active?.kill()` in the
|
|
480
|
+
* cleanup. Found on the corpus, in a preview whose timeline was built in a
|
|
481
|
+
* helper and handed to a variable the effect owns.
|
|
482
|
+
*/
|
|
483
|
+
if (kept?.name) {
|
|
484
|
+
const aliases = findAll(ast, (n) => {
|
|
485
|
+
const from = n.type === "AssignmentExpression" ? n.right : n.type === "VariableDeclarator" ? n.init : null;
|
|
486
|
+
const to = n.type === "AssignmentExpression" ? n.left : n.type === "VariableDeclarator" ? n.id : null;
|
|
487
|
+
return unwrap(from)?.type === "Identifier" && unwrap(from).name === kept.name && to?.type === "Identifier";
|
|
488
|
+
}).map((n) => (n.type === "AssignmentExpression" ? n.left.name : n.id.name));
|
|
489
|
+
if (aliases.some(endsUnder)) return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (killedThroughCapture(ast, node)) return true;
|
|
493
|
+
|
|
344
494
|
const fn = scopeOf(ast, node);
|
|
345
495
|
if (!isFunction(fn)) return false;
|
|
346
496
|
|
|
@@ -886,6 +1036,62 @@ export const RULES = [
|
|
|
886
1036
|
});
|
|
887
1037
|
},
|
|
888
1038
|
},
|
|
1039
|
+
{
|
|
1040
|
+
id: "ref-read-in-cleanup",
|
|
1041
|
+
level: "warn",
|
|
1042
|
+
description:
|
|
1043
|
+
"`killTweensOf` given a ref's `.current` in a `useEffect` cleanup, where React has already cleared it at unmount",
|
|
1044
|
+
/**
|
|
1045
|
+
* At unmount React detaches a DOM ref before it runs passive cleanups, so
|
|
1046
|
+
* `ref.current` is null there and the kill finds nothing. A corpus project
|
|
1047
|
+
* did this in three cleanups, and fifteen tweens outlived their component.
|
|
1048
|
+
*
|
|
1049
|
+
* Narrow on purpose. Only `useEffect`: when a layout cleanup runs relative
|
|
1050
|
+
* to ref detachment is not something this rule is sure of. Only
|
|
1051
|
+
* `killTweensOf`, whose argument is the element: a ref that holds a timeline
|
|
1052
|
+
* — `tlRef.current?.kill()` — is not cleared by React, and is correct.
|
|
1053
|
+
*/
|
|
1054
|
+
test(file) {
|
|
1055
|
+
if (!file.isReact) return [];
|
|
1056
|
+
const effects = findAll(
|
|
1057
|
+
file.ast,
|
|
1058
|
+
(n) => n.type === "CallExpression" && calleeName(n)?.split(".").at(-1) === "useEffect",
|
|
1059
|
+
)
|
|
1060
|
+
.map((call) => unwrap(call.arguments[0]))
|
|
1061
|
+
.filter(isFunction);
|
|
1062
|
+
|
|
1063
|
+
return effects.flatMap((effect) =>
|
|
1064
|
+
cleanupsOf(file.ast, effect).flatMap((cleanup) => {
|
|
1065
|
+
/** A local the cleanup itself reads from a ref: `const root = rootRef.current`. */
|
|
1066
|
+
const fromRef = new Set(
|
|
1067
|
+
findAll(
|
|
1068
|
+
cleanup,
|
|
1069
|
+
(n) =>
|
|
1070
|
+
n.type === "VariableDeclarator" &&
|
|
1071
|
+
n.id.type === "Identifier" &&
|
|
1072
|
+
n.init &&
|
|
1073
|
+
readsRef(n.init),
|
|
1074
|
+
).map((n) => n.id.name),
|
|
1075
|
+
);
|
|
1076
|
+
const viaRef = (arg) =>
|
|
1077
|
+
readsRef(arg) ||
|
|
1078
|
+
findAll(arg, (n) => n.type === "Identifier" && fromRef.has(n.name)).length > 0;
|
|
1079
|
+
|
|
1080
|
+
return findAll(
|
|
1081
|
+
cleanup,
|
|
1082
|
+
(n) =>
|
|
1083
|
+
n.type === "CallExpression" &&
|
|
1084
|
+
calleeName(n)?.endsWith("killTweensOf") &&
|
|
1085
|
+
n.arguments.some(viaRef),
|
|
1086
|
+
).map((call) => ({
|
|
1087
|
+
index: call.start,
|
|
1088
|
+
message: "`killTweensOf` reads a ref in a useEffect cleanup, which React has already cleared at unmount.",
|
|
1089
|
+
hint: HINT.refReadInCleanup,
|
|
1090
|
+
}));
|
|
1091
|
+
}),
|
|
1092
|
+
);
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
889
1095
|
{
|
|
890
1096
|
id: "dangling-listener",
|
|
891
1097
|
level: "warn",
|
|
@@ -1682,7 +1888,14 @@ export const RULES = [
|
|
|
1682
1888
|
*/
|
|
1683
1889
|
test(file) {
|
|
1684
1890
|
if (!file.usesGsap) return [];
|
|
1685
|
-
if (
|
|
1891
|
+
if (hasReducedBranch(file)) return [];
|
|
1892
|
+
/**
|
|
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.
|
|
1897
|
+
*/
|
|
1898
|
+
if (file.importers?.length && file.importers.every((importer) => !importer.parseError && hasReducedBranch(importer))) {
|
|
1686
1899
|
return [];
|
|
1687
1900
|
}
|
|
1688
1901
|
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
|
@@ -125,8 +134,15 @@ export function seams(track, { factor = 6, minimum = 4, least = 16 } = {}) {
|
|
|
125
134
|
/** A still element has no normal step to be unlike, so nothing is a seam. */
|
|
126
135
|
if (!median) return [];
|
|
127
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
|
+
|
|
128
144
|
return steps
|
|
129
|
-
.filter((step) => step.delta > median * factor && step.delta >= least)
|
|
145
|
+
.filter((step, index) => step.delta > median * factor && step.delta >= least && resumes(index))
|
|
130
146
|
.map((step) => ({ at: step.at, jump: Math.round(step.delta * 10) / 10 }));
|
|
131
147
|
}
|
|
132
148
|
|
|
@@ -201,7 +217,7 @@ export function afterScroll(found, scrolledAt, { settle = 300 } = {}) {
|
|
|
201
217
|
*/
|
|
202
218
|
export function seamEvents(torn, { window = 50 } = {}) {
|
|
203
219
|
const flat = torn.flatMap((element) =>
|
|
204
|
-
element.seams.map((seam) => ({ key: element.key, at: seam.at, jump: seam.jump })),
|
|
220
|
+
element.seams.map((seam) => ({ key: element.key, box: element.box, at: seam.at, jump: seam.jump })),
|
|
205
221
|
);
|
|
206
222
|
flat.sort((a, b) => a.at - b.at);
|
|
207
223
|
|
|
@@ -217,6 +233,7 @@ export function seamEvents(torn, { window = 50 } = {}) {
|
|
|
217
233
|
events.push({
|
|
218
234
|
at: seam.at,
|
|
219
235
|
elements: new Set([seam.key]),
|
|
236
|
+
box: seam.box,
|
|
220
237
|
least: seam.jump,
|
|
221
238
|
most: seam.jump,
|
|
222
239
|
});
|
|
@@ -226,6 +243,12 @@ export function seamEvents(torn, { window = 50 } = {}) {
|
|
|
226
243
|
at: event.at,
|
|
227
244
|
count: event.elements.size,
|
|
228
245
|
example: [...event.elements][0],
|
|
246
|
+
/**
|
|
247
|
+
* Where the example was on screen, as [x, y, width, height]. A key like
|
|
248
|
+
* `div.absolute` names hundreds of elements in a Tailwind project; a real
|
|
249
|
+
* report could not find the one that tore.
|
|
250
|
+
*/
|
|
251
|
+
box: event.box ?? null,
|
|
229
252
|
least: event.least,
|
|
230
253
|
most: event.most,
|
|
231
254
|
}));
|
|
@@ -258,6 +281,29 @@ export function longFrameSummary(longFrames, cost = []) {
|
|
|
258
281
|
};
|
|
259
282
|
}
|
|
260
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
|
+
|
|
261
307
|
/** Each element and the moment it first moved, in that order. */
|
|
262
308
|
export function starts(log) {
|
|
263
309
|
const first = new Map();
|
|
@@ -282,6 +328,7 @@ export function parseOptions(argv) {
|
|
|
282
328
|
watch: 3000,
|
|
283
329
|
scroll: null,
|
|
284
330
|
mobile: false,
|
|
331
|
+
viewport: null,
|
|
285
332
|
reduced: false,
|
|
286
333
|
dark: false,
|
|
287
334
|
cpu: 1,
|
|
@@ -337,6 +384,12 @@ export function parseOptions(argv) {
|
|
|
337
384
|
case "--mobile":
|
|
338
385
|
options.mobile = true;
|
|
339
386
|
break;
|
|
387
|
+
case "--viewport": {
|
|
388
|
+
const size = parseViewport(next());
|
|
389
|
+
if (size) options.viewport = size;
|
|
390
|
+
else options.errors.push("--viewport takes a width and height, such as --viewport 1440x900.");
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
340
393
|
case "--reduced":
|
|
341
394
|
options.reduced = true;
|
|
342
395
|
break;
|
|
@@ -364,6 +417,9 @@ export function parseOptions(argv) {
|
|
|
364
417
|
if (options.url === null && options.probe === null) {
|
|
365
418
|
options.errors.push("No URL. Pass the page to review.");
|
|
366
419
|
}
|
|
420
|
+
if (options.mobile && options.viewport) {
|
|
421
|
+
options.errors.push("--mobile sets its own viewport; drop one of --mobile and --viewport.");
|
|
422
|
+
}
|
|
367
423
|
return options;
|
|
368
424
|
}
|
|
369
425
|
|
|
@@ -501,6 +557,11 @@ const RECORDER = `(() => {
|
|
|
501
557
|
id: idOf(el),
|
|
502
558
|
key: keyOf(el),
|
|
503
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",
|
|
504
565
|
box: state.screen,
|
|
505
566
|
x: state.x,
|
|
506
567
|
opacity: state.o,
|
|
@@ -606,6 +667,9 @@ async function watch(options) {
|
|
|
606
667
|
});
|
|
607
668
|
await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
|
608
669
|
}
|
|
670
|
+
if (options.viewport) {
|
|
671
|
+
await call("Emulation.setDeviceMetricsOverride", { ...options.viewport, deviceScaleFactor: 1, mobile: false });
|
|
672
|
+
}
|
|
609
673
|
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
610
674
|
|
|
611
675
|
await call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDER });
|
|
@@ -614,19 +678,13 @@ async function watch(options) {
|
|
|
614
678
|
await call("Page.navigate", { url: options.url });
|
|
615
679
|
await wait(options.watch);
|
|
616
680
|
|
|
681
|
+
const notDone = [];
|
|
617
682
|
if (options.scroll !== null) {
|
|
618
|
-
const
|
|
619
|
-
const moved = await evaluate(
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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`);
|
|
683
|
+
const step = { type: "scroll", value: options.scroll };
|
|
684
|
+
const moved = await evaluate(scrollExpression(options.scroll));
|
|
685
|
+
const missed = undone(step, moved);
|
|
686
|
+
if (missed) notDone.push(missed);
|
|
687
|
+
else notes.push(`scroll: to ${moved.y}px`);
|
|
630
688
|
await wait(options.watch);
|
|
631
689
|
}
|
|
632
690
|
|
|
@@ -658,6 +716,7 @@ async function watch(options) {
|
|
|
658
716
|
recalcs: delta("RecalcStyleCount"),
|
|
659
717
|
shot,
|
|
660
718
|
notes,
|
|
719
|
+
notDone,
|
|
661
720
|
};
|
|
662
721
|
} finally {
|
|
663
722
|
await browser.close();
|
|
@@ -678,6 +737,7 @@ export function conclude(result) {
|
|
|
678
737
|
/** One track per element, to find the loops that tear. */
|
|
679
738
|
const tracks = new Map();
|
|
680
739
|
const named = new Map();
|
|
740
|
+
const boxes = new Map();
|
|
681
741
|
for (const frame of log) {
|
|
682
742
|
for (const moved of frame.moved) {
|
|
683
743
|
const id = moved.id ?? moved.key;
|
|
@@ -685,6 +745,7 @@ export function conclude(result) {
|
|
|
685
745
|
track.push({ at: frame.at, x: moved.x });
|
|
686
746
|
tracks.set(id, track);
|
|
687
747
|
named.set(id, moved.key);
|
|
748
|
+
boxes.set(id, moved.box);
|
|
688
749
|
}
|
|
689
750
|
}
|
|
690
751
|
const scrolledAt = log.filter((frame) => frame.scrolled).map((frame) => frame.at);
|
|
@@ -694,7 +755,12 @@ export function conclude(result) {
|
|
|
694
755
|
if (track.length < 20) continue;
|
|
695
756
|
const found = afterScroll(seams(track), scrolledAt);
|
|
696
757
|
if (found.length) {
|
|
697
|
-
torn.push({
|
|
758
|
+
torn.push({
|
|
759
|
+
key: named.get(key) ?? key,
|
|
760
|
+
box: boxes.get(key) ?? null,
|
|
761
|
+
seams: found.slice(0, 3),
|
|
762
|
+
frames: track.length,
|
|
763
|
+
});
|
|
698
764
|
}
|
|
699
765
|
}
|
|
700
766
|
|
|
@@ -713,6 +779,7 @@ export function conclude(result) {
|
|
|
713
779
|
gsapMovers: new Set(
|
|
714
780
|
log.flatMap((frame) => frame.moved.filter((m) => m.gsap).map((m) => m.id ?? m.key)),
|
|
715
781
|
).size,
|
|
782
|
+
movingElements: movingElements(log),
|
|
716
783
|
seams: seamEvents(torn),
|
|
717
784
|
overText,
|
|
718
785
|
began: began.slice(0, 8),
|
|
@@ -750,6 +817,7 @@ export function probe(kind) {
|
|
|
750
817
|
starts,
|
|
751
818
|
firstMotion,
|
|
752
819
|
longFrameSummary,
|
|
820
|
+
movingElements,
|
|
753
821
|
conclude,
|
|
754
822
|
};
|
|
755
823
|
return `() => {
|
|
@@ -775,6 +843,8 @@ export function report(result, options) {
|
|
|
775
843
|
options.headed && "headed, on the GPU",
|
|
776
844
|
].filter(Boolean);
|
|
777
845
|
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
846
|
+
const { viewport } = result.recorded;
|
|
847
|
+
if (viewport) lines.push(` viewport: ${viewport.width}x${viewport.height}`);
|
|
778
848
|
for (const note of result.notes) lines.push(` ${note}`);
|
|
779
849
|
const gpu = gpuNote(found.canvases, options);
|
|
780
850
|
if (gpu) lines.push(` ${gpu}`);
|
|
@@ -785,9 +855,16 @@ export function report(result, options) {
|
|
|
785
855
|
lines.push(" or it needs a scroll or a pointer to begin");
|
|
786
856
|
} else {
|
|
787
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);
|
|
788
864
|
lines.push(
|
|
789
865
|
` ${found.movers} elements moved, ${found.gsapMovers} of them GSAP's` +
|
|
790
|
-
`${
|
|
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})` : ""}`,
|
|
791
868
|
);
|
|
792
869
|
if (found.spread) lines.push(` they started over a span of ${found.spread}ms`);
|
|
793
870
|
}
|
|
@@ -838,9 +915,10 @@ export function report(result, options) {
|
|
|
838
915
|
lines.push("", "Seams — a step far larger than the motion's own rhythm");
|
|
839
916
|
for (const event of found.seams.slice(0, 6)) {
|
|
840
917
|
const size = event.least === event.most ? `${event.most}px` : `${event.least}-${event.most}px`;
|
|
918
|
+
const where = event.box ? ` at ${event.box.map(Math.round).join(",")}` : "";
|
|
841
919
|
lines.push(
|
|
842
920
|
` ${event.at}ms ${event.count === 1 ? event.example : `${event.count} elements (${event.example})`}` +
|
|
843
|
-
|
|
921
|
+
`${where} jumps ${size}`,
|
|
844
922
|
);
|
|
845
923
|
}
|
|
846
924
|
}
|
|
@@ -855,6 +933,11 @@ export function report(result, options) {
|
|
|
855
933
|
if (result.shot) lines.push("", `Frame: ${result.shot}`);
|
|
856
934
|
|
|
857
935
|
lines.push("", "These are measurements. Composition, rhythm and taste are yours to judge.");
|
|
936
|
+
/** Last, so a reader who sees only the end still sees it. */
|
|
937
|
+
if (result.notDone?.length) {
|
|
938
|
+
lines.push("", "Not done — the run exits non-zero");
|
|
939
|
+
for (const line of result.notDone) lines.push(` ${line}`);
|
|
940
|
+
}
|
|
858
941
|
return lines.join("\n");
|
|
859
942
|
}
|
|
860
943
|
|
|
@@ -877,9 +960,10 @@ async function main(argv) {
|
|
|
877
960
|
const result = await watch(options);
|
|
878
961
|
console.log(
|
|
879
962
|
options.json
|
|
880
|
-
? JSON.stringify({ url: result.url, ...conclude(result) }, null, 2)
|
|
963
|
+
? JSON.stringify({ url: result.url, ...conclude(result), notDone: result.notDone }, null, 2)
|
|
881
964
|
: report(result, options),
|
|
882
965
|
);
|
|
966
|
+
process.exitCode = exitFor(result.notDone);
|
|
883
967
|
} catch (error) {
|
|
884
968
|
console.error(error.message);
|
|
885
969
|
process.exit(1);
|