@mehshekari/gsap-motion 3.4.0 → 3.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 +2 -1
- package/bin/gsap-motion.mjs +13 -0
- package/package.json +1 -1
- package/skill/SKILL.md +9 -9
- package/skill/reference/refine.md +37 -0
- package/skill/scripts/capture-motion.mjs +94 -9
- package/skill/scripts/explain-motion.mjs +30 -9
- package/skill/scripts/review-motion.mjs +731 -0
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ No dependencies, no install scripts, Node.js 18 or later.
|
|
|
22
22
|
| `audit-svg <file...>` | Reports what DrawSVG can draw, what MorphSVG can morph, and what will fail silently. `--morph a.svg b.svg` compares a pair; `--hues` also reports hard-coded hues. |
|
|
23
23
|
| `inspect <url>` | Watches a page animate over the DevTools Protocol and reports what it saw: a filmstrip, the timeline GSAP is running, and the layouts, style recalculations and frames the browser did. `--scroll`, `--hover` and `--click` reach the state first; `--reduced`, `--dark`, `--mobile` and `--cpu` change the conditions. Needs a Chrome on the machine; installs nothing. |
|
|
24
24
|
| `explain <file...|url>` | Maps an animation and changes nothing: where it lives, its preference branches, its beats in order with the reasons their comments give, its scroll configuration, and what the audit says. Given a URL it reads the running page instead, where the timeline is the one GSAP actually built. |
|
|
25
|
+
| `review <url>` | Measures what the motion actually did, by sampling the DOM every frame rather than by reading GSAP's timeline — which a bundled app never exposes, and which would make it work on demo pages and fail on real ones. Reports time to first motion, the share of the screen moving at its busiest, loop seams, and motion over text being read. Sampling forces layout, so it does not report layout counts; `inspect` measures those. Takes the same `--scroll`, `--reduced`, `--dark`, `--mobile` and `--cpu`. |
|
|
25
26
|
| `doctor` | Checks Node.js, the installed skill's version for this project and your user, your project's `gsap` and `@gsap/react`, and whether an `ANIMATION.md` exists. |
|
|
26
27
|
|
|
27
28
|
## In CI
|
|
@@ -30,7 +31,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
30
31
|
build without warning:
|
|
31
32
|
|
|
32
33
|
```json
|
|
33
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@3.
|
|
34
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@3.5.0 audit src --quiet"
|
|
34
35
|
```
|
|
35
36
|
|
|
36
37
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
package/bin/gsap-motion.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* npx @mehshekari/gsap-motion audit-svg <file.svg...> [--morph] [--hues]
|
|
10
10
|
* npx @mehshekari/gsap-motion inspect <url> [--at ...] [--hover ...] [--reduced]
|
|
11
11
|
* npx @mehshekari/gsap-motion explain <file...|url>
|
|
12
|
+
* npx @mehshekari/gsap-motion review <url> [--scroll ...] [--mobile]
|
|
12
13
|
* npx @mehshekari/gsap-motion doctor
|
|
13
14
|
*
|
|
14
15
|
* Installed as a dependency, the same commands run as `gsap-motion <command>`.
|
|
@@ -84,6 +85,15 @@ Commands
|
|
|
84
85
|
explain <file...> Map an animation: scopes, branches, beats, triggers
|
|
85
86
|
Give it a URL instead to read the running page
|
|
86
87
|
--json Machine-readable output
|
|
88
|
+
review <url> Measure what the motion did: time to first motion,
|
|
89
|
+
how much moves at once, loop seams, motion over text
|
|
90
|
+
--watch <ms> How long to watch. Default 3000
|
|
91
|
+
--scroll <px|sel> Scroll, then watch again
|
|
92
|
+
--reduced --dark Emulate a preference
|
|
93
|
+
--mobile 390x844 with touch input
|
|
94
|
+
--cpu 4 Throttle the CPU by this factor
|
|
95
|
+
--out <dir> Save a frame alongside the measurements
|
|
96
|
+
--json Machine-readable output
|
|
87
97
|
doctor Check Node, the installed skill and your project's GSAP
|
|
88
98
|
--version Print the version
|
|
89
99
|
|
|
@@ -322,6 +332,9 @@ switch (command) {
|
|
|
322
332
|
case "explain":
|
|
323
333
|
runScript("explain-motion.mjs", args);
|
|
324
334
|
break;
|
|
335
|
+
case "review":
|
|
336
|
+
runScript("review-motion.mjs", args);
|
|
337
|
+
break;
|
|
325
338
|
case "doctor":
|
|
326
339
|
doctor();
|
|
327
340
|
break;
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
|
|
|
4
4
|
license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
|
|
5
5
|
compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 18+ and nothing else.
|
|
6
6
|
metadata:
|
|
7
|
-
version: "3.
|
|
7
|
+
version: "3.5.0"
|
|
8
8
|
verified-gsap: "3.15"
|
|
9
9
|
verified-gsap-react: "2.1"
|
|
10
10
|
---
|
|
@@ -178,16 +178,16 @@ node <skill-dir>/scripts/audit-svg.mjs <file.svg>
|
|
|
178
178
|
node <skill-dir>/scripts/audit-svg.mjs --morph <a.svg> <b.svg>
|
|
179
179
|
node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg>
|
|
180
180
|
|
|
181
|
-
# Watch a page animate
|
|
182
|
-
#
|
|
181
|
+
# Both need a Chrome. Watch a page animate; then measure what it did:
|
|
182
|
+
# first motion, screen share, seams, motion over text.
|
|
183
183
|
node <skill-dir>/scripts/capture-motion.mjs <url> --at 0,300,900
|
|
184
|
+
node <skill-dir>/scripts/review-motion.mjs <url> --scroll 2000
|
|
184
185
|
```
|
|
185
186
|
|
|
186
|
-
Run `audit-gsap`
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
Run `audit-gsap` in an `audit` and after writing animation; as a plugin, a hook
|
|
188
|
+
runs it on each edit. A false finding is a bug in the script, never something to
|
|
189
|
+
work around.
|
|
189
190
|
|
|
190
191
|
`MotionPathHelper`, `GSDevTools`, `markers: true` and
|
|
191
|
-
`MorphSVGPlugin.findShapeIndex()` are development-only, and a static import
|
|
192
|
-
|
|
193
|
-
check, and delete it once its value is baked in.
|
|
192
|
+
`MorphSVGPlugin.findShapeIndex()` are development-only, and a static import ships
|
|
193
|
+
whatever `if` surrounds it. Import them dynamically behind a `NODE_ENV` check.
|
|
@@ -78,6 +78,41 @@ Reach the state first, and change the conditions that usually break things:
|
|
|
78
78
|
|
|
79
79
|
**Without a browser, report the stage as not run.** Not "fine".
|
|
80
80
|
|
|
81
|
+
## Measure it, once you have watched it
|
|
82
|
+
|
|
83
|
+
`capture-motion.mjs` shows you frames. `review-motion.mjs` measures what the
|
|
84
|
+
motion did, and is where the visual stage of this loop gets its evidence:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
node <skill-dir>/scripts/review-motion.mjs http://localhost:3000 --scroll 2000
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
It samples the DOM every frame rather than reading `gsap.globalTimeline`,
|
|
91
|
+
because a bundled app never publishes `gsap` to the page — so it works on a real
|
|
92
|
+
Next, Vite, Nuxt, Astro or Svelte app, and it sees CSS and Web Animations motion
|
|
93
|
+
that no source rule can reach.
|
|
94
|
+
|
|
95
|
+
| It says | Read it as |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| **first motion at N ms** | How long the page is still. Late enough and the visitor has scrolled past the thing you built. |
|
|
98
|
+
| **N elements moved, M of them GSAP's** | The gap is motion the audit never sees. A surprise there is worth chasing. |
|
|
99
|
+
| **X% of the screen in motion** | Everything moving at once reads as noise, not as choreography. |
|
|
100
|
+
| **seams** | A step far larger than that motion's own rhythm: a loop put back by hand instead of carried. |
|
|
101
|
+
| **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. |
|
|
102
|
+
|
|
103
|
+
Three things it deliberately does not do, so you do not read them into it:
|
|
104
|
+
|
|
105
|
+
- **It does not report layout cost.** Reading every box every frame is what
|
|
106
|
+
forces layout, so its numbers would be partly its own doing. `inspect`
|
|
107
|
+
measures that without sampling; use it for cost.
|
|
108
|
+
- **It does not judge taste.** Composition, rhythm and whether the thing is any
|
|
109
|
+
good are yours, with the filmstrip in front of you.
|
|
110
|
+
- **It does not report a seam a scroll caused.** Jumping the scrollbar is how it
|
|
111
|
+
reaches a scene, and a scrubbed animation answers instantly and correctly.
|
|
112
|
+
|
|
113
|
+
A clean report is evidence, not proof: it watched one window, on one machine, at
|
|
114
|
+
one size. Say which.
|
|
115
|
+
|
|
81
116
|
## When to stop
|
|
82
117
|
|
|
83
118
|
Stop when every check is clean, or after **three rounds**, reporting what is
|
|
@@ -103,6 +138,8 @@ The answer's **Verification** section is one line per check, with its evidence:
|
|
|
103
138
|
```text
|
|
104
139
|
Verification
|
|
105
140
|
- audit-gsap: clean (3 warns waived: layout-property on the grid, with reasons)
|
|
141
|
+
- review: first motion 410ms, 38% of the screen at its busiest, no seams,
|
|
142
|
+
nothing over text (1280x800, one 3s window)
|
|
106
143
|
- reduced motion: emulated, the static branch sets the end state
|
|
107
144
|
- StrictMode: mounted twice in dev, no doubled tweens
|
|
108
145
|
- not checked: frame cost on a real phone — no device to hand
|
|
@@ -257,14 +257,35 @@ export function parseOptions(argv) {
|
|
|
257
257
|
|
|
258
258
|
/**
|
|
259
259
|
* The timeline GSAP is running, read from the page itself rather than guessed
|
|
260
|
-
* from the source.
|
|
261
|
-
*
|
|
260
|
+
* from the source.
|
|
261
|
+
*
|
|
262
|
+
* A bundled app never gives the page a gsap global. GSAP installs its exports
|
|
263
|
+
* into a private object, and the branch of its installer that would reach
|
|
264
|
+
* window cannot be taken, because that object is truthy from the start. So a
|
|
265
|
+
* page can be full of GSAP with nothing to ask. Presence is read instead from
|
|
266
|
+
* the traces it leaves — the version it announces, and the cache it hangs on
|
|
267
|
+
* every element it touches — and a timeline that cannot be read is reported as
|
|
268
|
+
* unreadable, never as an animation that never ran.
|
|
262
269
|
*/
|
|
263
270
|
const READ_TIMELINE = `(() => {
|
|
264
|
-
|
|
265
|
-
|
|
271
|
+
const handle =
|
|
272
|
+
typeof gsap !== "undefined"
|
|
273
|
+
? gsap
|
|
274
|
+
: window.GreenSockGlobals && window.GreenSockGlobals.gsap
|
|
275
|
+
? window.GreenSockGlobals.gsap
|
|
276
|
+
: null;
|
|
277
|
+
const version = (window.gsapVersions || [])[0] || null;
|
|
278
|
+
const controlled = [...document.querySelectorAll("*")].filter((el) => el._gsap).length;
|
|
279
|
+
if (!handle) {
|
|
280
|
+
if (!version && !controlled) return null;
|
|
281
|
+
return { readable: false, version, controlled };
|
|
282
|
+
}
|
|
283
|
+
const children = handle.globalTimeline.getChildren(true, true, true);
|
|
266
284
|
return {
|
|
267
|
-
|
|
285
|
+
readable: true,
|
|
286
|
+
version,
|
|
287
|
+
controlled,
|
|
288
|
+
time: handle.globalTimeline.time(),
|
|
268
289
|
children: children.map((child) => ({
|
|
269
290
|
targets: (child.targets?.() ?? []).map((target) =>
|
|
270
291
|
target?.id ? "#" + target.id :
|
|
@@ -283,6 +304,27 @@ const READ_TIMELINE = `(() => {
|
|
|
283
304
|
};
|
|
284
305
|
})()`;
|
|
285
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Frames the page actually drove.
|
|
309
|
+
*
|
|
310
|
+
* Chrome's own Frames metric reads 0 in headless even while a tween runs at a
|
|
311
|
+
* steady 60fps, so it cannot be reported as a frame count. A requestAnimationFrame
|
|
312
|
+
* counter, installed before the page's own scripts, counts the frames the page
|
|
313
|
+
* was given — which is the number a reader wants when asking whether motion
|
|
314
|
+
* was smooth.
|
|
315
|
+
*/
|
|
316
|
+
const COUNT_FRAMES = `(() => {
|
|
317
|
+
if (window.__gsapMotionFrames !== undefined) return true;
|
|
318
|
+
window.__gsapMotionFrames = 0;
|
|
319
|
+
window.__gsapMotionStart = performance.now();
|
|
320
|
+
const step = () => {
|
|
321
|
+
window.__gsapMotionFrames += 1;
|
|
322
|
+
requestAnimationFrame(step);
|
|
323
|
+
};
|
|
324
|
+
requestAnimationFrame(step);
|
|
325
|
+
return true;
|
|
326
|
+
})()`;
|
|
327
|
+
|
|
286
328
|
/** The live box of a selector, so a pointer lands on an element that is moving. */
|
|
287
329
|
const boxOf = (selector) => `(() => {
|
|
288
330
|
const element = document.querySelector(${JSON.stringify(selector)});
|
|
@@ -295,6 +337,17 @@ const boxOf = (selector) => `(() => {
|
|
|
295
337
|
|
|
296
338
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
297
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Whether a box can be pointed at.
|
|
342
|
+
*
|
|
343
|
+
* A selector can match an element that styles say is visible and that layout
|
|
344
|
+
* gives no box at all — a duplicate inside a collapsed container, say. Its
|
|
345
|
+
* rect is every zero, so a pointer aimed at its centre lands in the corner of
|
|
346
|
+
* the viewport and hits whatever is there. Found on a real site, where a
|
|
347
|
+
* header held two copies of the same button and the first had no box.
|
|
348
|
+
*/
|
|
349
|
+
export const pointable = (box) => Boolean(box && box.width > 0 && box.height > 0);
|
|
350
|
+
|
|
298
351
|
async function capture(options) {
|
|
299
352
|
const executable = findChrome(options.chrome);
|
|
300
353
|
if (!executable) {
|
|
@@ -343,6 +396,8 @@ async function capture(options) {
|
|
|
343
396
|
|
|
344
397
|
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
345
398
|
|
|
399
|
+
await call("Page.addScriptToEvaluateOnNewDocument", { source: COUNT_FRAMES });
|
|
400
|
+
|
|
346
401
|
const before = await call("Performance.getMetrics");
|
|
347
402
|
await call("Page.navigate", { url: options.url });
|
|
348
403
|
await wait(options.wait);
|
|
@@ -373,6 +428,12 @@ async function capture(options) {
|
|
|
373
428
|
notes.push(`${step.type}: nothing matched ${step.value}`);
|
|
374
429
|
continue;
|
|
375
430
|
}
|
|
431
|
+
if (!pointable(box)) {
|
|
432
|
+
notes.push(
|
|
433
|
+
`${step.type}: ${step.value} matched an element with no box on screen, so nothing was done`,
|
|
434
|
+
);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
376
437
|
/**
|
|
377
438
|
* Two moves, because a pointer that is already there never enters; and
|
|
378
439
|
* the box is read now, since whatever is animating has moved since the
|
|
@@ -423,6 +484,9 @@ async function capture(options) {
|
|
|
423
484
|
}
|
|
424
485
|
|
|
425
486
|
const timeline = await evaluate(READ_TIMELINE);
|
|
487
|
+
const drawn = await evaluate(
|
|
488
|
+
"({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
|
|
489
|
+
);
|
|
426
490
|
const after = await call("Performance.getMetrics");
|
|
427
491
|
|
|
428
492
|
const delta = (name) => {
|
|
@@ -431,11 +495,17 @@ async function capture(options) {
|
|
|
431
495
|
return end - start;
|
|
432
496
|
};
|
|
433
497
|
|
|
434
|
-
|
|
498
|
+
/** The window the page was actually watched for, not the one that was asked for. */
|
|
499
|
+
const span = Math.round(
|
|
500
|
+
drawn.since === null
|
|
501
|
+
? (options.at.at(-1) ?? 0) + options.wait
|
|
502
|
+
: await evaluate(`performance.now() - ${drawn.since}`),
|
|
503
|
+
);
|
|
435
504
|
const measured = {
|
|
436
505
|
layouts: delta("LayoutCount"),
|
|
437
506
|
recalcs: delta("RecalcStyleCount"),
|
|
438
|
-
frames:
|
|
507
|
+
frames: drawn.frames,
|
|
508
|
+
fps: drawn.frames && span ? Number(((drawn.frames / span) * 1000).toFixed(1)) : null,
|
|
439
509
|
layoutSeconds: Number(delta("LayoutDuration").toFixed(3)),
|
|
440
510
|
recalcSeconds: Number(delta("RecalcStyleDuration").toFixed(3)),
|
|
441
511
|
overMs: span,
|
|
@@ -449,7 +519,7 @@ async function capture(options) {
|
|
|
449
519
|
|
|
450
520
|
// --- Reporting ---------------------------------------------------------------
|
|
451
521
|
|
|
452
|
-
function report(result, options) {
|
|
522
|
+
export function report(result, options) {
|
|
453
523
|
const lines = [`Watched ${result.url}`];
|
|
454
524
|
|
|
455
525
|
const emulated = [
|
|
@@ -469,6 +539,17 @@ function report(result, options) {
|
|
|
469
539
|
lines.push("", "Timeline, as the page is running it");
|
|
470
540
|
if (result.timeline === null) {
|
|
471
541
|
lines.push(" no GSAP on the page — nothing was animating, or it never loaded");
|
|
542
|
+
} else if (result.timeline.readable === false) {
|
|
543
|
+
const { version, controlled } = result.timeline;
|
|
544
|
+
lines.push(
|
|
545
|
+
` GSAP${version ? ` ${version}` : ""} is running, but this page keeps it to itself,` +
|
|
546
|
+
" so its timeline cannot be read from outside.",
|
|
547
|
+
);
|
|
548
|
+
lines.push(
|
|
549
|
+
` ${controlled} element${controlled === 1 ? " carries" : "s carry"} GSAP's cache,` +
|
|
550
|
+
" so it has touched the page.",
|
|
551
|
+
);
|
|
552
|
+
lines.push(" To read the timeline here, call gsap.install(window) in development.");
|
|
472
553
|
} else if (result.timeline.children.length === 0) {
|
|
473
554
|
lines.push(" GSAP is loaded, and its global timeline has no children right now");
|
|
474
555
|
} else {
|
|
@@ -486,8 +567,12 @@ function report(result, options) {
|
|
|
486
567
|
|
|
487
568
|
const { measured } = result;
|
|
488
569
|
lines.push("", `Browser work over ${measured.overMs}ms`);
|
|
570
|
+
/** A rate only means something when frames were counted at all. */
|
|
571
|
+
const rate = measured.fps === null ? "" : ` (${measured.fps}/s)`;
|
|
572
|
+
const drew =
|
|
573
|
+
measured.frames === null ? "frames not counted" : `${measured.frames} frames${rate}`;
|
|
489
574
|
lines.push(
|
|
490
|
-
` ${
|
|
575
|
+
` ${drew} · ${measured.layouts} layouts (${measured.layoutSeconds}s)` +
|
|
491
576
|
` · ${measured.recalcs} style recalculations (${measured.recalcSeconds}s)`,
|
|
492
577
|
);
|
|
493
578
|
lines.push("", "These are observations. What they mean is yours to judge.");
|
|
@@ -29,10 +29,12 @@ import { connect, findChrome } from "./capture-motion.mjs";
|
|
|
29
29
|
import { check, mentionsGsap } from "./lib/audit.mjs";
|
|
30
30
|
import {
|
|
31
31
|
calleeName,
|
|
32
|
+
contains,
|
|
32
33
|
dottedName,
|
|
33
34
|
findAll,
|
|
34
35
|
keptUnder,
|
|
35
36
|
methodName,
|
|
37
|
+
scopeOf,
|
|
36
38
|
propertyOf,
|
|
37
39
|
staticString,
|
|
38
40
|
timelineLinks,
|
|
@@ -185,6 +187,8 @@ function readFile(file) {
|
|
|
185
187
|
// Scenes: a timeline, and the beats on it in order.
|
|
186
188
|
const timelines = findAll(file.ast, (node) => calleeName(node) === "gsap.timeline");
|
|
187
189
|
const claimed = new Set();
|
|
190
|
+
/** Which function holds each scene, since a file often has two called `tl`. */
|
|
191
|
+
const owners = new Map();
|
|
188
192
|
|
|
189
193
|
for (const timeline of timelines) {
|
|
190
194
|
const beats = [];
|
|
@@ -192,7 +196,9 @@ function readFile(file) {
|
|
|
192
196
|
claimed.add(link);
|
|
193
197
|
const method = methodName(link);
|
|
194
198
|
if (method === "addLabel") {
|
|
195
|
-
beats.push({
|
|
199
|
+
beats.push({
|
|
200
|
+
label: staticString(link.arguments[0]) ?? textOf(file, link.arguments[0], 24) ?? "(label)",
|
|
201
|
+
});
|
|
196
202
|
continue;
|
|
197
203
|
}
|
|
198
204
|
if (!["to", "from", "fromTo", "set", "call", "add"].includes(method)) continue;
|
|
@@ -209,19 +215,31 @@ function readFile(file) {
|
|
|
209
215
|
});
|
|
210
216
|
}
|
|
211
217
|
|
|
212
|
-
|
|
218
|
+
const scene = {
|
|
213
219
|
line: lineAt(file.raw, timeline.start),
|
|
214
220
|
name: keptUnder(file.ast, timeline)?.name ?? null,
|
|
215
221
|
repeat: valueOf(varsOf(timeline), "repeat"),
|
|
216
222
|
why: reasonFor(file, timeline),
|
|
217
223
|
beats,
|
|
218
|
-
}
|
|
224
|
+
};
|
|
225
|
+
map.scenes.push(scene);
|
|
226
|
+
owners.set(scene, scopeOf(file.ast, timeline));
|
|
219
227
|
}
|
|
220
228
|
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
229
|
+
/**
|
|
230
|
+
* The scene a beat belongs to. A name alone is not enough: two timelines in
|
|
231
|
+
* one file are often both `tl`, and handing one scene's beats to the other is
|
|
232
|
+
* the kind of wrong a map must not be. The owner is the enclosing function.
|
|
233
|
+
*/
|
|
234
|
+
const sceneFor = (name, node) => {
|
|
235
|
+
const holding = map.scenes.filter((scene) => {
|
|
236
|
+
if (scene.name !== name) return false;
|
|
237
|
+
const owner = owners.get(scene);
|
|
238
|
+
/** A timeline at module scope owns the whole file; otherwise its function must hold the beat. */
|
|
239
|
+
return !owner || contains(owner, node);
|
|
240
|
+
});
|
|
241
|
+
return holding.at(-1) ?? null;
|
|
242
|
+
};
|
|
225
243
|
|
|
226
244
|
// Beats added to a timeline this file was handed: the helper shape, where the
|
|
227
245
|
// caller owns the timeline and this file only fills it.
|
|
@@ -232,14 +250,17 @@ function readFile(file) {
|
|
|
232
250
|
if (claimed.has(call) || timelines.includes(call)) continue;
|
|
233
251
|
const owner = dottedName(unwrap(call.callee).object);
|
|
234
252
|
if (!owner || owner === "gsap" || owner.startsWith("gsap.")) continue;
|
|
235
|
-
const own =
|
|
253
|
+
const own = sceneFor(owner, call);
|
|
236
254
|
if (!own && !handed.has(owner)) handed.set(owner, []);
|
|
237
255
|
|
|
238
256
|
const method = methodName(call);
|
|
239
257
|
const vars = varsOf(call);
|
|
240
258
|
(own ? own.beats : handed.get(owner)).push(
|
|
241
259
|
method === "addLabel"
|
|
242
|
-
? {
|
|
260
|
+
? {
|
|
261
|
+
label:
|
|
262
|
+
staticString(call.arguments[0]) ?? textOf(file, call.arguments[0], 24) ?? "(label)",
|
|
263
|
+
}
|
|
243
264
|
: {
|
|
244
265
|
line: lineAt(file.raw, startOf(call)),
|
|
245
266
|
method,
|
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* review — the visual audit: what the motion actually did, measured.
|
|
4
|
+
*
|
|
5
|
+
* `audit` reads the source and says what is likely wrong. `inspect` watches and
|
|
6
|
+
* reports what it saw. This sits between them: it watches, and then draws
|
|
7
|
+
* conclusions that can be checked — time to first motion, how much of the screen
|
|
8
|
+
* moves at once, motion over text being read, loop seams, and layout work per
|
|
9
|
+
* frame.
|
|
10
|
+
*
|
|
11
|
+
* It does not read `gsap.globalTimeline`. A bundled app never publishes `gsap`,
|
|
12
|
+
* so an audit built on the timeline would work on demo pages and fail on real
|
|
13
|
+
* ones. Everything here comes from sampling the DOM: every frame, the box,
|
|
14
|
+
* opacity and transform of each element that can move, diffed against the frame
|
|
15
|
+
* before. That sees CSS and Web Animations motion too, which the source audit
|
|
16
|
+
* cannot see at all.
|
|
17
|
+
*
|
|
18
|
+
* What it does not do is judge taste. Composition, rhythm and visual quality are
|
|
19
|
+
* for a reviewer looking at the filmstrip; this reports evidence, with the time
|
|
20
|
+
* and the element, and leaves the verdict to them.
|
|
21
|
+
*
|
|
22
|
+
* Node.js 18+, no dependencies, whatever Chrome the machine already has.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
import { connect, findChrome } from "./capture-motion.mjs";
|
|
29
|
+
|
|
30
|
+
// --- What a recording means ---------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** The first moment anything moved, or null when nothing ever did. */
|
|
33
|
+
export function firstMotion(log) {
|
|
34
|
+
return log.length ? log[0].at : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The share of the viewport in motion.
|
|
39
|
+
*
|
|
40
|
+
* Summing the boxes is wrong and wildly so: on a real page the moving elements
|
|
41
|
+
* are nested, and the first spike reported 34 viewports in motion on a viewport
|
|
42
|
+
* that has one. This is the area of their union, clipped to the screen — the
|
|
43
|
+
* fraction a viewer would say is moving.
|
|
44
|
+
*
|
|
45
|
+
* The union is computed by sweeping the distinct x edges and, for each strip,
|
|
46
|
+
* merging the y ranges that cover it. With the handful of boxes a frame holds,
|
|
47
|
+
* that is exact and fast enough to run per frame.
|
|
48
|
+
*/
|
|
49
|
+
export function coverage(boxes, viewport) {
|
|
50
|
+
const clipped = [];
|
|
51
|
+
for (const [x, y, width, height] of boxes) {
|
|
52
|
+
const x0 = Math.max(0, x);
|
|
53
|
+
const y0 = Math.max(0, y);
|
|
54
|
+
const x1 = Math.min(viewport.width, x + width);
|
|
55
|
+
const y1 = Math.min(viewport.height, y + height);
|
|
56
|
+
if (x1 > x0 && y1 > y0) clipped.push([x0, y0, x1, y1]);
|
|
57
|
+
}
|
|
58
|
+
if (!clipped.length) return 0;
|
|
59
|
+
|
|
60
|
+
const edges = [...new Set(clipped.flatMap(([x0, , x1]) => [x0, x1]))].sort((a, b) => a - b);
|
|
61
|
+
let area = 0;
|
|
62
|
+
for (let i = 0; i < edges.length - 1; i += 1) {
|
|
63
|
+
const left = edges[i];
|
|
64
|
+
const right = edges[i + 1];
|
|
65
|
+
const width = right - left;
|
|
66
|
+
if (!width) continue;
|
|
67
|
+
|
|
68
|
+
const spans = clipped
|
|
69
|
+
.filter(([x0, , x1]) => x0 <= left && x1 >= right)
|
|
70
|
+
.map(([, y0, , y1]) => [y0, y1])
|
|
71
|
+
.sort((a, b) => a[0] - b[0]);
|
|
72
|
+
|
|
73
|
+
let covered = 0;
|
|
74
|
+
let openFrom = null;
|
|
75
|
+
let openTo = null;
|
|
76
|
+
for (const [y0, y1] of spans) {
|
|
77
|
+
if (openTo === null || y0 > openTo) {
|
|
78
|
+
if (openTo !== null) covered += openTo - openFrom;
|
|
79
|
+
openFrom = y0;
|
|
80
|
+
openTo = y1;
|
|
81
|
+
} else if (y1 > openTo) {
|
|
82
|
+
openTo = y1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (openTo !== null) covered += openTo - openFrom;
|
|
86
|
+
area += width * covered;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return Math.round((area / (viewport.width * viewport.height)) * 1000) / 1000;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where a loop jumps instead of travelling.
|
|
94
|
+
*
|
|
95
|
+
* A seam is a frame whose step is unlike every other step: a marquee that
|
|
96
|
+
* restarts at zero rather than being carried back, and shows a tear. Easing is
|
|
97
|
+
* not a seam, which is why this compares against the median step rather than
|
|
98
|
+
* the mean — an eased tween's steps vary smoothly, and no single one stands out
|
|
99
|
+
* against the middle of the distribution.
|
|
100
|
+
*
|
|
101
|
+
* A ratio alone is not enough. A nearly-still element has a median step of a
|
|
102
|
+
* fraction of a pixel, so the smallest wobble is many times it: the first run
|
|
103
|
+
* against a real page reported an orbiting dot that moved 7.6px. A tear has to
|
|
104
|
+
* be big enough to see, so a seam is both unlike its neighbours and at least
|
|
105
|
+
* `least` pixels.
|
|
106
|
+
*/
|
|
107
|
+
export function seams(track, { factor = 6, minimum = 4, least = 16 } = {}) {
|
|
108
|
+
if (track.length < minimum) return [];
|
|
109
|
+
|
|
110
|
+
const steps = [];
|
|
111
|
+
/**
|
|
112
|
+
* From index 2: a track begins when the element first moves, so its first
|
|
113
|
+
* step is the leap out of rest that a `from` tween makes when it sets its
|
|
114
|
+
* starting offset. A real page reported every such entrance as a 633px tear.
|
|
115
|
+
*/
|
|
116
|
+
for (let i = 2; i < track.length; i += 1) {
|
|
117
|
+
steps.push({ at: track[i].at, delta: Math.abs(track[i].x - track[i - 1].x) });
|
|
118
|
+
}
|
|
119
|
+
if (!steps.length) return [];
|
|
120
|
+
|
|
121
|
+
const sorted = [...steps].map((step) => step.delta).sort((a, b) => a - b);
|
|
122
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
123
|
+
/** A still element has no normal step to be unlike, so nothing is a seam. */
|
|
124
|
+
if (!median) return [];
|
|
125
|
+
|
|
126
|
+
return steps
|
|
127
|
+
.filter((step) => step.delta > median * factor && step.delta >= least)
|
|
128
|
+
.map((step) => ({ at: step.at, jump: Math.round(step.delta * 10) / 10 }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** How much of `a` lies under `b`, as a share of `a`. */
|
|
132
|
+
const overlap = ([ax, ay, aw, ah], [bx, by, bw, bh]) => {
|
|
133
|
+
const width = Math.min(ax + aw, bx + bw) - Math.max(ax, bx);
|
|
134
|
+
const height = Math.min(ay + ah, by + bh) - Math.max(ay, by);
|
|
135
|
+
if (width <= 0 || height <= 0) return 0;
|
|
136
|
+
return (width * height) / (aw * ah);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Motion passing over text a reader is in the middle of.
|
|
141
|
+
*
|
|
142
|
+
* A shared edge is not a problem — a rule sliding under a heading covers a few
|
|
143
|
+
* pixels of it — so this only reports motion over a quarter of the line or
|
|
144
|
+
* more. An element moving over itself is not motion over text, or every reveal
|
|
145
|
+
* of a paragraph would be reported.
|
|
146
|
+
*/
|
|
147
|
+
export function motionOverText(moving, text, { least = 0.25, visible = 0.5 } = {}) {
|
|
148
|
+
const found = [];
|
|
149
|
+
for (const line of text) {
|
|
150
|
+
for (const mover of moving) {
|
|
151
|
+
if (mover.key === line.key) continue;
|
|
152
|
+
/** A sheet at two percent opacity is over the text but not in the way. */
|
|
153
|
+
if (mover.opacity !== undefined && mover.opacity < visible) continue;
|
|
154
|
+
/**
|
|
155
|
+
* Nor is a wrapper that paints nothing. Geometry cannot tell a positioning
|
|
156
|
+
* box from a sheet: on a real page a transparent full-screen div was
|
|
157
|
+
* reported as covering every line on it. The page is asked whether the
|
|
158
|
+
* element paints at all, and only then is it in the way.
|
|
159
|
+
*/
|
|
160
|
+
if (mover.paints === false) continue;
|
|
161
|
+
/**
|
|
162
|
+
* When the page hit-tested the line, believe it: `covered` is the element
|
|
163
|
+
* actually painted on top of it, and null means nothing is.
|
|
164
|
+
*/
|
|
165
|
+
if (line.covered !== undefined && mover.id !== line.covered) continue;
|
|
166
|
+
const share = overlap(line.box, mover.box);
|
|
167
|
+
if (share >= least) {
|
|
168
|
+
found.push({
|
|
169
|
+
text: line.key,
|
|
170
|
+
over: mover.key,
|
|
171
|
+
share: Math.round(share * 100) / 100,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return found;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Seams that a scroll did not cause.
|
|
181
|
+
*
|
|
182
|
+
* Jumping the scrollbar is how this tool reaches a scene, and a scrubbed
|
|
183
|
+
* animation answers an instant scroll by moving instantly — correctly. A real
|
|
184
|
+
* page reported 575px and 77px tears at the exact moment of the scroll, which
|
|
185
|
+
* says something about how it was driven, not about the animation. Anything
|
|
186
|
+
* within `settle` of a frame that scrolled is therefore not reported.
|
|
187
|
+
*/
|
|
188
|
+
export function afterScroll(found, scrolledAt, { settle = 300 } = {}) {
|
|
189
|
+
return found.filter((seam) => !scrolledAt.some((at) => seam.at >= at && seam.at - at <= settle));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One lurch is one finding, however many elements were in it.
|
|
194
|
+
*
|
|
195
|
+
* Elements that move together tear together: a real page reported five rects
|
|
196
|
+
* jumping 25-30px, which reads as five problems and is one. Seams within
|
|
197
|
+
* `window` of each other are the same event, and the reader is told how many
|
|
198
|
+
* elements were in it.
|
|
199
|
+
*/
|
|
200
|
+
export function seamEvents(torn, { window = 50 } = {}) {
|
|
201
|
+
const flat = torn.flatMap((element) =>
|
|
202
|
+
element.seams.map((seam) => ({ key: element.key, at: seam.at, jump: seam.jump })),
|
|
203
|
+
);
|
|
204
|
+
flat.sort((a, b) => a.at - b.at);
|
|
205
|
+
|
|
206
|
+
const events = [];
|
|
207
|
+
for (const seam of flat) {
|
|
208
|
+
const open = events.at(-1);
|
|
209
|
+
if (open && seam.at - open.at <= window) {
|
|
210
|
+
open.elements.add(seam.key);
|
|
211
|
+
open.least = Math.min(open.least, seam.jump);
|
|
212
|
+
open.most = Math.max(open.most, seam.jump);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
events.push({
|
|
216
|
+
at: seam.at,
|
|
217
|
+
elements: new Set([seam.key]),
|
|
218
|
+
least: seam.jump,
|
|
219
|
+
most: seam.jump,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return events.map((event) => ({
|
|
224
|
+
at: event.at,
|
|
225
|
+
count: event.elements.size,
|
|
226
|
+
example: [...event.elements][0],
|
|
227
|
+
least: event.least,
|
|
228
|
+
most: event.most,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Each element and the moment it first moved, in that order. */
|
|
233
|
+
export function starts(log) {
|
|
234
|
+
const first = new Map();
|
|
235
|
+
for (const frame of log) {
|
|
236
|
+
for (const moved of frame.moved) {
|
|
237
|
+
if (!first.has(moved.key)) first.set(moved.key, frame.at);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return [...first].map(([key, at]) => ({ key, at }));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Options -----------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
const asNumber = (value) => {
|
|
246
|
+
if (!/^\d+$/.test(String(value ?? ""))) return null;
|
|
247
|
+
return Number(value);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export function parseOptions(argv) {
|
|
251
|
+
const options = {
|
|
252
|
+
url: null,
|
|
253
|
+
watch: 3000,
|
|
254
|
+
scroll: null,
|
|
255
|
+
mobile: false,
|
|
256
|
+
reduced: false,
|
|
257
|
+
dark: false,
|
|
258
|
+
cpu: 1,
|
|
259
|
+
out: null,
|
|
260
|
+
json: false,
|
|
261
|
+
chrome: null,
|
|
262
|
+
errors: [],
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
266
|
+
const argument = argv[i];
|
|
267
|
+
const next = () => argv[(i += 1)];
|
|
268
|
+
|
|
269
|
+
if (!argument.startsWith("--")) {
|
|
270
|
+
if (options.url === null) options.url = argument;
|
|
271
|
+
else options.errors.push(`More than one URL: ${argument}`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
switch (argument) {
|
|
276
|
+
case "--watch": {
|
|
277
|
+
const ms = asNumber(next());
|
|
278
|
+
if (ms === null || ms < 200) {
|
|
279
|
+
options.errors.push("--watch takes milliseconds, 200 or more.");
|
|
280
|
+
} else {
|
|
281
|
+
options.watch = ms;
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "--scroll":
|
|
286
|
+
options.scroll = next() ?? null;
|
|
287
|
+
break;
|
|
288
|
+
case "--out":
|
|
289
|
+
options.out = next() ?? null;
|
|
290
|
+
break;
|
|
291
|
+
case "--cpu": {
|
|
292
|
+
const rate = Number(next());
|
|
293
|
+
if (!Number.isFinite(rate) || rate < 1) {
|
|
294
|
+
options.errors.push("--cpu takes a factor of 1 or more.");
|
|
295
|
+
} else {
|
|
296
|
+
options.cpu = rate;
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case "--chrome":
|
|
301
|
+
options.chrome = next() ?? null;
|
|
302
|
+
break;
|
|
303
|
+
case "--mobile":
|
|
304
|
+
options.mobile = true;
|
|
305
|
+
break;
|
|
306
|
+
case "--reduced":
|
|
307
|
+
options.reduced = true;
|
|
308
|
+
break;
|
|
309
|
+
case "--dark":
|
|
310
|
+
options.dark = true;
|
|
311
|
+
break;
|
|
312
|
+
case "--json":
|
|
313
|
+
options.json = true;
|
|
314
|
+
break;
|
|
315
|
+
default:
|
|
316
|
+
options.errors.push(`Unknown option: ${argument}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (options.url === null) options.errors.push("No URL. Pass the page to review.");
|
|
321
|
+
return options;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- What runs in the page ----------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The recorder, installed before the page's own scripts so it sees the first
|
|
328
|
+
* frame. It keeps its log in the page and hands the whole thing over at the end:
|
|
329
|
+
* one round trip, not one per frame, because a round trip per frame would slow
|
|
330
|
+
* the page it is measuring.
|
|
331
|
+
*/
|
|
332
|
+
const RECORDER = `(() => {
|
|
333
|
+
if (window.__gsapMotionReview) return true;
|
|
334
|
+
const log = [];
|
|
335
|
+
const started = performance.now();
|
|
336
|
+
let frames = 0;
|
|
337
|
+
let previous = new Map();
|
|
338
|
+
let lastScrollX = window.scrollX;
|
|
339
|
+
let lastScrollY = window.scrollY;
|
|
340
|
+
|
|
341
|
+
/** Does this element put anything on the screen, or only position things? */
|
|
342
|
+
const paints = (style) =>
|
|
343
|
+
style.backgroundImage !== "none" ||
|
|
344
|
+
style.backdropFilter !== "none" ||
|
|
345
|
+
!/^rgba\(.*,\s*0(\.0+)?\)$/.test(style.backgroundColor);
|
|
346
|
+
|
|
347
|
+
const keyOf = (el) => {
|
|
348
|
+
const cls = typeof el.className === "string" ? el.className.trim().split(/\\s+/)[0] : "";
|
|
349
|
+
return el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (cls ? "." + cls : "");
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A readable key is not an identity. An SVG holds many rect elements with no
|
|
354
|
+
* id and no class, and on a real page every one of them collapsed into a
|
|
355
|
+
* single track whose interleaved positions looked like a 633px tear. Each
|
|
356
|
+
* element carries its own number; the key is only what the reader is shown.
|
|
357
|
+
*/
|
|
358
|
+
let counter = 0;
|
|
359
|
+
const ids = new WeakMap();
|
|
360
|
+
const idOf = (el) => {
|
|
361
|
+
let id = ids.get(el);
|
|
362
|
+
if (id === undefined) {
|
|
363
|
+
id = counter += 1;
|
|
364
|
+
ids.set(el, id);
|
|
365
|
+
}
|
|
366
|
+
return id;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const tick =() => {
|
|
370
|
+
frames += 1;
|
|
371
|
+
const at = Math.round(performance.now() - started);
|
|
372
|
+
const now = new Map();
|
|
373
|
+
const moved = [];
|
|
374
|
+
/**
|
|
375
|
+
* Scrolling moves every box on the screen at once. Measured against the
|
|
376
|
+
* viewport, a scroll reads as the whole page animating and as a jump of
|
|
377
|
+
* hundreds of pixels in every track — the first run against a real page
|
|
378
|
+
* reported exactly that. Motion is therefore measured against the document,
|
|
379
|
+
* and only coverage and motion-over-text use where a thing is on screen.
|
|
380
|
+
*/
|
|
381
|
+
const sx = window.scrollX;
|
|
382
|
+
const sy = window.scrollY;
|
|
383
|
+
const scrolled = sx !== lastScrollX || sy !== lastScrollY;
|
|
384
|
+
lastScrollX = sx;
|
|
385
|
+
lastScrollY = sy;
|
|
386
|
+
|
|
387
|
+
for (const el of document.querySelectorAll("*")) {
|
|
388
|
+
const style = getComputedStyle(el);
|
|
389
|
+
const transform = style.transform === "none" ? "" : style.transform;
|
|
390
|
+
const opacity = Math.round(parseFloat(style.opacity) * 100) / 100;
|
|
391
|
+
/** Only what can move: GSAP's own, or anything transformed or faded. */
|
|
392
|
+
if (!el._gsap && !transform && opacity === 1) continue;
|
|
393
|
+
|
|
394
|
+
const box = el.getBoundingClientRect();
|
|
395
|
+
const state = {
|
|
396
|
+
x: Math.round((box.x + sx) * 10) / 10,
|
|
397
|
+
y: Math.round((box.y + sy) * 10) / 10,
|
|
398
|
+
w: Math.round(box.width * 10) / 10,
|
|
399
|
+
h: Math.round(box.height * 10) / 10,
|
|
400
|
+
o: opacity,
|
|
401
|
+
t: transform,
|
|
402
|
+
screen: [
|
|
403
|
+
Math.round(box.x * 10) / 10,
|
|
404
|
+
Math.round(box.y * 10) / 10,
|
|
405
|
+
Math.round(box.width * 10) / 10,
|
|
406
|
+
Math.round(box.height * 10) / 10,
|
|
407
|
+
],
|
|
408
|
+
};
|
|
409
|
+
now.set(el, state);
|
|
410
|
+
|
|
411
|
+
const before = previous.get(el);
|
|
412
|
+
if (
|
|
413
|
+
before &&
|
|
414
|
+
(before.x !== state.x || before.y !== state.y || before.w !== state.w ||
|
|
415
|
+
before.h !== state.h || before.o !== state.o || before.t !== state.t)
|
|
416
|
+
) {
|
|
417
|
+
moved.push({
|
|
418
|
+
id: idOf(el),
|
|
419
|
+
key: keyOf(el),
|
|
420
|
+
gsap: Boolean(el._gsap),
|
|
421
|
+
box: state.screen,
|
|
422
|
+
x: state.x,
|
|
423
|
+
opacity: state.o,
|
|
424
|
+
paints: paints(style),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
previous = now;
|
|
430
|
+
if (moved.length) log.push({ at, moved, scrolled });
|
|
431
|
+
requestAnimationFrame(tick);
|
|
432
|
+
};
|
|
433
|
+
requestAnimationFrame(tick);
|
|
434
|
+
|
|
435
|
+
/** The text on the page, read once at the end: what a reader could be reading. */
|
|
436
|
+
const textLines = () => {
|
|
437
|
+
const lines = [];
|
|
438
|
+
for (const el of document.querySelectorAll("p, h1, h2, h3, h4, li, blockquote, figcaption")) {
|
|
439
|
+
const text = (el.textContent || "").trim();
|
|
440
|
+
if (text.length < 20) continue;
|
|
441
|
+
const box = el.getBoundingClientRect();
|
|
442
|
+
if (box.width < 40 || box.height < 10) continue;
|
|
443
|
+
if (box.y > innerHeight || box.y + box.height < 0) continue;
|
|
444
|
+
/**
|
|
445
|
+
* Boxes cannot express stacking. A full-screen backdrop sits under the
|
|
446
|
+
* text and still covers it geometrically; a real page reported one as
|
|
447
|
+
* covering every line on it. Hit testing is the browser's own answer to
|
|
448
|
+
* what is on top, so the covering element is read rather than inferred.
|
|
449
|
+
*/
|
|
450
|
+
const onTop = document.elementFromPoint(
|
|
451
|
+
Math.min(innerWidth - 1, Math.max(0, box.x + box.width / 2)),
|
|
452
|
+
Math.min(innerHeight - 1, Math.max(0, box.y + box.height / 2)),
|
|
453
|
+
);
|
|
454
|
+
const covered = onTop && onTop !== el && !el.contains(onTop) ? idOf(onTop) : null;
|
|
455
|
+
|
|
456
|
+
lines.push({
|
|
457
|
+
key: el.tagName.toLowerCase() + (el.id ? "#" + el.id : ""),
|
|
458
|
+
covered,
|
|
459
|
+
box: [
|
|
460
|
+
Math.round(box.x), Math.round(box.y),
|
|
461
|
+
Math.round(box.width), Math.round(box.height),
|
|
462
|
+
],
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return lines;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
window.__gsapMotionReview = () => ({
|
|
469
|
+
frames,
|
|
470
|
+
elapsed: Math.round(performance.now() - started),
|
|
471
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
472
|
+
text: textLines(),
|
|
473
|
+
log,
|
|
474
|
+
});
|
|
475
|
+
return true;
|
|
476
|
+
})()`;
|
|
477
|
+
|
|
478
|
+
// --- Watching ------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
481
|
+
|
|
482
|
+
async function watch(options) {
|
|
483
|
+
const executable = findChrome(options.chrome);
|
|
484
|
+
if (!executable) {
|
|
485
|
+
throw new Error("No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const browser = connect(executable);
|
|
489
|
+
const notes = [];
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
const { targetId } = await browser.send("Target.createTarget", { url: "about:blank" });
|
|
493
|
+
const { sessionId } = await browser.send("Target.attachToTarget", { targetId, flatten: true });
|
|
494
|
+
const call = (method, params) => browser.send(method, params, sessionId);
|
|
495
|
+
const evaluate = async (expression) => {
|
|
496
|
+
const { result } = await call("Runtime.evaluate", {
|
|
497
|
+
expression,
|
|
498
|
+
returnByValue: true,
|
|
499
|
+
awaitPromise: true,
|
|
500
|
+
});
|
|
501
|
+
return result.value;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
await call("Page.enable");
|
|
505
|
+
await call("Runtime.enable");
|
|
506
|
+
await call("Performance.enable");
|
|
507
|
+
|
|
508
|
+
const features = [];
|
|
509
|
+
if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
|
|
510
|
+
if (options.dark) features.push({ name: "prefers-color-scheme", value: "dark" });
|
|
511
|
+
if (features.length) await call("Emulation.setEmulatedMedia", { features });
|
|
512
|
+
if (options.mobile) {
|
|
513
|
+
await call("Emulation.setDeviceMetricsOverride", {
|
|
514
|
+
width: 390,
|
|
515
|
+
height: 844,
|
|
516
|
+
deviceScaleFactor: 3,
|
|
517
|
+
mobile: true,
|
|
518
|
+
});
|
|
519
|
+
await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
|
520
|
+
}
|
|
521
|
+
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
522
|
+
|
|
523
|
+
await call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDER });
|
|
524
|
+
|
|
525
|
+
const before = await call("Performance.getMetrics");
|
|
526
|
+
await call("Page.navigate", { url: options.url });
|
|
527
|
+
await wait(options.watch);
|
|
528
|
+
|
|
529
|
+
if (options.scroll !== null) {
|
|
530
|
+
const pixels = asNumber(options.scroll);
|
|
531
|
+
const moved = await evaluate(
|
|
532
|
+
pixels === null
|
|
533
|
+
? `(() => {
|
|
534
|
+
const element = document.querySelector(${JSON.stringify(options.scroll)});
|
|
535
|
+
if (!element) return null;
|
|
536
|
+
element.scrollIntoView({ block: "center" });
|
|
537
|
+
return Math.round(window.scrollY);
|
|
538
|
+
})()`
|
|
539
|
+
: `(window.scrollTo(0, ${pixels}), Math.round(window.scrollY))`,
|
|
540
|
+
);
|
|
541
|
+
notes.push(moved === null ? `scroll: nothing matched ${options.scroll}` : `scroll: to ${moved}px`);
|
|
542
|
+
await wait(options.watch);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const recorded = await evaluate(
|
|
546
|
+
"window.__gsapMotionReview ? window.__gsapMotionReview() : null",
|
|
547
|
+
);
|
|
548
|
+
const after = await call("Performance.getMetrics");
|
|
549
|
+
|
|
550
|
+
if (!recorded) throw new Error("The recorder never ran. The page may have blocked scripts.");
|
|
551
|
+
|
|
552
|
+
const delta = (name) => {
|
|
553
|
+
const start = before.metrics.find((m) => m.name === name)?.value ?? 0;
|
|
554
|
+
const end = after.metrics.find((m) => m.name === name)?.value ?? 0;
|
|
555
|
+
return end - start;
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
let shot = null;
|
|
559
|
+
if (options.out) {
|
|
560
|
+
mkdirSync(resolve(options.out), { recursive: true });
|
|
561
|
+
const png = await call("Page.captureScreenshot", { format: "png" });
|
|
562
|
+
shot = join(resolve(options.out), "review.png");
|
|
563
|
+
writeFileSync(shot, Buffer.from(png.data, "base64"));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
url: options.url,
|
|
568
|
+
recorded,
|
|
569
|
+
layouts: delta("LayoutCount"),
|
|
570
|
+
recalcs: delta("RecalcStyleCount"),
|
|
571
|
+
shot,
|
|
572
|
+
notes,
|
|
573
|
+
};
|
|
574
|
+
} finally {
|
|
575
|
+
await browser.close();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// --- Conclusions ---------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
/** Turn one recording into the findings a reviewer can act on. */
|
|
582
|
+
export function conclude(result) {
|
|
583
|
+
const { recorded } = result;
|
|
584
|
+
const { log, viewport, text, frames, elapsed } = recorded;
|
|
585
|
+
|
|
586
|
+
const busiest = log
|
|
587
|
+
.map((frame) => ({ at: frame.at, share: coverage(frame.moved.map((m) => m.box), viewport) }))
|
|
588
|
+
.sort((a, b) => b.share - a.share)[0] ?? null;
|
|
589
|
+
|
|
590
|
+
/** One track per element, to find the loops that tear. */
|
|
591
|
+
const tracks = new Map();
|
|
592
|
+
const named = new Map();
|
|
593
|
+
for (const frame of log) {
|
|
594
|
+
for (const moved of frame.moved) {
|
|
595
|
+
const id = moved.id ?? moved.key;
|
|
596
|
+
const track = tracks.get(id) ?? [];
|
|
597
|
+
track.push({ at: frame.at, x: moved.x });
|
|
598
|
+
tracks.set(id, track);
|
|
599
|
+
named.set(id, moved.key);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const scrolledAt = log.filter((frame) => frame.scrolled).map((frame) => frame.at);
|
|
603
|
+
const torn = [];
|
|
604
|
+
for (const [key, track] of tracks) {
|
|
605
|
+
/** Only long-running motion can have a seam; a reveal simply ends. */
|
|
606
|
+
if (track.length < 20) continue;
|
|
607
|
+
const found = afterScroll(seams(track), scrolledAt);
|
|
608
|
+
if (found.length) {
|
|
609
|
+
torn.push({ key: named.get(key) ?? key, seams: found.slice(0, 3), frames: track.length });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Motion over text, judged at the busiest moment rather than every frame. */
|
|
614
|
+
const atBusiest = busiest ? log.find((frame) => frame.at === busiest.at) : null;
|
|
615
|
+
const overText = atBusiest ? motionOverText(atBusiest.moved, text) : [];
|
|
616
|
+
|
|
617
|
+
const began = starts(log);
|
|
618
|
+
return {
|
|
619
|
+
firstMotion: firstMotion(log),
|
|
620
|
+
frames,
|
|
621
|
+
elapsed,
|
|
622
|
+
fps: elapsed ? Math.round((frames / elapsed) * 1000 * 10) / 10 : null,
|
|
623
|
+
busiest,
|
|
624
|
+
movers: tracks.size,
|
|
625
|
+
gsapMovers: new Set(
|
|
626
|
+
log.flatMap((frame) => frame.moved.filter((m) => m.gsap).map((m) => m.id ?? m.key)),
|
|
627
|
+
).size,
|
|
628
|
+
seams: seamEvents(torn),
|
|
629
|
+
overText,
|
|
630
|
+
began: began.slice(0, 8),
|
|
631
|
+
spread: began.length > 1 ? began.at(-1).at - began[0].at : 0,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// --- Report --------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
export function report(result, options) {
|
|
638
|
+
const found = conclude(result);
|
|
639
|
+
const lines = [`Reviewed ${result.url}`];
|
|
640
|
+
|
|
641
|
+
const emulated = [
|
|
642
|
+
options.reduced && "reduced motion",
|
|
643
|
+
options.dark && "dark",
|
|
644
|
+
options.mobile && "mobile 390x844",
|
|
645
|
+
options.cpu > 1 && `CPU ÷${options.cpu}`,
|
|
646
|
+
].filter(Boolean);
|
|
647
|
+
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
648
|
+
for (const note of result.notes) lines.push(` ${note}`);
|
|
649
|
+
|
|
650
|
+
lines.push("", "What moved");
|
|
651
|
+
if (found.firstMotion === null) {
|
|
652
|
+
lines.push(" nothing moved in the whole window — the animation never started,");
|
|
653
|
+
lines.push(" or it needs a scroll or a pointer to begin");
|
|
654
|
+
} else {
|
|
655
|
+
lines.push(` first motion at ${found.firstMotion}ms`);
|
|
656
|
+
lines.push(
|
|
657
|
+
` ${found.movers} elements moved, ${found.gsapMovers} of them GSAP's` +
|
|
658
|
+
`${found.movers > found.gsapMovers ? " (the rest is CSS, which the source audit cannot see)" : ""}`,
|
|
659
|
+
);
|
|
660
|
+
if (found.spread) lines.push(` they started over a span of ${found.spread}ms`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (found.busiest) {
|
|
664
|
+
lines.push("", "How much moves at once");
|
|
665
|
+
lines.push(
|
|
666
|
+
` at its busiest, ${Math.round(found.busiest.share * 100)}% of the screen was in motion` +
|
|
667
|
+
` (${found.busiest.at}ms)`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
lines.push("", "Cost");
|
|
672
|
+
lines.push(
|
|
673
|
+
` ${found.frames} frames over ${found.elapsed}ms${found.fps ? ` (${found.fps}/s)` : ""}`,
|
|
674
|
+
);
|
|
675
|
+
/**
|
|
676
|
+
* Layout counts are deliberately absent. Reading every box every frame is
|
|
677
|
+
* what forces layout, so this run's counts are the sampler's own work as much
|
|
678
|
+
* as the page's — measured on a real page, sampling quadrupled them. `inspect`
|
|
679
|
+
* measures that without sampling, and is the honest place to read it.
|
|
680
|
+
*/
|
|
681
|
+
lines.push(" layout work is not measured here — sampling forces it; use inspect for that");
|
|
682
|
+
|
|
683
|
+
if (found.seams.length) {
|
|
684
|
+
lines.push("", "Seams — a step far larger than the motion's own rhythm");
|
|
685
|
+
for (const event of found.seams.slice(0, 6)) {
|
|
686
|
+
const size = event.least === event.most ? `${event.most}px` : `${event.least}-${event.most}px`;
|
|
687
|
+
lines.push(
|
|
688
|
+
` ${event.at}ms ${event.count === 1 ? event.example : `${event.count} elements (${event.example})`}` +
|
|
689
|
+
` jumps ${size}`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (found.overText.length) {
|
|
695
|
+
lines.push("", "Motion over text");
|
|
696
|
+
for (const o of found.overText.slice(0, 5)) {
|
|
697
|
+
lines.push(` ${o.over} covers ${Math.round(o.share * 100)}% of ${o.text}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (result.shot) lines.push("", `Frame: ${result.shot}`);
|
|
702
|
+
|
|
703
|
+
lines.push("", "These are measurements. Composition, rhythm and taste are yours to judge.");
|
|
704
|
+
return lines.join("\n");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// --- Entry ---------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
async function main(argv) {
|
|
710
|
+
const options = parseOptions(argv);
|
|
711
|
+
if (options.errors.length) {
|
|
712
|
+
for (const error of options.errors) console.error(error);
|
|
713
|
+
console.error("\nUsage: node review-motion.mjs <url> [--watch 3000] [--scroll <px|sel>] …");
|
|
714
|
+
process.exit(2);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const result = await watch(options);
|
|
719
|
+
console.log(
|
|
720
|
+
options.json
|
|
721
|
+
? JSON.stringify({ url: result.url, ...conclude(result) }, null, 2)
|
|
722
|
+
: report(result, options),
|
|
723
|
+
);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
console.error(error.message);
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
731
|
+
if (invoked) await main(process.argv.slice(2));
|