@mehshekari/gsap-motion 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/gsap-motion.mjs +21 -0
- package/package.json +1 -1
- package/skill/SKILL.md +15 -11
- package/skill/reference/refine.md +28 -0
- package/skill/reference/routing.md +12 -0
- package/skill/scripts/capture-motion.mjs +604 -0
- package/skill/scripts/explain-motion.mjs +596 -0
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ No dependencies, no install scripts, Node.js 18 or later.
|
|
|
20
20
|
| `remove` | Uninstalls it. Takes the same `--global` and `--dir`. It only ever removes a directory that holds this skill. |
|
|
21
21
|
| `audit [path...]` | Scans for leaks, per-frame cost, eased loops, shared plugin ids, unregistered plugins and shipped dev tooling. Default path `src`. `--quiet` for errors only, `--json` for scripts. |
|
|
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
|
+
| `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
|
+
| `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. |
|
|
23
25
|
| `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. |
|
|
24
26
|
|
|
25
27
|
## In CI
|
|
@@ -28,7 +30,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
28
30
|
build without warning:
|
|
29
31
|
|
|
30
32
|
```json
|
|
31
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@3.
|
|
33
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@3.4.1 audit src --quiet"
|
|
32
34
|
```
|
|
33
35
|
|
|
34
36
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
package/bin/gsap-motion.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* npx @mehshekari/gsap-motion remove [--global | --dir <skills-dir>]
|
|
8
8
|
* npx @mehshekari/gsap-motion audit [path...] [--quiet] [--json]
|
|
9
9
|
* npx @mehshekari/gsap-motion audit-svg <file.svg...> [--morph] [--hues]
|
|
10
|
+
* npx @mehshekari/gsap-motion inspect <url> [--at ...] [--hover ...] [--reduced]
|
|
11
|
+
* npx @mehshekari/gsap-motion explain <file...|url>
|
|
10
12
|
* npx @mehshekari/gsap-motion doctor
|
|
11
13
|
*
|
|
12
14
|
* Installed as a dependency, the same commands run as `gsap-motion <command>`.
|
|
@@ -69,6 +71,19 @@ Commands
|
|
|
69
71
|
audit-svg <file...> What an SVG can do before you animate it
|
|
70
72
|
--morph <a> <b> Compare a morph pair
|
|
71
73
|
--hues Also report hard-coded hues
|
|
74
|
+
inspect <url> Watch a page animate and report what it did
|
|
75
|
+
--at 0,300,900 When to capture, in ms after the page settles
|
|
76
|
+
--scroll <px|sel> Scroll before capturing
|
|
77
|
+
--hover <selector> Move the pointer onto an element
|
|
78
|
+
--click <selector> Click an element
|
|
79
|
+
--reduced --dark Emulate a preference
|
|
80
|
+
--mobile 390x844 with touch input
|
|
81
|
+
--cpu 4 Throttle the CPU by this factor
|
|
82
|
+
--out <dir> Where frames go. Default .gsap-motion/inspect
|
|
83
|
+
--json Machine-readable output
|
|
84
|
+
explain <file...> Map an animation: scopes, branches, beats, triggers
|
|
85
|
+
Give it a URL instead to read the running page
|
|
86
|
+
--json Machine-readable output
|
|
72
87
|
doctor Check Node, the installed skill and your project's GSAP
|
|
73
88
|
--version Print the version
|
|
74
89
|
|
|
@@ -301,6 +316,12 @@ switch (command) {
|
|
|
301
316
|
case "audit-svg":
|
|
302
317
|
runScript("audit-svg.mjs", args);
|
|
303
318
|
break;
|
|
319
|
+
case "inspect":
|
|
320
|
+
runScript("capture-motion.mjs", args);
|
|
321
|
+
break;
|
|
322
|
+
case "explain":
|
|
323
|
+
runScript("explain-motion.mjs", args);
|
|
324
|
+
break;
|
|
304
325
|
case "doctor":
|
|
305
326
|
doctor();
|
|
306
327
|
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.4.1"
|
|
8
8
|
verified-gsap: "3.15"
|
|
9
9
|
verified-gsap-react: "2.1"
|
|
10
10
|
---
|
|
@@ -163,27 +163,31 @@ never ship both.
|
|
|
163
163
|
|
|
164
164
|
## Tools
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
These catch what is **silent**. Run them from the project root; `<skill-dir>`
|
|
167
|
+
is the skill's folder.
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
170
|
# Leaks, per-event and per-frame tweens, layout and paint properties, loops,
|
|
171
|
-
#
|
|
171
|
+
# plugin ids, unregistered plugins, dev tooling. Exit 1 on an error.
|
|
172
172
|
node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
|
|
173
173
|
node <skill-dir>/scripts/audit-gsap.mjs src --quiet # errors only
|
|
174
|
-
node <skill-dir>/scripts/audit-gsap.mjs src --json
|
|
174
|
+
node <skill-dir>/scripts/audit-gsap.mjs src --json
|
|
175
175
|
|
|
176
|
-
# What an SVG can
|
|
176
|
+
# What an SVG can do, before any animation is written.
|
|
177
177
|
node <skill-dir>/scripts/audit-svg.mjs <file.svg>
|
|
178
178
|
node <skill-dir>/scripts/audit-svg.mjs --morph <a.svg> <b.svg>
|
|
179
|
-
node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg>
|
|
179
|
+
node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg>
|
|
180
|
+
|
|
181
|
+
# Watch a page animate: frames, timeline, browser work.
|
|
182
|
+
# Needs a Chrome; observations, not verdicts.
|
|
183
|
+
node <skill-dir>/scripts/capture-motion.mjs <url> --at 0,300,900
|
|
180
184
|
```
|
|
181
185
|
|
|
182
|
-
Run `audit-gsap` first in an `audit` and after writing
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
Run `audit-gsap` first in an `audit` and after writing animation — as a plugin,
|
|
187
|
+
a hook already runs it on every edit. A false finding is a bug in the script,
|
|
188
|
+
not something to work around.
|
|
185
189
|
|
|
186
190
|
`MotionPathHelper`, `GSDevTools`, `markers: true` and
|
|
187
191
|
`MorphSVGPlugin.findShapeIndex()` are development-only, and a static import
|
|
188
192
|
ships whatever `if` surrounds it. Import them dynamically behind a `NODE_ENV`
|
|
189
|
-
check, and delete
|
|
193
|
+
check, and delete it once its value is baked in.
|
|
@@ -25,6 +25,9 @@ script.
|
|
|
25
25
|
A finding you believe is wrong is a bug in the script. Report it; do not work
|
|
26
26
|
around it with a waiver.
|
|
27
27
|
|
|
28
|
+
When what the code says and what the page does disagree, `explain-motion.mjs`
|
|
29
|
+
reads both: the file's map, or — given a URL — the timeline GSAP actually built.
|
|
30
|
+
|
|
28
31
|
## Fix in this order
|
|
29
32
|
|
|
30
33
|
The order is not taste. A fix further down the list can be undone by a fix
|
|
@@ -50,6 +53,31 @@ at a time, and read what actually happened in the DOM.
|
|
|
50
53
|
A fix built on a guess usually works by accident, and the comment above it is
|
|
51
54
|
then wrong for everyone who reads it next.
|
|
52
55
|
|
|
56
|
+
## Watch it, when you can
|
|
57
|
+
|
|
58
|
+
A checker reads code. It cannot see a seam, a slideshow, or a hero that starts
|
|
59
|
+
so late the visitor has already scrolled past it. When a browser is on the
|
|
60
|
+
machine, run the page:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
node <skill-dir>/scripts/capture-motion.mjs http://localhost:3000 --at 0,300,900
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It reports a filmstrip, the timeline GSAP is actually running — which is not
|
|
67
|
+
always the one in the source — and the layouts, style recalculations and frames
|
|
68
|
+
the browser did. All observations; the judgement is yours.
|
|
69
|
+
|
|
70
|
+
Reach the state first, and change the conditions that usually break things:
|
|
71
|
+
|
|
72
|
+
- `--scroll <px|selector>`, `--hover <selector>`, `--click <selector>` run in
|
|
73
|
+
the order written, before the first capture. A hover effect or a mid-page
|
|
74
|
+
ScrollTrigger is invisible without them.
|
|
75
|
+
- `--reduced`, `--dark`, `--mobile`, `--cpu 4`. `--mobile` makes input touch,
|
|
76
|
+
so it cannot be combined with `--hover`: a coarse pointer has no hover, which
|
|
77
|
+
is the thing worth knowing.
|
|
78
|
+
|
|
79
|
+
**Without a browser, report the stage as not run.** Not "fine".
|
|
80
|
+
|
|
53
81
|
## When to stop
|
|
54
82
|
|
|
55
83
|
Stop when every check is clean, or after **three rounds**, reporting what is
|
|
@@ -99,6 +99,18 @@ the mechanical failures before you spend attention on them:
|
|
|
99
99
|
node .claude/skills/gsap-creative-animation/scripts/audit-gsap.mjs <path>
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
**Read the map before reading the code.** `explain-motion.mjs` prints what a
|
|
103
|
+
file animates — scopes, preference branches, beats in order with the reasons
|
|
104
|
+
their comments give, and the scroll configuration — and cites the audit rather
|
|
105
|
+
than judging. Given a URL it reads the running page instead, which is the only
|
|
106
|
+
way to see the timeline GSAP actually built, after every branch and early
|
|
107
|
+
return:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
node <skill-dir>/scripts/explain-motion.mjs src/hooks/useHero.ts
|
|
111
|
+
node <skill-dir>/scripts/explain-motion.mjs http://localhost:3000
|
|
112
|
+
```
|
|
113
|
+
|
|
102
114
|
**When an animation is not visible, ask whether it was ever built.** Before
|
|
103
115
|
touching timings or transforms, check that the code which creates it actually
|
|
104
116
|
runs. Animation is routinely built behind a condition — an image decode race, a
|
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Watches a page animate, and reports what it saw.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is an observation, never a verdict. It captures frames at
|
|
7
|
+
* given times, reads the timeline GSAP is actually running, and counts the
|
|
8
|
+
* layouts, style recalculations and frames the browser did — and then says so,
|
|
9
|
+
* with numbers. Whether 14 layouts in a second is a problem depends on the
|
|
10
|
+
* animation, and that judgement belongs to whoever reads this.
|
|
11
|
+
*
|
|
12
|
+
* It drives Chrome over the DevTools Protocol through a pipe — `stdin` and
|
|
13
|
+
* `stdout` of two extra file descriptors — rather than a WebSocket, because
|
|
14
|
+
* Node 18 has no global WebSocket and this skill installs nothing. It uses
|
|
15
|
+
* whatever Chrome the machine already has.
|
|
16
|
+
*
|
|
17
|
+
* Usage, from the project root:
|
|
18
|
+
* node <skill-dir>/scripts/capture-motion.mjs <url> [options]
|
|
19
|
+
*
|
|
20
|
+
* --at 0,300,900 when to capture, in ms after the page settles
|
|
21
|
+
* --wait 400 settle time before the first capture (default 400)
|
|
22
|
+
* --out <dir> where frames go (default .gsap-motion/inspect)
|
|
23
|
+
* --scroll <px|sel> scroll before capturing: a number, or to a selector
|
|
24
|
+
* --hover <selector> move the pointer onto an element, at its live box
|
|
25
|
+
* --click <selector> click an element
|
|
26
|
+
* --reduced emulate prefers-reduced-motion: reduce
|
|
27
|
+
* --dark emulate prefers-color-scheme: dark
|
|
28
|
+
* --mobile 390x844 at dpr 3, with touch input
|
|
29
|
+
* --cpu 4 throttle the CPU by this factor
|
|
30
|
+
* --json machine-readable, for scripts
|
|
31
|
+
* --chrome <path> a browser to use, overriding the search
|
|
32
|
+
*
|
|
33
|
+
* `--scroll`, `--hover` and `--click` run in the order they are written, before
|
|
34
|
+
* the first capture. An animation that needs a pointer or a scroll position is
|
|
35
|
+
* invisible without them.
|
|
36
|
+
*
|
|
37
|
+
* `--mobile` makes input touch, so it cannot be combined with `--hover`: a
|
|
38
|
+
* device with a coarse pointer has no hover, which is the point.
|
|
39
|
+
*/
|
|
40
|
+
import { spawn } from "node:child_process";
|
|
41
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { homedir, tmpdir } from "node:os";
|
|
43
|
+
import { join, resolve } from "node:path";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
|
|
46
|
+
// --- The browser -------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Where a Chrome usually is, per platform. `CHROME_PATH` wins over all of it. */
|
|
49
|
+
export function findChrome(override) {
|
|
50
|
+
const candidates = [
|
|
51
|
+
override,
|
|
52
|
+
process.env.GSAP_MOTION_CHROME,
|
|
53
|
+
process.env.CHROME_PATH,
|
|
54
|
+
...(process.platform === "win32"
|
|
55
|
+
? [
|
|
56
|
+
`${process.env.ProgramFiles}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
57
|
+
`${process.env["ProgramFiles(x86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
58
|
+
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
59
|
+
`${process.env.ProgramFiles}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
60
|
+
]
|
|
61
|
+
: process.platform === "darwin"
|
|
62
|
+
? [
|
|
63
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
64
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
65
|
+
`${homedir()}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
66
|
+
]
|
|
67
|
+
: [
|
|
68
|
+
"/usr/bin/google-chrome",
|
|
69
|
+
"/usr/bin/google-chrome-stable",
|
|
70
|
+
"/usr/bin/chromium",
|
|
71
|
+
"/usr/bin/chromium-browser",
|
|
72
|
+
"/snap/bin/chromium",
|
|
73
|
+
]),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
return candidates.find((path) => path && existsSync(path)) ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A CDP connection over Chrome's pipe transport: commands into file descriptor
|
|
81
|
+
* 3, replies and events out of 4, each message a JSON object followed by a NUL.
|
|
82
|
+
*/
|
|
83
|
+
export function connect(executable, { headless = true } = {}) {
|
|
84
|
+
const profile = join(tmpdir(), `gsap-motion-inspect-${process.pid}`);
|
|
85
|
+
const chrome = spawn(
|
|
86
|
+
executable,
|
|
87
|
+
[
|
|
88
|
+
...(headless ? ["--headless=new"] : []),
|
|
89
|
+
"--remote-debugging-pipe",
|
|
90
|
+
"--no-first-run",
|
|
91
|
+
"--no-default-browser-check",
|
|
92
|
+
"--disable-gpu",
|
|
93
|
+
"--disable-extensions",
|
|
94
|
+
`--user-data-dir=${profile}`,
|
|
95
|
+
"about:blank",
|
|
96
|
+
],
|
|
97
|
+
{ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"] },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const [, , stderr, toBrowser, fromBrowser] = chrome.stdio;
|
|
101
|
+
const stderrLines = [];
|
|
102
|
+
stderr.on("data", (chunk) => stderrLines.push(chunk.toString()));
|
|
103
|
+
|
|
104
|
+
const waiting = new Map();
|
|
105
|
+
let buffer = "";
|
|
106
|
+
|
|
107
|
+
fromBrowser.on("data", (chunk) => {
|
|
108
|
+
buffer += chunk.toString("utf8");
|
|
109
|
+
let end = buffer.indexOf("\0");
|
|
110
|
+
while (end !== -1) {
|
|
111
|
+
let message;
|
|
112
|
+
try {
|
|
113
|
+
message = JSON.parse(buffer.slice(0, end));
|
|
114
|
+
} catch {
|
|
115
|
+
message = null;
|
|
116
|
+
}
|
|
117
|
+
buffer = buffer.slice(end + 1);
|
|
118
|
+
const pending = message?.id && waiting.get(message.id);
|
|
119
|
+
if (pending) {
|
|
120
|
+
waiting.delete(message.id);
|
|
121
|
+
if (message.error) pending.reject(new Error(message.error.message ?? "CDP error"));
|
|
122
|
+
else pending.resolve(message.result);
|
|
123
|
+
}
|
|
124
|
+
end = buffer.indexOf("\0");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let nextId = 0;
|
|
129
|
+
const send = (method, params = {}, sessionId) =>
|
|
130
|
+
new Promise((accept, reject) => {
|
|
131
|
+
const id = (nextId += 1);
|
|
132
|
+
waiting.set(id, { resolve: accept, reject });
|
|
133
|
+
toBrowser.write(
|
|
134
|
+
`${JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })}\0`,
|
|
135
|
+
);
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
if (!waiting.has(id)) return;
|
|
138
|
+
waiting.delete(id);
|
|
139
|
+
reject(new Error(`${method} did not answer within 20s`));
|
|
140
|
+
}, 20000);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const close = async () => {
|
|
144
|
+
try {
|
|
145
|
+
await send("Browser.close");
|
|
146
|
+
} catch {
|
|
147
|
+
chrome.kill();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return { send, close, stderrLines };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Options -----------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** A number of milliseconds, or null when the text is not one. */
|
|
157
|
+
const asNumber = (text) => {
|
|
158
|
+
const value = Number(text);
|
|
159
|
+
return Number.isFinite(value) ? value : null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reads the command line. Exported because the parsing is worth testing without
|
|
164
|
+
* a browser, and because a wrong `--at` should fail before Chrome starts.
|
|
165
|
+
*/
|
|
166
|
+
export function parseOptions(argv) {
|
|
167
|
+
const options = {
|
|
168
|
+
url: null,
|
|
169
|
+
at: [0, 300, 900],
|
|
170
|
+
wait: 400,
|
|
171
|
+
out: join(".gsap-motion", "inspect"),
|
|
172
|
+
steps: [],
|
|
173
|
+
reduced: false,
|
|
174
|
+
dark: false,
|
|
175
|
+
mobile: false,
|
|
176
|
+
cpu: 1,
|
|
177
|
+
json: false,
|
|
178
|
+
chrome: null,
|
|
179
|
+
errors: [],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
183
|
+
const argument = argv[index];
|
|
184
|
+
const value = () => argv[(index += 1)];
|
|
185
|
+
|
|
186
|
+
switch (argument) {
|
|
187
|
+
case "--at": {
|
|
188
|
+
const times = String(value() ?? "")
|
|
189
|
+
.split(",")
|
|
190
|
+
.map((part) => asNumber(part.trim()));
|
|
191
|
+
if (times.length === 0 || times.some((time) => time === null || time < 0)) {
|
|
192
|
+
options.errors.push("--at takes milliseconds, such as --at 0,300,900");
|
|
193
|
+
} else {
|
|
194
|
+
options.at = times.sort((a, b) => a - b);
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "--wait": {
|
|
199
|
+
const ms = asNumber(value());
|
|
200
|
+
if (ms === null || ms < 0) options.errors.push("--wait takes milliseconds");
|
|
201
|
+
else options.wait = ms;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "--out":
|
|
205
|
+
options.out = value() ?? options.out;
|
|
206
|
+
break;
|
|
207
|
+
case "--scroll":
|
|
208
|
+
options.steps.push({ type: "scroll", value: value() ?? "" });
|
|
209
|
+
break;
|
|
210
|
+
case "--hover":
|
|
211
|
+
options.steps.push({ type: "hover", value: value() ?? "" });
|
|
212
|
+
break;
|
|
213
|
+
case "--click":
|
|
214
|
+
options.steps.push({ type: "click", value: value() ?? "" });
|
|
215
|
+
break;
|
|
216
|
+
case "--reduced":
|
|
217
|
+
options.reduced = true;
|
|
218
|
+
break;
|
|
219
|
+
case "--dark":
|
|
220
|
+
options.dark = true;
|
|
221
|
+
break;
|
|
222
|
+
case "--mobile":
|
|
223
|
+
options.mobile = true;
|
|
224
|
+
break;
|
|
225
|
+
case "--cpu": {
|
|
226
|
+
const rate = asNumber(value());
|
|
227
|
+
if (rate === null || rate < 1) options.errors.push("--cpu takes a factor of 1 or more");
|
|
228
|
+
else options.cpu = rate;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "--json":
|
|
232
|
+
options.json = true;
|
|
233
|
+
break;
|
|
234
|
+
case "--chrome":
|
|
235
|
+
options.chrome = value() ?? null;
|
|
236
|
+
break;
|
|
237
|
+
default:
|
|
238
|
+
if (argument.startsWith("--")) options.errors.push(`Unknown option: ${argument}`);
|
|
239
|
+
else if (options.url === null) options.url = argument;
|
|
240
|
+
else options.errors.push(`Unexpected argument: ${argument}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (options.url === null) options.errors.push("No URL. Pass the page to watch.");
|
|
245
|
+
|
|
246
|
+
/** A coarse pointer has no hover; emulating both would measure something no device is. */
|
|
247
|
+
if (options.mobile && options.steps.some((step) => step.type === "hover")) {
|
|
248
|
+
options.errors.push(
|
|
249
|
+
"--mobile and --hover cannot be combined: mobile emulation makes input touch, and a touch device has no hover.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return options;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- What runs in the page ---------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The timeline GSAP is running, read from the page itself rather than guessed
|
|
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.
|
|
269
|
+
*/
|
|
270
|
+
const READ_TIMELINE = `(() => {
|
|
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);
|
|
284
|
+
return {
|
|
285
|
+
readable: true,
|
|
286
|
+
version,
|
|
287
|
+
controlled,
|
|
288
|
+
time: handle.globalTimeline.time(),
|
|
289
|
+
children: children.map((child) => ({
|
|
290
|
+
targets: (child.targets?.() ?? []).map((target) =>
|
|
291
|
+
target?.id ? "#" + target.id :
|
|
292
|
+
target?.tagName ? target.tagName.toLowerCase() :
|
|
293
|
+
String(target)
|
|
294
|
+
),
|
|
295
|
+
duration: Number(child.duration?.().toFixed?.(3) ?? child.duration?.() ?? 0),
|
|
296
|
+
repeat: child.repeat?.() ?? 0,
|
|
297
|
+
yoyo: child.yoyo?.() ?? false,
|
|
298
|
+
paused: child.paused?.() ?? false,
|
|
299
|
+
ease: typeof child.vars?.ease === "string" ? child.vars.ease : child.vars?.ease ? "(function)" : null,
|
|
300
|
+
props: Object.keys(child.vars ?? {}).filter(
|
|
301
|
+
(key) => !["ease", "duration", "repeat", "yoyo", "scrollTrigger", "onComplete", "onUpdate", "delay", "stagger"].includes(key),
|
|
302
|
+
),
|
|
303
|
+
})),
|
|
304
|
+
};
|
|
305
|
+
})()`;
|
|
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
|
+
|
|
328
|
+
/** The live box of a selector, so a pointer lands on an element that is moving. */
|
|
329
|
+
const boxOf = (selector) => `(() => {
|
|
330
|
+
const element = document.querySelector(${JSON.stringify(selector)});
|
|
331
|
+
if (!element) return null;
|
|
332
|
+
const box = element.getBoundingClientRect();
|
|
333
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2, width: box.width, height: box.height };
|
|
334
|
+
})()`;
|
|
335
|
+
|
|
336
|
+
// --- The run -----------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
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
|
+
|
|
351
|
+
async function capture(options) {
|
|
352
|
+
const executable = findChrome(options.chrome);
|
|
353
|
+
if (!executable) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
"No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const browser = connect(executable);
|
|
360
|
+
const notes = [];
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const { targetId } = await browser.send("Target.createTarget", { url: "about:blank" });
|
|
364
|
+
const { sessionId } = await browser.send("Target.attachToTarget", {
|
|
365
|
+
targetId,
|
|
366
|
+
flatten: true,
|
|
367
|
+
});
|
|
368
|
+
const call = (method, params) => browser.send(method, params, sessionId);
|
|
369
|
+
const evaluate = async (expression) => {
|
|
370
|
+
const { result } = await call("Runtime.evaluate", {
|
|
371
|
+
expression,
|
|
372
|
+
returnByValue: true,
|
|
373
|
+
awaitPromise: true,
|
|
374
|
+
});
|
|
375
|
+
return result.value;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
await call("Page.enable");
|
|
379
|
+
await call("Runtime.enable");
|
|
380
|
+
await call("Performance.enable");
|
|
381
|
+
|
|
382
|
+
const features = [];
|
|
383
|
+
if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
|
|
384
|
+
if (options.dark) features.push({ name: "prefers-color-scheme", value: "dark" });
|
|
385
|
+
if (features.length) await call("Emulation.setEmulatedMedia", { features });
|
|
386
|
+
|
|
387
|
+
if (options.mobile) {
|
|
388
|
+
await call("Emulation.setDeviceMetricsOverride", {
|
|
389
|
+
width: 390,
|
|
390
|
+
height: 844,
|
|
391
|
+
deviceScaleFactor: 3,
|
|
392
|
+
mobile: true,
|
|
393
|
+
});
|
|
394
|
+
await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
398
|
+
|
|
399
|
+
await call("Page.addScriptToEvaluateOnNewDocument", { source: COUNT_FRAMES });
|
|
400
|
+
|
|
401
|
+
const before = await call("Performance.getMetrics");
|
|
402
|
+
await call("Page.navigate", { url: options.url });
|
|
403
|
+
await wait(options.wait);
|
|
404
|
+
|
|
405
|
+
for (const step of options.steps) {
|
|
406
|
+
if (step.type === "scroll") {
|
|
407
|
+
const pixels = asNumber(step.value);
|
|
408
|
+
const moved = await evaluate(
|
|
409
|
+
pixels === null
|
|
410
|
+
? `(() => {
|
|
411
|
+
const element = document.querySelector(${JSON.stringify(step.value)});
|
|
412
|
+
if (!element) return null;
|
|
413
|
+
element.scrollIntoView({ block: "center" });
|
|
414
|
+
return Math.round(window.scrollY);
|
|
415
|
+
})()`
|
|
416
|
+
: `(window.scrollTo(0, ${pixels}), Math.round(window.scrollY))`,
|
|
417
|
+
);
|
|
418
|
+
notes.push(
|
|
419
|
+
moved === null
|
|
420
|
+
? `scroll: nothing matched ${step.value}`
|
|
421
|
+
: `scroll: to ${moved}px`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (step.type === "hover" || step.type === "click") {
|
|
426
|
+
const box = await evaluate(boxOf(step.value));
|
|
427
|
+
if (!box) {
|
|
428
|
+
notes.push(`${step.type}: nothing matched ${step.value}`);
|
|
429
|
+
continue;
|
|
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
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Two moves, because a pointer that is already there never enters; and
|
|
439
|
+
* the box is read now, since whatever is animating has moved since the
|
|
440
|
+
* page loaded.
|
|
441
|
+
*/
|
|
442
|
+
await call("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1, y: 1, buttons: 0 });
|
|
443
|
+
await wait(60);
|
|
444
|
+
await call("Input.dispatchMouseEvent", {
|
|
445
|
+
type: "mouseMoved",
|
|
446
|
+
x: box.x,
|
|
447
|
+
y: box.y,
|
|
448
|
+
buttons: 0,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (step.type === "click") {
|
|
452
|
+
for (const type of ["mousePressed", "mouseReleased"]) {
|
|
453
|
+
await call("Input.dispatchMouseEvent", {
|
|
454
|
+
type,
|
|
455
|
+
x: box.x,
|
|
456
|
+
y: box.y,
|
|
457
|
+
button: "left",
|
|
458
|
+
buttons: type === "mousePressed" ? 1 : 0,
|
|
459
|
+
clickCount: 1,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
notes.push(
|
|
464
|
+
`${step.type}: ${step.value} at ${Math.round(box.x)},${Math.round(box.y)}`,
|
|
465
|
+
);
|
|
466
|
+
await wait(120);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
mkdirSync(resolve(options.out), { recursive: true });
|
|
471
|
+
|
|
472
|
+
const frames = [];
|
|
473
|
+
let elapsed = 0;
|
|
474
|
+
for (const at of options.at) {
|
|
475
|
+
if (at > elapsed) {
|
|
476
|
+
await wait(at - elapsed);
|
|
477
|
+
elapsed = at;
|
|
478
|
+
}
|
|
479
|
+
const shot = await call("Page.captureScreenshot", { format: "png" });
|
|
480
|
+
const bytes = Buffer.from(shot.data, "base64");
|
|
481
|
+
const file = join(resolve(options.out), `frame-${String(at).padStart(5, "0")}ms.png`);
|
|
482
|
+
writeFileSync(file, bytes);
|
|
483
|
+
frames.push({ at, file, bytes: bytes.length });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const timeline = await evaluate(READ_TIMELINE);
|
|
487
|
+
const drawn = await evaluate(
|
|
488
|
+
"({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
|
|
489
|
+
);
|
|
490
|
+
const after = await call("Performance.getMetrics");
|
|
491
|
+
|
|
492
|
+
const delta = (name) => {
|
|
493
|
+
const start = before.metrics.find((metric) => metric.name === name)?.value ?? 0;
|
|
494
|
+
const end = after.metrics.find((metric) => metric.name === name)?.value ?? 0;
|
|
495
|
+
return end - start;
|
|
496
|
+
};
|
|
497
|
+
|
|
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
|
+
);
|
|
504
|
+
const measured = {
|
|
505
|
+
layouts: delta("LayoutCount"),
|
|
506
|
+
recalcs: delta("RecalcStyleCount"),
|
|
507
|
+
frames: drawn.frames,
|
|
508
|
+
fps: drawn.frames && span ? Number(((drawn.frames / span) * 1000).toFixed(1)) : null,
|
|
509
|
+
layoutSeconds: Number(delta("LayoutDuration").toFixed(3)),
|
|
510
|
+
recalcSeconds: Number(delta("RecalcStyleDuration").toFixed(3)),
|
|
511
|
+
overMs: span,
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
return { url: options.url, frames, timeline, measured, notes, chrome: executable };
|
|
515
|
+
} finally {
|
|
516
|
+
await browser.close();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// --- Reporting ---------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
export function report(result, options) {
|
|
523
|
+
const lines = [`Watched ${result.url}`];
|
|
524
|
+
|
|
525
|
+
const emulated = [
|
|
526
|
+
options.reduced && "reduced motion",
|
|
527
|
+
options.dark && "dark",
|
|
528
|
+
options.mobile && "mobile 390x844",
|
|
529
|
+
options.cpu > 1 && `CPU ÷${options.cpu}`,
|
|
530
|
+
].filter(Boolean);
|
|
531
|
+
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
532
|
+
for (const note of result.notes) lines.push(` ${note}`);
|
|
533
|
+
|
|
534
|
+
lines.push("", `Frames (${result.frames.length}), in ${options.out}`);
|
|
535
|
+
for (const frame of result.frames) {
|
|
536
|
+
lines.push(` ${String(frame.at).padStart(5)}ms ${frame.file}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
lines.push("", "Timeline, as the page is running it");
|
|
540
|
+
if (result.timeline === null) {
|
|
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.");
|
|
553
|
+
} else if (result.timeline.children.length === 0) {
|
|
554
|
+
lines.push(" GSAP is loaded, and its global timeline has no children right now");
|
|
555
|
+
} else {
|
|
556
|
+
for (const child of result.timeline.children) {
|
|
557
|
+
const forever = child.repeat === -1;
|
|
558
|
+
lines.push(
|
|
559
|
+
` ${child.targets.join(", ") || "(no target)"} · ${child.duration}s` +
|
|
560
|
+
`${forever ? ", repeats forever" : child.repeat ? `, repeats ${child.repeat}` : ""}` +
|
|
561
|
+
`${child.yoyo ? ", yoyo" : ""}${child.paused ? ", paused" : ""}` +
|
|
562
|
+
`${child.ease ? `, ease ${child.ease}` : ""}` +
|
|
563
|
+
`${child.props.length ? ` · ${child.props.join(" ")}` : ""}`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const { measured } = result;
|
|
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}`;
|
|
574
|
+
lines.push(
|
|
575
|
+
` ${drew} · ${measured.layouts} layouts (${measured.layoutSeconds}s)` +
|
|
576
|
+
` · ${measured.recalcs} style recalculations (${measured.recalcSeconds}s)`,
|
|
577
|
+
);
|
|
578
|
+
lines.push("", "These are observations. What they mean is yours to judge.");
|
|
579
|
+
|
|
580
|
+
return lines.join("\n");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// --- Entry -------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
async function main(argv) {
|
|
586
|
+
const options = parseOptions(argv);
|
|
587
|
+
|
|
588
|
+
if (options.errors.length) {
|
|
589
|
+
for (const error of options.errors) console.error(error);
|
|
590
|
+
console.error("\nUsage: node capture-motion.mjs <url> [--at 0,300,900] [--hover <sel>] …");
|
|
591
|
+
process.exit(2);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
const result = await capture(options);
|
|
596
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : report(result, options));
|
|
597
|
+
} catch (error) {
|
|
598
|
+
console.error(error.message);
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
604
|
+
if (invoked) await main(process.argv.slice(2));
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps an animation: what it does, in what order, under what conditions, and
|
|
5
|
+
* what cleans it up.
|
|
6
|
+
*
|
|
7
|
+
* It changes nothing and judges nothing. Every line says where it came from,
|
|
8
|
+
* because that is the difference between a map and a guess:
|
|
9
|
+
*
|
|
10
|
+
* (file) read from the syntax tree — this is what the code says
|
|
11
|
+
* (comment) the reason the author wrote down, quoted
|
|
12
|
+
* (audit) a finding, with the rule that made it
|
|
13
|
+
* (page) read from the running page, which may differ from the source
|
|
14
|
+
* (inferred) a reading, not a fact
|
|
15
|
+
*
|
|
16
|
+
* Given files it reads the code. Given a URL it reads the running page, where
|
|
17
|
+
* the timeline is the one GSAP actually built — after every condition, branch
|
|
18
|
+
* and early return the source has.
|
|
19
|
+
*
|
|
20
|
+
* Usage, from the project root:
|
|
21
|
+
* node <skill-dir>/scripts/explain-motion.mjs <file...> [--json]
|
|
22
|
+
* node <skill-dir>/scripts/explain-motion.mjs <url> [--json] [--wait 600]
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
import { connect, findChrome } from "./capture-motion.mjs";
|
|
29
|
+
import { check, mentionsGsap } from "./lib/audit.mjs";
|
|
30
|
+
import {
|
|
31
|
+
calleeName,
|
|
32
|
+
contains,
|
|
33
|
+
dottedName,
|
|
34
|
+
findAll,
|
|
35
|
+
keptUnder,
|
|
36
|
+
methodName,
|
|
37
|
+
scopeOf,
|
|
38
|
+
propertyOf,
|
|
39
|
+
staticString,
|
|
40
|
+
timelineLinks,
|
|
41
|
+
tweenMethod,
|
|
42
|
+
unwrap,
|
|
43
|
+
} from "./lib/ast.mjs";
|
|
44
|
+
import { RULES } from "./lib/rules.mjs";
|
|
45
|
+
import { collect, lineAt, load } from "./lib/source.mjs";
|
|
46
|
+
|
|
47
|
+
// --- Reading the code --------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** The source of a node, on one line, short enough to read in a list. */
|
|
50
|
+
const textOf = (file, node, limit = 48) => {
|
|
51
|
+
if (!node) return null;
|
|
52
|
+
const text = file.raw.slice(node.start, node.end).replace(/\s+/g, " ").trim();
|
|
53
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The comment block immediately above `node`, which is where the reason usually
|
|
58
|
+
* is. Only a block that ends within two lines counts: a comment further up
|
|
59
|
+
* belongs to something else.
|
|
60
|
+
*/
|
|
61
|
+
function reasonFor(file, node) {
|
|
62
|
+
const line = lineAt(file.raw, node.start);
|
|
63
|
+
const above = file.comments
|
|
64
|
+
.filter((comment) => {
|
|
65
|
+
const ends = lineAt(file.raw, comment.end);
|
|
66
|
+
return ends < line && line - ends <= 2;
|
|
67
|
+
})
|
|
68
|
+
.sort((a, b) => b.end - a.end)[0];
|
|
69
|
+
if (!above) return null;
|
|
70
|
+
|
|
71
|
+
const sentence = above.value
|
|
72
|
+
.split("\n")
|
|
73
|
+
.map((row) => row.replace(/^\s*\*?\s?/, "").trim())
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join(" ")
|
|
76
|
+
.trim();
|
|
77
|
+
return sentence.length > 96 ? `${sentence.slice(0, 95)}…` : sentence;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The position argument of a timeline call: `"<"`, `"-=0.2"`, a number. */
|
|
81
|
+
function positionOf(file, call) {
|
|
82
|
+
const method = methodName(call);
|
|
83
|
+
const index = method === "fromTo" ? 3 : 2;
|
|
84
|
+
return textOf(file, call.arguments[index], 16);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Where a call keeps its vars: a timeline at 0, a fromTo at 2, everything else at 1. */
|
|
88
|
+
const varsOf = (call) => {
|
|
89
|
+
const method = methodName(call) ?? tweenMethod(call);
|
|
90
|
+
const index = method === "timeline" ? 0 : method === "fromTo" ? 2 : 1;
|
|
91
|
+
const node = unwrap(call.arguments[index]);
|
|
92
|
+
return node?.type === "ObjectExpression" ? node : null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where a call reads as starting. In `tl.from(a).from(b)` both calls begin at
|
|
97
|
+
* `tl`, so the method's own name is what tells the beats apart — and what puts
|
|
98
|
+
* each one's comment above the right beat.
|
|
99
|
+
*/
|
|
100
|
+
const startOf = (call) => unwrap(call.callee)?.property?.start ?? call.start;
|
|
101
|
+
|
|
102
|
+
const valueOf = (vars, key) => {
|
|
103
|
+
const property = vars && propertyOf(vars, key);
|
|
104
|
+
if (!property) return null;
|
|
105
|
+
const literal = unwrap(property.value);
|
|
106
|
+
if (literal?.type === "Literal") return String(literal.value);
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The scroll configuration of a tween's vars, or of a ScrollTrigger.create. */
|
|
111
|
+
function triggerOf(file, call) {
|
|
112
|
+
const vars = varsOf(call);
|
|
113
|
+
const own = vars && propertyOf(vars, "scrollTrigger");
|
|
114
|
+
const config = own ? unwrap(own.value) : null;
|
|
115
|
+
const direct =
|
|
116
|
+
calleeName(call) === "ScrollTrigger.create"
|
|
117
|
+
? unwrap(call.arguments[0])
|
|
118
|
+
: null;
|
|
119
|
+
const object = config?.type === "ObjectExpression" ? config : direct?.type === "ObjectExpression" ? direct : null;
|
|
120
|
+
if (!object) return null;
|
|
121
|
+
|
|
122
|
+
const read = (key) => {
|
|
123
|
+
const property = propertyOf(object, key);
|
|
124
|
+
return property ? textOf(file, property.value, 24) : null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
line: lineAt(file.raw, (own ?? call).start),
|
|
129
|
+
trigger: read("trigger"),
|
|
130
|
+
start: read("start"),
|
|
131
|
+
end: read("end"),
|
|
132
|
+
scrub: read("scrub"),
|
|
133
|
+
pin: read("pin"),
|
|
134
|
+
toggleActions: read("toggleActions"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readFile(file) {
|
|
139
|
+
const map = {
|
|
140
|
+
file: file.display,
|
|
141
|
+
scopes: [],
|
|
142
|
+
branches: [],
|
|
143
|
+
scenes: [],
|
|
144
|
+
loose: [],
|
|
145
|
+
triggers: [],
|
|
146
|
+
findings: [],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Where animation is created, and what owns its cleanup.
|
|
150
|
+
for (const call of findAll(file.ast, (node) => {
|
|
151
|
+
const name = calleeName(node);
|
|
152
|
+
return name === "useGSAP" || name === "gsap.context";
|
|
153
|
+
})) {
|
|
154
|
+
const vars = unwrap(call.arguments[1]);
|
|
155
|
+
map.scopes.push({
|
|
156
|
+
kind: calleeName(call),
|
|
157
|
+
line: lineAt(file.raw, call.start),
|
|
158
|
+
scope:
|
|
159
|
+
vars?.type === "ObjectExpression"
|
|
160
|
+
? textOf(file, propertyOf(vars, "scope")?.value, 24)
|
|
161
|
+
: textOf(file, call.arguments[1], 24),
|
|
162
|
+
why: reasonFor(file, call),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Device and preference branches.
|
|
167
|
+
for (const call of findAll(file.ast, (node) => methodName(node) === "add")) {
|
|
168
|
+
const conditions = unwrap(call.arguments[0]);
|
|
169
|
+
if (conditions?.type === "ObjectExpression") {
|
|
170
|
+
const named = conditions.properties
|
|
171
|
+
.map((property) => {
|
|
172
|
+
const query = staticString(property.value);
|
|
173
|
+
return query ? `${property.key.name ?? property.key.value}: ${query}` : null;
|
|
174
|
+
})
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
if (named.length) {
|
|
177
|
+
map.branches.push({ line: lineAt(file.raw, call.start), conditions: named });
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
const query = staticString(call.arguments[0]);
|
|
181
|
+
if (query && /\(.*:.*\)/.test(query)) {
|
|
182
|
+
map.branches.push({ line: lineAt(file.raw, call.start), conditions: [query] });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Scenes: a timeline, and the beats on it in order.
|
|
188
|
+
const timelines = findAll(file.ast, (node) => calleeName(node) === "gsap.timeline");
|
|
189
|
+
const claimed = new Set();
|
|
190
|
+
/** Which function holds each scene, since a file often has two called `tl`. */
|
|
191
|
+
const owners = new Map();
|
|
192
|
+
|
|
193
|
+
for (const timeline of timelines) {
|
|
194
|
+
const beats = [];
|
|
195
|
+
for (const link of timelineLinks(file.ast, timeline)) {
|
|
196
|
+
claimed.add(link);
|
|
197
|
+
const method = methodName(link);
|
|
198
|
+
if (method === "addLabel") {
|
|
199
|
+
beats.push({
|
|
200
|
+
label: staticString(link.arguments[0]) ?? textOf(file, link.arguments[0], 24) ?? "(label)",
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!["to", "from", "fromTo", "set", "call", "add"].includes(method)) continue;
|
|
205
|
+
|
|
206
|
+
const vars = varsOf(link);
|
|
207
|
+
beats.push({
|
|
208
|
+
line: lineAt(file.raw, startOf(link)),
|
|
209
|
+
method,
|
|
210
|
+
target: textOf(file, link.arguments[0], 32),
|
|
211
|
+
duration: valueOf(vars, "duration"),
|
|
212
|
+
ease: valueOf(vars, "ease"),
|
|
213
|
+
at: positionOf(file, link),
|
|
214
|
+
why: reasonFor(file, { start: startOf(link) }),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const scene = {
|
|
219
|
+
line: lineAt(file.raw, timeline.start),
|
|
220
|
+
name: keptUnder(file.ast, timeline)?.name ?? null,
|
|
221
|
+
repeat: valueOf(varsOf(timeline), "repeat"),
|
|
222
|
+
why: reasonFor(file, timeline),
|
|
223
|
+
beats,
|
|
224
|
+
};
|
|
225
|
+
map.scenes.push(scene);
|
|
226
|
+
owners.set(scene, scopeOf(file.ast, timeline));
|
|
227
|
+
}
|
|
228
|
+
|
|
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
|
+
};
|
|
243
|
+
|
|
244
|
+
// Beats added to a timeline this file was handed: the helper shape, where the
|
|
245
|
+
// caller owns the timeline and this file only fills it.
|
|
246
|
+
const handed = new Map();
|
|
247
|
+
for (const call of findAll(file.ast, (node) =>
|
|
248
|
+
["to", "from", "fromTo", "set", "addLabel", "call"].includes(methodName(node)),
|
|
249
|
+
)) {
|
|
250
|
+
if (claimed.has(call) || timelines.includes(call)) continue;
|
|
251
|
+
const owner = dottedName(unwrap(call.callee).object);
|
|
252
|
+
if (!owner || owner === "gsap" || owner.startsWith("gsap.")) continue;
|
|
253
|
+
const own = sceneFor(owner, call);
|
|
254
|
+
if (!own && !handed.has(owner)) handed.set(owner, []);
|
|
255
|
+
|
|
256
|
+
const method = methodName(call);
|
|
257
|
+
const vars = varsOf(call);
|
|
258
|
+
(own ? own.beats : handed.get(owner)).push(
|
|
259
|
+
method === "addLabel"
|
|
260
|
+
? {
|
|
261
|
+
label:
|
|
262
|
+
staticString(call.arguments[0]) ?? textOf(file, call.arguments[0], 24) ?? "(label)",
|
|
263
|
+
}
|
|
264
|
+
: {
|
|
265
|
+
line: lineAt(file.raw, startOf(call)),
|
|
266
|
+
method,
|
|
267
|
+
target: textOf(file, call.arguments[0], 32),
|
|
268
|
+
duration: valueOf(vars, "duration"),
|
|
269
|
+
ease: valueOf(vars, "ease"),
|
|
270
|
+
at: positionOf(file, call),
|
|
271
|
+
why: reasonFor(file, { start: startOf(call) }),
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
map.handed = [...handed.entries()].map(([owner, beats]) => ({ owner, beats }));
|
|
276
|
+
for (const scene of map.scenes) {
|
|
277
|
+
scene.beats.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Tweens that belong to no timeline.
|
|
281
|
+
for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
|
|
282
|
+
if (claimed.has(call) || timelines.includes(call)) continue;
|
|
283
|
+
if (calleeName(call)?.startsWith("gsap.") !== true) continue;
|
|
284
|
+
const vars = varsOf(call);
|
|
285
|
+
map.loose.push({
|
|
286
|
+
line: lineAt(file.raw, call.start),
|
|
287
|
+
method: tweenMethod(call),
|
|
288
|
+
target: textOf(file, call.arguments[0], 32),
|
|
289
|
+
duration: valueOf(vars, "duration"),
|
|
290
|
+
ease: valueOf(vars, "ease"),
|
|
291
|
+
repeat: valueOf(vars, "repeat"),
|
|
292
|
+
why: reasonFor(file, call),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Scroll configuration, wherever it is written.
|
|
297
|
+
for (const call of findAll(file.ast, (node) => node.type === "CallExpression")) {
|
|
298
|
+
const trigger = triggerOf(file, call);
|
|
299
|
+
if (trigger) map.triggers.push(trigger);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// What the audit says about this file, so a claim can cite a rule.
|
|
303
|
+
for (const rule of RULES) {
|
|
304
|
+
for (const finding of check(rule, file)) {
|
|
305
|
+
map.findings.push({ line: finding.line, rule: finding.rule, level: finding.level, message: finding.message });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return map;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Reading the page --------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
const LIVE = `(() => {
|
|
315
|
+
const out = { gsap: typeof gsap !== "undefined", children: [], triggers: [], reduced: null };
|
|
316
|
+
out.reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
317
|
+
if (!out.gsap) return out;
|
|
318
|
+
|
|
319
|
+
out.children = gsap.globalTimeline.getChildren(true, true, true).map((child) => ({
|
|
320
|
+
targets: (child.targets?.() ?? []).map((target) =>
|
|
321
|
+
target?.id ? "#" + target.id : target?.tagName ? target.tagName.toLowerCase() : String(target)
|
|
322
|
+
),
|
|
323
|
+
duration: child.duration?.() ?? 0,
|
|
324
|
+
repeat: child.repeat?.() ?? 0,
|
|
325
|
+
paused: child.paused?.() ?? false,
|
|
326
|
+
ease: typeof child.vars?.ease === "string" ? child.vars.ease : null,
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
const ScrollTriggerClass = globalThis.ScrollTrigger ?? gsap.core?.globals?.().ScrollTrigger;
|
|
330
|
+
if (ScrollTriggerClass?.getAll) {
|
|
331
|
+
out.triggers = ScrollTriggerClass.getAll().map((trigger) => ({
|
|
332
|
+
element: trigger.trigger?.id ? "#" + trigger.trigger.id : trigger.trigger?.tagName?.toLowerCase() ?? null,
|
|
333
|
+
start: trigger.start,
|
|
334
|
+
end: trigger.end,
|
|
335
|
+
scrub: Boolean(trigger.vars?.scrub),
|
|
336
|
+
pin: Boolean(trigger.vars?.pin),
|
|
337
|
+
progress: Number((trigger.progress ?? 0).toFixed(3)),
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
})()`;
|
|
342
|
+
|
|
343
|
+
async function readPage(url, { wait = 600, chrome = null } = {}) {
|
|
344
|
+
const executable = findChrome(chrome);
|
|
345
|
+
if (!executable) {
|
|
346
|
+
throw new Error("No Chrome found. Install Chrome, pass --chrome <path>, or set CHROME_PATH.");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const browser = connect(executable);
|
|
350
|
+
try {
|
|
351
|
+
const { targetId } = await browser.send("Target.createTarget", { url: "about:blank" });
|
|
352
|
+
const { sessionId } = await browser.send("Target.attachToTarget", { targetId, flatten: true });
|
|
353
|
+
const call = (method, params) => browser.send(method, params, sessionId);
|
|
354
|
+
|
|
355
|
+
await call("Page.enable");
|
|
356
|
+
await call("Runtime.enable");
|
|
357
|
+
await call("Page.navigate", { url });
|
|
358
|
+
await new Promise((done) => setTimeout(done, wait));
|
|
359
|
+
|
|
360
|
+
const { result } = await call("Runtime.evaluate", {
|
|
361
|
+
expression: LIVE,
|
|
362
|
+
returnByValue: true,
|
|
363
|
+
awaitPromise: true,
|
|
364
|
+
});
|
|
365
|
+
return { url, ...result.value };
|
|
366
|
+
} finally {
|
|
367
|
+
await browser.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Reporting ---------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
function reportFile(map) {
|
|
374
|
+
const lines = [`${map.file}`];
|
|
375
|
+
const say = (text) => lines.push(` ${text}`);
|
|
376
|
+
|
|
377
|
+
if (map.scopes.length) {
|
|
378
|
+
say("");
|
|
379
|
+
say("Where it lives");
|
|
380
|
+
for (const scope of map.scopes) {
|
|
381
|
+
say(` ${scope.line}: ${scope.kind}${scope.scope ? ` scoped to ${scope.scope}` : ""} (file)`);
|
|
382
|
+
if (scope.why) say(` "${scope.why}" (comment)`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (map.branches.length) {
|
|
387
|
+
say("");
|
|
388
|
+
say("Branches");
|
|
389
|
+
for (const branch of map.branches) {
|
|
390
|
+
say(` ${branch.line}: ${branch.conditions.join(" · ")} (file)`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const scene of map.scenes) {
|
|
395
|
+
say("");
|
|
396
|
+
say(`Scene at line ${scene.line}${scene.name ? ` (${scene.name})` : ""}${scene.repeat === "-1" ? ", repeats forever" : ""} (file)`);
|
|
397
|
+
if (scene.why) say(` "${scene.why}" (comment)`);
|
|
398
|
+
for (const beat of scene.beats) {
|
|
399
|
+
if (beat.label) {
|
|
400
|
+
say(` label "${beat.label}"`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const parts = [
|
|
404
|
+
`${beat.method} ${beat.target ?? ""}`.trim(),
|
|
405
|
+
beat.duration ? `${beat.duration}s` : null,
|
|
406
|
+
beat.ease ? `ease ${beat.ease}` : null,
|
|
407
|
+
beat.at ? `at ${beat.at}` : null,
|
|
408
|
+
].filter(Boolean);
|
|
409
|
+
say(` ${beat.line}: ${parts.join(" · ")}`);
|
|
410
|
+
if (beat.why) say(` "${beat.why}" (comment)`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const scene of map.handed ?? []) {
|
|
415
|
+
say("");
|
|
416
|
+
say(`Beats this file adds to ${scene.owner}, a timeline it was given (file)`);
|
|
417
|
+
for (const beat of scene.beats) {
|
|
418
|
+
if (beat.label) {
|
|
419
|
+
say(` label "${beat.label}"`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const parts = [
|
|
423
|
+
`${beat.method} ${beat.target ?? ""}`.trim(),
|
|
424
|
+
beat.duration ? `${beat.duration}s` : null,
|
|
425
|
+
beat.ease ? `ease ${beat.ease}` : null,
|
|
426
|
+
beat.at ? `at ${beat.at}` : null,
|
|
427
|
+
].filter(Boolean);
|
|
428
|
+
say(` ${beat.line}: ${parts.join(" · ")}`);
|
|
429
|
+
if (beat.why) say(` "${beat.why}" (comment)`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (map.loose.length) {
|
|
434
|
+
say("");
|
|
435
|
+
say("Tweens on no timeline (file)");
|
|
436
|
+
for (const tween of map.loose) {
|
|
437
|
+
const parts = [
|
|
438
|
+
`${tween.method} ${tween.target ?? ""}`.trim(),
|
|
439
|
+
tween.duration ? `${tween.duration}s` : null,
|
|
440
|
+
tween.ease ? `ease ${tween.ease}` : null,
|
|
441
|
+
tween.repeat === "-1" ? "repeats forever" : null,
|
|
442
|
+
].filter(Boolean);
|
|
443
|
+
say(` ${tween.line}: ${parts.join(" · ")}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (map.triggers.length) {
|
|
448
|
+
say("");
|
|
449
|
+
say("Scroll (file)");
|
|
450
|
+
for (const trigger of map.triggers) {
|
|
451
|
+
const parts = [
|
|
452
|
+
trigger.trigger ? `trigger ${trigger.trigger}` : null,
|
|
453
|
+
trigger.start ? `start ${trigger.start}` : null,
|
|
454
|
+
trigger.end ? `end ${trigger.end}` : null,
|
|
455
|
+
trigger.scrub ? `scrub ${trigger.scrub}` : null,
|
|
456
|
+
trigger.pin ? `pin ${trigger.pin}` : null,
|
|
457
|
+
trigger.toggleActions ? `toggleActions ${trigger.toggleActions}` : null,
|
|
458
|
+
].filter(Boolean);
|
|
459
|
+
say(` ${trigger.line}: ${parts.join(" · ") || "(no options read)"}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
say("");
|
|
464
|
+
if (map.findings.length) {
|
|
465
|
+
say("What the audit says");
|
|
466
|
+
for (const finding of map.findings) {
|
|
467
|
+
say(` ${finding.line}: ${finding.message} [${finding.rule}] (audit)`);
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
say("The audit reports nothing on this file. (audit)");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return lines.join("\n");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function reportPage(page) {
|
|
477
|
+
const lines = [`${page.url} (page)`];
|
|
478
|
+
const say = (text) => lines.push(` ${text}`);
|
|
479
|
+
|
|
480
|
+
say("");
|
|
481
|
+
say(`Reduced motion: ${page.reduced ? "the visitor asked for less" : "no preference"}`);
|
|
482
|
+
|
|
483
|
+
say("");
|
|
484
|
+
if (!page.gsap) {
|
|
485
|
+
say("No GSAP on the page. Either it never loaded, or nothing here animates with it.");
|
|
486
|
+
return lines.join("\n");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
say(`Running now: ${page.children.length} tween(s) or timeline(s) on the global timeline`);
|
|
490
|
+
for (const child of page.children) {
|
|
491
|
+
const parts = [
|
|
492
|
+
child.targets.join(", ") || "(no target)",
|
|
493
|
+
`${Number(child.duration.toFixed(3))}s`,
|
|
494
|
+
child.repeat === -1 ? "repeats forever" : child.repeat ? `repeats ${child.repeat}` : null,
|
|
495
|
+
child.paused ? "paused" : null,
|
|
496
|
+
child.ease ? `ease ${child.ease}` : null,
|
|
497
|
+
].filter(Boolean);
|
|
498
|
+
say(` ${parts.join(" · ")}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
say("");
|
|
502
|
+
say(`ScrollTriggers: ${page.triggers.length}`);
|
|
503
|
+
for (const trigger of page.triggers) {
|
|
504
|
+
const parts = [
|
|
505
|
+
trigger.element ?? "(no element)",
|
|
506
|
+
`${Math.round(trigger.start)}→${Math.round(trigger.end)}`,
|
|
507
|
+
trigger.scrub ? "scrub" : null,
|
|
508
|
+
trigger.pin ? "pin" : null,
|
|
509
|
+
`progress ${trigger.progress}`,
|
|
510
|
+
].filter(Boolean);
|
|
511
|
+
say(` ${parts.join(" · ")}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
say("");
|
|
515
|
+
say("A finished tween leaves the global timeline, so what is here is what is running now.");
|
|
516
|
+
return lines.join("\n");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// --- Entry -------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
export function parseArguments(argv) {
|
|
522
|
+
const options = { targets: [], json: false, wait: 600, chrome: null, errors: [] };
|
|
523
|
+
|
|
524
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
525
|
+
const argument = argv[index];
|
|
526
|
+
if (argument === "--json") options.json = true;
|
|
527
|
+
else if (argument === "--wait") options.wait = Number(argv[(index += 1)]) || options.wait;
|
|
528
|
+
else if (argument === "--chrome") options.chrome = argv[(index += 1)] ?? null;
|
|
529
|
+
else if (argument.startsWith("--")) options.errors.push(`Unknown option: ${argument}`);
|
|
530
|
+
else options.targets.push(argument);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (options.targets.length === 0) {
|
|
534
|
+
options.errors.push("Nothing to explain. Pass a file, a directory, or a URL.");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
options.live = options.targets.some((target) => /^https?:|^file:/.test(target));
|
|
538
|
+
if (options.live && options.targets.length > 1) {
|
|
539
|
+
options.errors.push("Explain one URL at a time.");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return options;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function main(argv) {
|
|
546
|
+
const options = parseArguments(argv);
|
|
547
|
+
if (options.errors.length) {
|
|
548
|
+
for (const error of options.errors) console.error(error);
|
|
549
|
+
console.error("\nUsage: node explain-motion.mjs <file...|url> [--json]");
|
|
550
|
+
process.exit(2);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (options.live) {
|
|
554
|
+
const page = await readPage(options.targets[0], options);
|
|
555
|
+
console.log(options.json ? JSON.stringify(page, null, 2) : reportPage(page));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const root = process.cwd();
|
|
560
|
+
const maps = [];
|
|
561
|
+
|
|
562
|
+
for (const target of options.targets) {
|
|
563
|
+
const path = resolve(root, target);
|
|
564
|
+
if (!existsSync(path)) {
|
|
565
|
+
console.error(`No such path: ${path}`);
|
|
566
|
+
process.exit(2);
|
|
567
|
+
}
|
|
568
|
+
for (const found of collect(path)) {
|
|
569
|
+
const file = load(found, root);
|
|
570
|
+
if (!mentionsGsap(file)) continue;
|
|
571
|
+
if (file.parseError) {
|
|
572
|
+
maps.push({ file: file.display, parseError: file.parseError.message });
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
maps.push(readFile(file));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (maps.length === 0) {
|
|
580
|
+
console.log("Nothing here animates with GSAP.");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
console.log(
|
|
585
|
+
options.json
|
|
586
|
+
? JSON.stringify(maps, null, 2)
|
|
587
|
+
: maps
|
|
588
|
+
.map((map) =>
|
|
589
|
+
map.parseError ? `${map.file}\n could not be parsed: ${map.parseError}` : reportFile(map),
|
|
590
|
+
)
|
|
591
|
+
.join("\n\n"),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
596
|
+
if (invoked) await main(process.argv.slice(2));
|