@mehshekari/gsap-motion 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/gsap-motion.mjs +20 -2
- package/package.json +1 -1
- package/skill/SKILL.md +8 -8
- package/skill/adapter/next.md +1 -2
- package/skill/adapter/r3f.md +4 -1
- package/skill/adapter/react.md +8 -5
- package/skill/adapter/three.md +2 -0
- package/skill/example/magnetic-button.md +101 -101
- package/skill/preset/cursor.md +184 -184
- package/skill/preset/magnetic.md +155 -155
- package/skill/reference/devices.md +10 -0
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +1 -0
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +63 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +27 -3
- package/skill/scripts/capture-motion.mjs +248 -44
- package/skill/scripts/lib/ast.mjs +82 -12
- package/skill/scripts/lib/rules.mjs +311 -7
- package/skill/scripts/lib/source.mjs +9 -1
- package/skill/scripts/review-motion.mjs +224 -25
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -54,6 +54,15 @@ two is not a coin toss.
|
|
|
54
54
|
| "it feels cheap" | linear easing, or one uniform duration | `tune` |
|
|
55
55
|
| "it feels slow" | usually too sequential, not too long: try overlap | `tune` |
|
|
56
56
|
|
|
57
|
+
### "Match this site, one to one"
|
|
58
|
+
|
|
59
|
+
The reference is the precedent, and its motion is the spec. Measure its curves
|
|
60
|
+
and durations from it — `inspect` on its URL with a dense `--at`, or a
|
|
61
|
+
recording frame by frame — and copy them exactly. "Motion carries meaning or it
|
|
62
|
+
goes" judged the reference when it was designed; applied to the copy, it
|
|
63
|
+
simplifies what was asked for verbatim. Personalising is a second pass, after
|
|
64
|
+
the copy matches.
|
|
65
|
+
|
|
57
66
|
### "Something crazy"
|
|
58
67
|
|
|
59
68
|
A fade-and-scale is a failure of nerve; twelve techniques at once is a failure
|
|
@@ -87,7 +96,8 @@ single hover takes [interaction](interaction.md) and nothing else from its row.
|
|
|
87
96
|
| Request | Add |
|
|
88
97
|
|---|---|
|
|
89
98
|
| SVG draw / morph / path | [svg](svg.md), [timeline](timeline.md) |
|
|
90
|
-
| Scroll sequence, pin, scrub | [scrolltrigger](scrolltrigger.md), [timeline](timeline.md), [performance](performance.md) |
|
|
99
|
+
| Scroll sequence, pin, scrub | [scrolltrigger](scrolltrigger.md), [timeline](timeline.md), [performance](performance.md), [scroll-storytelling](../preset/scroll-storytelling.md) |
|
|
100
|
+
| Smooth scroll, Lenis | [scrolltrigger](scrolltrigger.md#lenis), [devices](devices.md) |
|
|
91
101
|
| Horizontal scroll | [scrolltrigger](scrolltrigger.md), [scroll-storytelling](../preset/scroll-storytelling.md) |
|
|
92
102
|
| Text reveal, kinetic type | [text](text.md), [timeline](timeline.md) |
|
|
93
103
|
| Magnetic, cursor, hover | [interaction](interaction.md), [performance](performance.md) |
|
|
@@ -97,6 +97,39 @@ Things that bite:
|
|
|
97
97
|
- `position: sticky` is cheaper and needs no plugin. Use it when you only need
|
|
98
98
|
the element to stay put, not to drive a timeline.
|
|
99
99
|
|
|
100
|
+
### `pinType`
|
|
101
|
+
|
|
102
|
+
How the pin holds the element. On the window it defaults to `"fixed"`: at each
|
|
103
|
+
enter and leave, ScrollTrigger swaps the element's whole state — `position`,
|
|
104
|
+
`top`, `left`, `width`, `height`, margins, padding — and the browser lays it out
|
|
105
|
+
again in that frame. `"transform"` swaps nothing, and writes a translate every
|
|
106
|
+
frame while the pin is active (verified against gsap 3.15).
|
|
107
|
+
|
|
108
|
+
`"fixed"` is the default because native scrolling runs off the main thread, and
|
|
109
|
+
a translate written once per frame would trail it. Under Lenis that reason is
|
|
110
|
+
gone for the wheel — the scroll moves in the same frame as the pin — and the
|
|
111
|
+
state swap at every section edge is what remains. On one landing page it was
|
|
112
|
+
measured at a CLS of 4 to 7.5 and frames of 50 to 117ms per edge, and
|
|
113
|
+
`"transform"` removed both. Touch is still native under Lenis, so keep
|
|
114
|
+
`"fixed"` there:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// Inside the component's context, so it reverts with it. Both conditions are
|
|
118
|
+
// named: a matchMedia callback runs only while one of them matches.
|
|
119
|
+
gsap.matchMedia().add({ smoothed: "(pointer: fine)", touch: "(pointer: coarse)" }, (context) => {
|
|
120
|
+
const { smoothed } = context.conditions;
|
|
121
|
+
ScrollTrigger.create({ trigger: section, pin: true, pinType: smoothed ? "transform" : "fixed" });
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A trackpad sends wheel events, so a laptop is smoothed too. A laptop with a
|
|
126
|
+
touch screen still reads `(pointer: fine)`, and scrolled by finger its pin
|
|
127
|
+
trails the native scroll. If that audience matters, branch on
|
|
128
|
+
`(any-pointer: coarse)` instead: every device with touch gets `"fixed"`, and
|
|
129
|
+
with it the cost at each edge.
|
|
130
|
+
|
|
131
|
+
Without smooth scrolling, leave the default.
|
|
132
|
+
|
|
100
133
|
## Horizontal scroll
|
|
101
134
|
|
|
102
135
|
A vertical scroll driving a horizontal translation. `xPercent` avoids measuring:
|
|
@@ -162,6 +195,49 @@ interferes with `position: fixed`, anchor links, and native scroll on some
|
|
|
162
195
|
touch devices. Do not add it to make a reveal smoother — fix the reveal. Add it
|
|
163
196
|
only when the whole page is designed around smoothed scrolling.
|
|
164
197
|
|
|
198
|
+
## Lenis
|
|
199
|
+
|
|
200
|
+
Lenis smooths wheel input and writes the result to the real scroll position, so
|
|
201
|
+
ScrollTrigger needs no `scrollerProxy`. Pins need one decision, in
|
|
202
|
+
[`pinType`](#pintype). What it needs above all is **one clock**, shared with
|
|
203
|
+
GSAP:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const lenis = new Lenis(); // autoRaf stays false, its default
|
|
207
|
+
lenis.on("scroll", ScrollTrigger.update); // triggers read this frame's scroll
|
|
208
|
+
const raf = (time: number) => lenis.raf(time * 1000); // the ticker's time is seconds
|
|
209
|
+
gsap.ticker.add(raf, false, true); // prioritized: before GSAP renders
|
|
210
|
+
gsap.ticker.lagSmoothing(0);
|
|
211
|
+
|
|
212
|
+
// Teardown. A context owns neither of these.
|
|
213
|
+
gsap.ticker.remove(raf);
|
|
214
|
+
lenis.destroy();
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **Prioritized, because GSAP renders in its own first ticker listener.** Added
|
|
218
|
+
after it, Lenis moves the scroll once GSAP has drawn the frame: a numeric
|
|
219
|
+
`scrub` — a tween — and anything tweened from the scroll position render one
|
|
220
|
+
frame late. `scrub: true` sets progress directly and is not affected.
|
|
221
|
+
- **Never `autoRaf: true` as well.** Lenis steps by the difference between
|
|
222
|
+
consecutive `raf` calls, and the two loops pass clocks with different origins,
|
|
223
|
+
so the step swings and the scroll stutters.
|
|
224
|
+
- **`lagSmoothing(0)`**: after a frame longer than 500ms, GSAP otherwise
|
|
225
|
+
advances its clock by 33ms, and Lenis on that clock crawls back to the
|
|
226
|
+
scroll. It applies to every tween on the page, which then jump after a long
|
|
227
|
+
frame instead of slowing.
|
|
228
|
+
- **Touch is native by default.** With `syncTouch` off, Lenis smooths nothing on
|
|
229
|
+
a touch screen and only runs its loop. Leave it off — it takes over the
|
|
230
|
+
platform's momentum, and is unstable on older iOS — and consider not creating
|
|
231
|
+
Lenis at all without a fine pointer; see [devices.md](devices.md).
|
|
232
|
+
- **Reduced motion is handled.** By default Lenis tracks the input 1:1 and makes
|
|
233
|
+
programmatic scrolls instant, and keeps running. Do not destroy it for reduced
|
|
234
|
+
motion: the ScrollTrigger sync above depends on it.
|
|
235
|
+
- In-page links need `anchors: true`; a nested scroll container needs
|
|
236
|
+
`data-lenis-prevent`. In React, `ReactLenis` from `lenis/react` takes the same
|
|
237
|
+
options: `autoRaf: false`, and the ticker wiring in an effect.
|
|
238
|
+
|
|
239
|
+
Verified against `lenis@1.3` and `gsap@3.15`.
|
|
240
|
+
|
|
165
241
|
## React
|
|
166
242
|
|
|
167
243
|
The lifecycle is the hard part. `useGSAP` reverts its context on unmount, which
|
|
@@ -189,3 +265,6 @@ images settle. `ScrollTrigger.refresh()` on mount is usually needed. See
|
|
|
189
265
|
| Janky while scrolling | layout-triggering properties — see [performance](performance.md) |
|
|
190
266
|
| Works, then breaks after navigation | ScrollTriggers not killed on unmount |
|
|
191
267
|
| Wrong on mobile after address-bar hide | viewport resize — `invalidateOnRefresh` |
|
|
268
|
+
| Smooth scroll stutters | Lenis on two loops — `autoRaf` and the ticker |
|
|
269
|
+
| A numeric scrub trails the scroll by a frame | Lenis's `raf` added to the ticker without `prioritize` |
|
|
270
|
+
| A layout shift and a long frame at each pinned section's edge | a `"fixed"` pin under Lenis — `pinType: "transform"` for the wheel |
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
*
|
|
33
33
|
* Exit code is 1 when any error-level finding is reported, 0 otherwise.
|
|
34
34
|
*/
|
|
35
|
-
import { existsSync } from "node:fs";
|
|
35
|
+
import { existsSync, statSync } from "node:fs";
|
|
36
36
|
import { resolve } from "node:path";
|
|
37
37
|
|
|
38
38
|
import { check, mentionsGsap, notParsed } from "./lib/audit.mjs";
|
|
39
39
|
import { pluginRegistrations, RULES } from "./lib/rules.mjs";
|
|
40
|
-
import { collect, load } from "./lib/source.mjs";
|
|
40
|
+
import { collect, hasSourceExtension, load } from "./lib/source.mjs";
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* The project being audited is wherever this is run from, not wherever the
|
|
@@ -66,6 +66,12 @@ for (const path of paths) {
|
|
|
66
66
|
|
|
67
67
|
const findings = [];
|
|
68
68
|
const sources = [];
|
|
69
|
+
/**
|
|
70
|
+
* A file named on the command line that the audit cannot read — a stylesheet,
|
|
71
|
+
* say. It used to be skipped in silence and the run printed "clean", which
|
|
72
|
+
* reads as "checked and fine" about a file nothing looked at.
|
|
73
|
+
*/
|
|
74
|
+
const notScanned = [];
|
|
69
75
|
|
|
70
76
|
/** A finding as the report shows it: under the file's display path, no column. */
|
|
71
77
|
const reported = (source, finding) => ({
|
|
@@ -78,6 +84,10 @@ const reported = (source, finding) => ({
|
|
|
78
84
|
});
|
|
79
85
|
|
|
80
86
|
for (const path of paths) {
|
|
87
|
+
if (statSync(path).isFile() && !hasSourceExtension(path)) {
|
|
88
|
+
notScanned.push(path);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
81
91
|
for (const file of collect(path)) {
|
|
82
92
|
const source = load(file, ROOT);
|
|
83
93
|
if (!mentionsGsap(source)) continue;
|
|
@@ -124,8 +134,14 @@ findings.sort(
|
|
|
124
134
|
|
|
125
135
|
const shown = quiet ? findings.filter((f) => f.level === "error") : findings;
|
|
126
136
|
|
|
137
|
+
const READS = "the audit reads .ts, .tsx, .js, .jsx and .mjs, and the scripts of .vue, .svelte and .astro files";
|
|
138
|
+
if (notScanned.length === paths.length) {
|
|
139
|
+
for (const path of notScanned) console.error(`Not scanned: ${path} — ${READS}.`);
|
|
140
|
+
process.exit(2);
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
if (asJson) {
|
|
128
|
-
console.log(JSON.stringify({ findings: shown }, null, 2));
|
|
144
|
+
console.log(JSON.stringify({ findings: shown, notScanned }, null, 2));
|
|
129
145
|
} else if (!shown.length) {
|
|
130
146
|
console.log(
|
|
131
147
|
findings.length
|
|
@@ -151,6 +167,14 @@ if (asJson) {
|
|
|
151
167
|
.join(", ");
|
|
152
168
|
|
|
153
169
|
console.log(`\n${tally}`);
|
|
170
|
+
/** Said here because a report that never mentions it reads as "there is no way". */
|
|
171
|
+
console.log(
|
|
172
|
+
"A deliberate finding is waived with `<rule-id>-ok` and a reason, on its line or in the eight above.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!asJson) {
|
|
177
|
+
for (const path of notScanned) console.log(`Not scanned: ${path} — ${READS}.`);
|
|
154
178
|
}
|
|
155
179
|
|
|
156
180
|
process.exit(findings.some((f) => f.level === "error") ? 1 : 0);
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
* Usage, from the project root:
|
|
18
18
|
* node <skill-dir>/scripts/capture-motion.mjs <url> [options]
|
|
19
19
|
*
|
|
20
|
-
* --at 0,300,900 when to capture, in ms after
|
|
21
|
-
* --wait 400
|
|
20
|
+
* --at 0,300,900 when to capture, in ms after --wait
|
|
21
|
+
* --wait 400 a fixed wait after navigation, before any step or
|
|
22
|
+
* capture (default 400). It does not detect a loader.
|
|
23
|
+
* --viewport 1440x900 the window's size; the default is whatever headless
|
|
24
|
+
* Chrome opens at, and every report prints it
|
|
22
25
|
* --out <dir> where frames go (default .gsap-motion/inspect)
|
|
23
26
|
* --scroll <px|sel> scroll before capturing: a number, or to a selector
|
|
24
27
|
* --hover <selector> move the pointer onto an element, at its live box
|
|
@@ -29,6 +32,12 @@
|
|
|
29
32
|
* --cpu 4 throttle the CPU by this factor
|
|
30
33
|
* --json machine-readable, for scripts
|
|
31
34
|
* --chrome <path> a browser to use, overriding the search
|
|
35
|
+
* --seek <name> call window.<name>(t) before each capture, and read
|
|
36
|
+
* --at as seconds of the timeline instead of the clock
|
|
37
|
+
* --headed a visible Chrome, on the machine's GPU: what a WebGL
|
|
38
|
+
* page needs for its frames and rate to mean anything
|
|
39
|
+
* --probe timeline print the timeline reader as a function, for a
|
|
40
|
+
* browser you already drive, and open none
|
|
32
41
|
*
|
|
33
42
|
* `--scroll`, `--hover` and `--click` run in the order they are written, before
|
|
34
43
|
* the first capture. An animation that needs a pointer or a scroll position is
|
|
@@ -40,7 +49,7 @@
|
|
|
40
49
|
import { spawn } from "node:child_process";
|
|
41
50
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
42
51
|
import { homedir, tmpdir } from "node:os";
|
|
43
|
-
import { join, resolve } from "node:path";
|
|
52
|
+
import { join, resolve, sep } from "node:path";
|
|
44
53
|
import { fileURLToPath } from "node:url";
|
|
45
54
|
|
|
46
55
|
// --- The browser -------------------------------------------------------------
|
|
@@ -85,11 +94,20 @@ export function connect(executable, { headless = true } = {}) {
|
|
|
85
94
|
const chrome = spawn(
|
|
86
95
|
executable,
|
|
87
96
|
[
|
|
88
|
-
|
|
97
|
+
/** Headless runs without a GPU, for a steady result; headed gets the machine's own. */
|
|
98
|
+
...(headless ? ["--headless=new", "--disable-gpu"] : []),
|
|
89
99
|
"--remote-debugging-pipe",
|
|
100
|
+
/**
|
|
101
|
+
* A window nobody can see is a page Chrome stops animating: headed and
|
|
102
|
+
* moved off-screen, as a window behind an editor is, rAF went from 122 a
|
|
103
|
+
* second to none, and the run hung. With these it ran at 166. They are
|
|
104
|
+
* Puppeteer's defaults too.
|
|
105
|
+
*/
|
|
106
|
+
"--disable-backgrounding-occluded-windows",
|
|
107
|
+
"--disable-renderer-backgrounding",
|
|
108
|
+
"--disable-background-timer-throttling",
|
|
90
109
|
"--no-first-run",
|
|
91
110
|
"--no-default-browser-check",
|
|
92
|
-
"--disable-gpu",
|
|
93
111
|
"--disable-extensions",
|
|
94
112
|
`--user-data-dir=${profile}`,
|
|
95
113
|
"about:blank",
|
|
@@ -173,9 +191,14 @@ export function parseOptions(argv) {
|
|
|
173
191
|
reduced: false,
|
|
174
192
|
dark: false,
|
|
175
193
|
mobile: false,
|
|
194
|
+
viewport: null,
|
|
176
195
|
cpu: 1,
|
|
177
196
|
json: false,
|
|
178
197
|
chrome: null,
|
|
198
|
+
headed: false,
|
|
199
|
+
seek: null,
|
|
200
|
+
atGiven: false,
|
|
201
|
+
probe: null,
|
|
179
202
|
errors: [],
|
|
180
203
|
};
|
|
181
204
|
|
|
@@ -192,6 +215,7 @@ export function parseOptions(argv) {
|
|
|
192
215
|
options.errors.push("--at takes milliseconds, such as --at 0,300,900");
|
|
193
216
|
} else {
|
|
194
217
|
options.at = times.sort((a, b) => a - b);
|
|
218
|
+
options.atGiven = true;
|
|
195
219
|
}
|
|
196
220
|
break;
|
|
197
221
|
}
|
|
@@ -222,6 +246,12 @@ export function parseOptions(argv) {
|
|
|
222
246
|
case "--mobile":
|
|
223
247
|
options.mobile = true;
|
|
224
248
|
break;
|
|
249
|
+
case "--viewport": {
|
|
250
|
+
const size = parseViewport(value());
|
|
251
|
+
if (size) options.viewport = size;
|
|
252
|
+
else options.errors.push("--viewport takes a width and height, such as --viewport 1440x900");
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
225
255
|
case "--cpu": {
|
|
226
256
|
const rate = asNumber(value());
|
|
227
257
|
if (rate === null || rate < 1) options.errors.push("--cpu takes a factor of 1 or more");
|
|
@@ -234,6 +264,20 @@ export function parseOptions(argv) {
|
|
|
234
264
|
case "--chrome":
|
|
235
265
|
options.chrome = value() ?? null;
|
|
236
266
|
break;
|
|
267
|
+
case "--headed":
|
|
268
|
+
options.headed = true;
|
|
269
|
+
break;
|
|
270
|
+
case "--seek": {
|
|
271
|
+
/** A name the page exports, never an expression: it is spliced into the page's code. */
|
|
272
|
+
const name = value() ?? "";
|
|
273
|
+
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(name)) options.seek = name;
|
|
274
|
+
else options.errors.push("--seek takes the name of a function on window, such as --seek __seek");
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "--probe":
|
|
278
|
+
if (value() === "timeline") options.probe = "timeline";
|
|
279
|
+
else options.errors.push("--probe takes timeline");
|
|
280
|
+
break;
|
|
237
281
|
default:
|
|
238
282
|
if (argument.startsWith("--")) options.errors.push(`Unknown option: ${argument}`);
|
|
239
283
|
else if (options.url === null) options.url = argument;
|
|
@@ -241,7 +285,19 @@ export function parseOptions(argv) {
|
|
|
241
285
|
}
|
|
242
286
|
}
|
|
243
287
|
|
|
244
|
-
|
|
288
|
+
/** A probe is printed, not run, so it needs no page. */
|
|
289
|
+
if (options.url === null && options.probe === null) {
|
|
290
|
+
options.errors.push("No URL. Pass the page to watch.");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Seeking makes `--at` timeline seconds, so the millisecond default would mean minutes. */
|
|
294
|
+
if (options.seek && !options.atGiven) {
|
|
295
|
+
options.errors.push("--seek needs --at, in seconds of the timeline, such as --at 3,3.3,3.6");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (options.mobile && options.viewport) {
|
|
299
|
+
options.errors.push("--mobile sets its own viewport; drop one of --mobile and --viewport.");
|
|
300
|
+
}
|
|
245
301
|
|
|
246
302
|
/** A coarse pointer has no hover; emulating both would measure something no device is. */
|
|
247
303
|
if (options.mobile && options.steps.some((step) => step.type === "hover")) {
|
|
@@ -325,14 +381,145 @@ const COUNT_FRAMES = `(() => {
|
|
|
325
381
|
return true;
|
|
326
382
|
})()`;
|
|
327
383
|
|
|
328
|
-
/**
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
384
|
+
/**
|
|
385
|
+
* The timeline reader as a function, for a browser something else already
|
|
386
|
+
* holds — a DevTools MCP server's `evaluate_script`. Layouts and style
|
|
387
|
+
* recalculations come from the DevTools protocol, which a page cannot reach, so
|
|
388
|
+
* they are not part of it.
|
|
389
|
+
*/
|
|
390
|
+
export const probe = () => `() => ${READ_TIMELINE}`;
|
|
391
|
+
|
|
392
|
+
/** Two rendered frames: a seek has been applied and painted once this resolves. */
|
|
393
|
+
const SETTLED = "new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => done(true))))";
|
|
394
|
+
|
|
395
|
+
/** A frame's file name: milliseconds of the clock, or seconds of the timeline when seeking. */
|
|
396
|
+
export const frameName = (at, options) =>
|
|
397
|
+
options.seek ? `frame-${at.toFixed(3).padStart(8, "0")}s.png` : `frame-${String(at).padStart(5, "0")}ms.png`;
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The first match a pointer can reach, read live so it lands on an element that
|
|
401
|
+
* is moving. Three real pages defeated "the first match": a header holding two
|
|
402
|
+
* copies of a button with the first in a collapsed container, five links of
|
|
403
|
+
* which only the last was on screen, and a match scrolled out of the viewport,
|
|
404
|
+
* where the pointer landed at a negative y and the run still said it clicked.
|
|
405
|
+
* So every match is tried in order, one with no box or hidden is passed over,
|
|
406
|
+
* and one outside the viewport is scrolled into it first. An invalid selector
|
|
407
|
+
* is said to be one, rather than matching nothing.
|
|
408
|
+
*/
|
|
409
|
+
export const pointAt = (selector) => `(() => {
|
|
410
|
+
let all;
|
|
411
|
+
try {
|
|
412
|
+
all = [...document.querySelectorAll(${JSON.stringify(selector)})];
|
|
413
|
+
} catch {
|
|
414
|
+
return { invalid: true };
|
|
415
|
+
}
|
|
416
|
+
const shown = (el) => {
|
|
417
|
+
const box = el.getBoundingClientRect();
|
|
418
|
+
return box.width > 0 && box.height > 0 && getComputedStyle(el).visibility !== "hidden";
|
|
419
|
+
};
|
|
420
|
+
const index = all.findIndex(shown);
|
|
421
|
+
if (index === -1) return { matches: all.length, index };
|
|
422
|
+
const el = all[index];
|
|
423
|
+
let box = el.getBoundingClientRect();
|
|
424
|
+
const inView = box.bottom > 0 && box.right > 0 && box.top < innerHeight && box.left < innerWidth;
|
|
425
|
+
if (!inView) {
|
|
426
|
+
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
427
|
+
box = el.getBoundingClientRect();
|
|
428
|
+
}
|
|
429
|
+
const clamp = (v, max) => Math.min(max - 1, Math.max(0, v));
|
|
430
|
+
return {
|
|
431
|
+
matches: all.length,
|
|
432
|
+
index,
|
|
433
|
+
scrolled: !inView,
|
|
434
|
+
x: clamp(box.x + box.width / 2, innerWidth),
|
|
435
|
+
y: clamp(box.y + box.height / 2, innerHeight),
|
|
436
|
+
width: box.width,
|
|
437
|
+
height: box.height,
|
|
438
|
+
};
|
|
334
439
|
})()`;
|
|
335
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Scrolls instantly, then reports where the page is once two frames have drawn.
|
|
443
|
+
* A page with `scroll-behavior: smooth` used to animate the jump, so the
|
|
444
|
+
* position was read as 0 while it travelled, and frames were captured mid-way.
|
|
445
|
+
*/
|
|
446
|
+
export function scrollExpression(value) {
|
|
447
|
+
const pixels = /^\d+$/.test(String(value)) ? Number(value) : null;
|
|
448
|
+
const move =
|
|
449
|
+
pixels === null
|
|
450
|
+
? `let el;
|
|
451
|
+
try { el = document.querySelector(${JSON.stringify(value)}); } catch { done({ invalid: true }); return; }
|
|
452
|
+
if (!el) { done({ matches: 0 }); return; }
|
|
453
|
+
el.scrollIntoView({ block: "center", behavior: "instant" });`
|
|
454
|
+
: `window.scrollTo({ top: ${pixels}, behavior: "instant" });`;
|
|
455
|
+
return `new Promise((done) => {
|
|
456
|
+
${move}
|
|
457
|
+
requestAnimationFrame(() => requestAnimationFrame(() => done({ y: Math.round(window.scrollY) })));
|
|
458
|
+
})`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** What a step that did nothing says, or null when it did something. */
|
|
462
|
+
export function undone(step, result) {
|
|
463
|
+
if (result?.invalid) return `${step.type}: ${step.value} is not a valid selector`;
|
|
464
|
+
if (!result || result.matches === 0) return `${step.type}: nothing matched ${step.value}`;
|
|
465
|
+
if (result.index === -1) {
|
|
466
|
+
const n = result.matches;
|
|
467
|
+
return `${step.type}: ${step.value} matched ${n} element${n === 1 ? "" : "s"}, none with a box on screen`;
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The exit code a run earns: 2 for an invalid selector, 3 when a step asked for
|
|
474
|
+
* did nothing, 0 otherwise. A step that did nothing used to leave a note in the
|
|
475
|
+
* middle of the report and exit 0, which a script — or a reader with `| tail` —
|
|
476
|
+
* took as done.
|
|
477
|
+
*/
|
|
478
|
+
export const exitFor = (notDone) =>
|
|
479
|
+
notDone.some((line) => line.endsWith("is not a valid selector")) ? 2 : notDone.length ? 3 : 0;
|
|
480
|
+
|
|
481
|
+
/** `--viewport 1440x900`, as numbers, or null. */
|
|
482
|
+
export function parseViewport(text) {
|
|
483
|
+
const match = /^(\d{2,5})x(\d{2,5})$/.exec(String(text ?? ""));
|
|
484
|
+
return match ? { width: Number(match[1]), height: Number(match[2]) } : null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** What the page measured itself at, for every report: a default nobody chose is still a size. */
|
|
488
|
+
export const VIEWPORT = "({ width: innerWidth, height: innerHeight })";
|
|
489
|
+
|
|
490
|
+
// --- Where output goes -------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* What a report says when the page has a canvas and Chrome ran without its GPU:
|
|
494
|
+
* a WebGL scene was then drawn in software, and its frames, rate and long frames
|
|
495
|
+
* describe that, not the visitor's machine. Null when there is nothing to say.
|
|
496
|
+
*/
|
|
497
|
+
export function gpuNote(canvases, options) {
|
|
498
|
+
if (!canvases || options.headed) return null;
|
|
499
|
+
return (
|
|
500
|
+
`${canvases} canvas${canvases === 1 ? "" : "es"} on the page, and Chrome ran without its GPU:` +
|
|
501
|
+
" a WebGL scene's frames, rate and long frames are software rendering's. Measure it with --headed."
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Creates `out` and returns its absolute path. When it is inside the project's
|
|
507
|
+
* `.gsap-motion/` — the default is — that folder gets a `.gitignore` of `*`,
|
|
508
|
+
* so the frames are never committed: a real project committed four of them in
|
|
509
|
+
* its first commit, and a knowledge-graph tool indexed them after that. The
|
|
510
|
+
* project's own `.gitignore` is left alone, and so is any other `--out`.
|
|
511
|
+
*/
|
|
512
|
+
export function prepareOut(out, cwd = process.cwd()) {
|
|
513
|
+
const path = resolve(cwd, out);
|
|
514
|
+
mkdirSync(path, { recursive: true });
|
|
515
|
+
const own = resolve(cwd, ".gsap-motion");
|
|
516
|
+
if (path === own || path.startsWith(own + sep)) {
|
|
517
|
+
const ignore = join(own, ".gitignore");
|
|
518
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "# Written by gsap-motion: frames are not source.\n*\n");
|
|
519
|
+
}
|
|
520
|
+
return path;
|
|
521
|
+
}
|
|
522
|
+
|
|
336
523
|
// --- The run -----------------------------------------------------------------
|
|
337
524
|
|
|
338
525
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
@@ -356,7 +543,7 @@ async function capture(options) {
|
|
|
356
543
|
);
|
|
357
544
|
}
|
|
358
545
|
|
|
359
|
-
const browser = connect(executable);
|
|
546
|
+
const browser = connect(executable, { headless: !options.headed });
|
|
360
547
|
const notes = [];
|
|
361
548
|
|
|
362
549
|
try {
|
|
@@ -393,6 +580,9 @@ async function capture(options) {
|
|
|
393
580
|
});
|
|
394
581
|
await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
|
|
395
582
|
}
|
|
583
|
+
if (options.viewport) {
|
|
584
|
+
await call("Emulation.setDeviceMetricsOverride", { ...options.viewport, deviceScaleFactor: 1, mobile: false });
|
|
585
|
+
}
|
|
396
586
|
|
|
397
587
|
if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
|
|
398
588
|
|
|
@@ -402,36 +592,20 @@ async function capture(options) {
|
|
|
402
592
|
await call("Page.navigate", { url: options.url });
|
|
403
593
|
await wait(options.wait);
|
|
404
594
|
|
|
595
|
+
const notDone = [];
|
|
405
596
|
for (const step of options.steps) {
|
|
406
597
|
if (step.type === "scroll") {
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
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
|
-
);
|
|
598
|
+
const moved = await evaluate(scrollExpression(step.value));
|
|
599
|
+
const missed = undone(step, moved);
|
|
600
|
+
if (missed) notDone.push(missed);
|
|
601
|
+
else notes.push(`scroll: to ${moved.y}px`);
|
|
423
602
|
}
|
|
424
603
|
|
|
425
604
|
if (step.type === "hover" || step.type === "click") {
|
|
426
|
-
const box = await evaluate(
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
);
|
|
605
|
+
const box = await evaluate(pointAt(step.value));
|
|
606
|
+
const missed = undone(step, box) ?? (pointable(box) ? null : undone(step, { ...box, index: -1 }));
|
|
607
|
+
if (missed) {
|
|
608
|
+
notDone.push(missed);
|
|
435
609
|
continue;
|
|
436
610
|
}
|
|
437
611
|
/**
|
|
@@ -460,25 +634,36 @@ async function capture(options) {
|
|
|
460
634
|
});
|
|
461
635
|
}
|
|
462
636
|
}
|
|
637
|
+
const which = box.matches > 1 ? ` (match ${box.index + 1} of ${box.matches})` : "";
|
|
463
638
|
notes.push(
|
|
464
|
-
`${step.type}: ${step.value} at ${Math.round(box.x)},${Math.round(box.y)}
|
|
639
|
+
`${step.type}: ${step.value}${which} at ${Math.round(box.x)},${Math.round(box.y)}` +
|
|
640
|
+
(box.scrolled ? ", scrolled into view first" : ""),
|
|
465
641
|
);
|
|
466
642
|
await wait(120);
|
|
467
643
|
}
|
|
468
644
|
}
|
|
469
645
|
|
|
470
|
-
|
|
646
|
+
const out = prepareOut(options.out);
|
|
471
647
|
|
|
472
648
|
const frames = [];
|
|
473
649
|
let elapsed = 0;
|
|
650
|
+
if (options.seek && !(await evaluate(`typeof window.${options.seek} === "function"`))) {
|
|
651
|
+
throw new Error(
|
|
652
|
+
`--seek: window.${options.seek} is not a function on this page. Export it in development; see refine.md.`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
474
655
|
for (const at of options.at) {
|
|
475
|
-
if (
|
|
656
|
+
if (options.seek) {
|
|
657
|
+
/** Seek, then let two frames render, so what is captured is that time drawn. */
|
|
658
|
+
await evaluate(`window.${options.seek}(${at})`);
|
|
659
|
+
await evaluate(SETTLED);
|
|
660
|
+
} else if (at > elapsed) {
|
|
476
661
|
await wait(at - elapsed);
|
|
477
662
|
elapsed = at;
|
|
478
663
|
}
|
|
479
664
|
const shot = await call("Page.captureScreenshot", { format: "png" });
|
|
480
665
|
const bytes = Buffer.from(shot.data, "base64");
|
|
481
|
-
const file = join(
|
|
666
|
+
const file = join(out, frameName(at, options));
|
|
482
667
|
writeFileSync(file, bytes);
|
|
483
668
|
frames.push({ at, file, bytes: bytes.length });
|
|
484
669
|
}
|
|
@@ -487,6 +672,8 @@ async function capture(options) {
|
|
|
487
672
|
const drawn = await evaluate(
|
|
488
673
|
"({ frames: window.__gsapMotionFrames ?? null, since: window.__gsapMotionStart ?? null })",
|
|
489
674
|
);
|
|
675
|
+
const canvases = await evaluate("document.querySelectorAll(\"canvas\").length");
|
|
676
|
+
const viewport = await evaluate(VIEWPORT);
|
|
490
677
|
const after = await call("Performance.getMetrics");
|
|
491
678
|
|
|
492
679
|
const delta = (name) => {
|
|
@@ -511,7 +698,7 @@ async function capture(options) {
|
|
|
511
698
|
overMs: span,
|
|
512
699
|
};
|
|
513
700
|
|
|
514
|
-
return { url: options.url, frames, timeline, measured, notes, chrome: executable };
|
|
701
|
+
return { url: options.url, frames, timeline, measured, notes, notDone, canvases, viewport, chrome: executable };
|
|
515
702
|
} finally {
|
|
516
703
|
await browser.close();
|
|
517
704
|
}
|
|
@@ -527,13 +714,19 @@ export function report(result, options) {
|
|
|
527
714
|
options.dark && "dark",
|
|
528
715
|
options.mobile && "mobile 390x844",
|
|
529
716
|
options.cpu > 1 && `CPU ÷${options.cpu}`,
|
|
717
|
+
options.headed && "headed, on the GPU",
|
|
718
|
+
options.seek && `seeking with window.${options.seek}`,
|
|
530
719
|
].filter(Boolean);
|
|
531
720
|
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
721
|
+
if (result.viewport) lines.push(` viewport: ${result.viewport.width}x${result.viewport.height}`);
|
|
532
722
|
for (const note of result.notes) lines.push(` ${note}`);
|
|
723
|
+
const gpu = gpuNote(result.canvases, options);
|
|
724
|
+
if (gpu) lines.push(` ${gpu}`);
|
|
533
725
|
|
|
534
726
|
lines.push("", `Frames (${result.frames.length}), in ${options.out}`);
|
|
535
727
|
for (const frame of result.frames) {
|
|
536
|
-
|
|
728
|
+
const when = options.seek ? `${frame.at}s`.padStart(8) : `${String(frame.at).padStart(5)}ms`;
|
|
729
|
+
lines.push(` ${when} ${frame.file}`);
|
|
537
730
|
}
|
|
538
731
|
|
|
539
732
|
lines.push("", "Timeline, as the page is running it");
|
|
@@ -576,6 +769,11 @@ export function report(result, options) {
|
|
|
576
769
|
` · ${measured.recalcs} style recalculations (${measured.recalcSeconds}s)`,
|
|
577
770
|
);
|
|
578
771
|
lines.push("", "These are observations. What they mean is yours to judge.");
|
|
772
|
+
/** Last, so a reader who sees only the end still sees it. */
|
|
773
|
+
if (result.notDone?.length) {
|
|
774
|
+
lines.push("", "Not done — the run exits non-zero");
|
|
775
|
+
for (const line of result.notDone) lines.push(` ${line}`);
|
|
776
|
+
}
|
|
579
777
|
|
|
580
778
|
return lines.join("\n");
|
|
581
779
|
}
|
|
@@ -591,9 +789,15 @@ async function main(argv) {
|
|
|
591
789
|
process.exit(2);
|
|
592
790
|
}
|
|
593
791
|
|
|
792
|
+
if (options.probe) {
|
|
793
|
+
console.log(probe());
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
594
797
|
try {
|
|
595
798
|
const result = await capture(options);
|
|
596
799
|
console.log(options.json ? JSON.stringify(result, null, 2) : report(result, options));
|
|
800
|
+
process.exitCode = exitFor(result.notDone);
|
|
597
801
|
} catch (error) {
|
|
598
802
|
console.error(error.message);
|
|
599
803
|
process.exit(1);
|