@mehshekari/gsap-motion 4.3.0 → 4.4.2

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.
@@ -19,13 +19,15 @@
19
19
  * for a reviewer looking at the filmstrip; this reports evidence, with the time
20
20
  * and the element, and leaves the verdict to them.
21
21
  *
22
- * Node.js 22+, no dependencies, whatever Chrome the machine already has.
22
+ * Node.js 22+, no dependencies, whatever Chrome the machine already has. With
23
+ * `--probe install` and `--probe read` it drives no browser at all, and prints
24
+ * the two functions to run in one you already have.
23
25
  */
24
- import { mkdirSync, writeFileSync } from "node:fs";
26
+ import { writeFileSync } from "node:fs";
25
27
  import { join, resolve } from "node:path";
26
28
  import { fileURLToPath } from "node:url";
27
29
 
28
- import { connect, findChrome } from "./capture-motion.mjs";
30
+ import { connect, findChrome, gpuNote, prepareOut } from "./capture-motion.mjs";
29
31
 
30
32
  // --- What a recording means ---------------------------------------------------
31
33
 
@@ -229,6 +231,33 @@ export function seamEvents(torn, { window = 50 } = {}) {
229
231
  }));
230
232
  }
231
233
 
234
+ /**
235
+ * The long frames, with the sampler's own time taken out of each.
236
+ *
237
+ * Reading every moving element's box and style is work inside every frame
238
+ * this records, so a long frame is partly the recorder's. `cost` is that work,
239
+ * per frame, as `[at, ms]`; what falls inside a long frame is subtracted, and
240
+ * the rest is the page's own. Null when the browser has no Long Animation
241
+ * Frames API, which is not the same as no long frames.
242
+ */
243
+ export function longFrameSummary(longFrames, cost = []) {
244
+ if (!longFrames) return null;
245
+ const own = (frame) =>
246
+ cost
247
+ .filter(([at]) => at >= frame.at && at <= frame.at + frame.duration)
248
+ .reduce((sum, [, ms]) => sum + ms, 0);
249
+ const frames = longFrames.map((frame) => ({
250
+ ...frame,
251
+ page: Math.max(0, Math.round(frame.duration - own(frame))),
252
+ }));
253
+ const longest = frames.reduce((a, b) => (b.duration > (a?.duration ?? -1) ? b : a), null);
254
+ return {
255
+ count: frames.length,
256
+ longest,
257
+ blocking: frames.reduce((sum, frame) => sum + frame.blocking, 0),
258
+ };
259
+ }
260
+
232
261
  /** Each element and the moment it first moved, in that order. */
233
262
  export function starts(log) {
234
263
  const first = new Map();
@@ -259,6 +288,8 @@ export function parseOptions(argv) {
259
288
  out: null,
260
289
  json: false,
261
290
  chrome: null,
291
+ headed: false,
292
+ probe: null,
262
293
  errors: [],
263
294
  };
264
295
 
@@ -300,6 +331,9 @@ export function parseOptions(argv) {
300
331
  case "--chrome":
301
332
  options.chrome = next() ?? null;
302
333
  break;
334
+ case "--headed":
335
+ options.headed = true;
336
+ break;
303
337
  case "--mobile":
304
338
  options.mobile = true;
305
339
  break;
@@ -312,12 +346,24 @@ export function parseOptions(argv) {
312
346
  case "--json":
313
347
  options.json = true;
314
348
  break;
349
+ case "--probe": {
350
+ const kind = next();
351
+ if (kind !== "install" && kind !== "read") {
352
+ options.errors.push("--probe takes install or read.");
353
+ } else {
354
+ options.probe = kind;
355
+ }
356
+ break;
357
+ }
315
358
  default:
316
359
  options.errors.push(`Unknown option: ${argument}`);
317
360
  }
318
361
  }
319
362
 
320
- if (options.url === null) options.errors.push("No URL. Pass the page to review.");
363
+ /** A probe is printed, not run, so it needs no page. */
364
+ if (options.url === null && options.probe === null) {
365
+ options.errors.push("No URL. Pass the page to review.");
366
+ }
321
367
  return options;
322
368
  }
323
369
 
@@ -366,9 +412,46 @@ const RECORDER = `(() => {
366
412
  return id;
367
413
  };
368
414
 
415
+ /**
416
+ * Long frames and layout shifts, where the browser reports them. The sampler
417
+ * below is itself work inside every frame, so its own time is kept too, and a
418
+ * long frame is reported with that time taken out. A read never shifts
419
+ * layout, so the shifts are the page's alone.
420
+ */
421
+ const supported = (type) =>
422
+ typeof PerformanceObserver !== "undefined" &&
423
+ (PerformanceObserver.supportedEntryTypes || []).includes(type);
424
+ const longFrames = supported("long-animation-frame") ? [] : null;
425
+ const shift = supported("layout-shift") ? { total: 0, count: 0, largest: null } : null;
426
+ const cost = [];
427
+ if (longFrames) {
428
+ new PerformanceObserver((list) => {
429
+ for (const entry of list.getEntries()) {
430
+ longFrames.push({
431
+ at: Math.round(entry.startTime - started),
432
+ duration: Math.round(entry.duration),
433
+ blocking: Math.round(entry.blockingDuration || 0),
434
+ });
435
+ }
436
+ }).observe({ type: "long-animation-frame" });
437
+ }
438
+ if (shift) {
439
+ new PerformanceObserver((list) => {
440
+ for (const entry of list.getEntries()) {
441
+ if (entry.hadRecentInput) continue;
442
+ shift.total += entry.value;
443
+ shift.count += 1;
444
+ if (!shift.largest || entry.value > shift.largest.value) {
445
+ shift.largest = { at: Math.round(entry.startTime - started), value: entry.value };
446
+ }
447
+ }
448
+ }).observe({ type: "layout-shift" });
449
+ }
450
+
369
451
  const tick =() => {
452
+ const began = performance.now();
370
453
  frames += 1;
371
- const at = Math.round(performance.now() - started);
454
+ const at = Math.round(began - started);
372
455
  const now = new Map();
373
456
  const moved = [];
374
457
  /**
@@ -428,6 +511,7 @@ const RECORDER = `(() => {
428
511
 
429
512
  previous = now;
430
513
  if (moved.length) log.push({ at, moved, scrolled });
514
+ cost.push([at, Math.round((performance.now() - began) * 10) / 10]);
431
515
  requestAnimationFrame(tick);
432
516
  };
433
517
  requestAnimationFrame(tick);
@@ -471,6 +555,10 @@ const RECORDER = `(() => {
471
555
  viewport: { width: innerWidth, height: innerHeight },
472
556
  text: textLines(),
473
557
  log,
558
+ longFrames,
559
+ shift,
560
+ cost,
561
+ canvases: document.querySelectorAll("canvas").length,
474
562
  });
475
563
  return true;
476
564
  })()`;
@@ -485,7 +573,7 @@ async function watch(options) {
485
573
  throw new Error("No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.");
486
574
  }
487
575
 
488
- const browser = connect(executable);
576
+ const browser = connect(executable, { headless: !options.headed });
489
577
  const notes = [];
490
578
 
491
579
  try {
@@ -557,9 +645,9 @@ async function watch(options) {
557
645
 
558
646
  let shot = null;
559
647
  if (options.out) {
560
- mkdirSync(resolve(options.out), { recursive: true });
648
+ const out = prepareOut(options.out);
561
649
  const png = await call("Page.captureScreenshot", { format: "png" });
562
- shot = join(resolve(options.out), "review.png");
650
+ shot = join(out, "review.png");
563
651
  writeFileSync(shot, Buffer.from(png.data, "base64"));
564
652
  }
565
653
 
@@ -629,7 +717,48 @@ export function conclude(result) {
629
717
  overText,
630
718
  began: began.slice(0, 8),
631
719
  spread: began.length > 1 ? began.at(-1).at - began[0].at : 0,
720
+ /** Both absent from a recording made before they were measured: unknown, not zero. */
721
+ longFrames: longFrameSummary(recorded.longFrames, recorded.cost),
722
+ canvases: recorded.canvases ?? null,
723
+ shift: recorded.shift
724
+ ? { ...recorded.shift, total: Math.round(recorded.shift.total * 1000) / 1000 }
725
+ : null,
726
+ };
727
+ }
728
+
729
+ // --- In a browser this does not drive -----------------------------------------
730
+
731
+ /**
732
+ * The recorder and its conclusions, as functions for a browser something else
733
+ * already holds — a DevTools MCP server's `evaluate_script`, where a project
734
+ * allows no second Chrome.
735
+ *
736
+ * `install` records from the moment it runs, not from navigation, so first
737
+ * motion is measured from there. `read` concludes in the page with the same
738
+ * functions this file uses, and returns only the findings: the frame log never
739
+ * crosses the wire. Emulation is the driving tool's own to set.
740
+ */
741
+ export function probe(kind) {
742
+ if (kind === "install") return `() => ${RECORDER}`;
743
+ const helpers = {
744
+ overlap,
745
+ coverage,
746
+ seams,
747
+ motionOverText,
748
+ afterScroll,
749
+ seamEvents,
750
+ starts,
751
+ firstMotion,
752
+ longFrameSummary,
753
+ conclude,
632
754
  };
755
+ return `() => {
756
+ if (!window.__gsapMotionReview) return null;
757
+ ${Object.entries(helpers)
758
+ .map(([name, fn]) => ` const ${name} = ${fn};`)
759
+ .join("\n")}
760
+ return conclude({ recorded: window.__gsapMotionReview() });
761
+ }`;
633
762
  }
634
763
 
635
764
  // --- Report --------------------------------------------------------------------
@@ -643,9 +772,12 @@ export function report(result, options) {
643
772
  options.dark && "dark",
644
773
  options.mobile && "mobile 390x844",
645
774
  options.cpu > 1 && `CPU ÷${options.cpu}`,
775
+ options.headed && "headed, on the GPU",
646
776
  ].filter(Boolean);
647
777
  if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
648
778
  for (const note of result.notes) lines.push(` ${note}`);
779
+ const gpu = gpuNote(found.canvases, options);
780
+ if (gpu) lines.push(` ${gpu}`);
649
781
 
650
782
  lines.push("", "What moved");
651
783
  if (found.firstMotion === null) {
@@ -680,6 +812,28 @@ export function report(result, options) {
680
812
  */
681
813
  lines.push(" layout work is not measured here — sampling forces it; use inspect for that");
682
814
 
815
+ const long = found.longFrames;
816
+ if (long === null) {
817
+ lines.push(" long frames: not measured — this browser has no Long Animation Frames API");
818
+ } else if (long.count === 0) {
819
+ lines.push(" no frame took longer than 50ms");
820
+ } else {
821
+ lines.push(
822
+ ` ${long.count} frame${long.count === 1 ? "" : "s"} over 50ms; the longest` +
823
+ ` ${long.longest.duration}ms at ${long.longest.at}ms, ${long.longest.page}ms of it` +
824
+ " the page's own rather than the sampler's",
825
+ );
826
+ }
827
+ const shift = found.shift;
828
+ if (shift === null) {
829
+ lines.push(" layout shift: not measured — this browser does not report it");
830
+ } else {
831
+ lines.push(
832
+ ` layout shift: ${shift.total} over ${shift.count} shift${shift.count === 1 ? "" : "s"}` +
833
+ `${shift.largest ? `, the largest ${Math.round(shift.largest.value * 1000) / 1000} at ${shift.largest.at}ms` : ""}`,
834
+ );
835
+ }
836
+
683
837
  if (found.seams.length) {
684
838
  lines.push("", "Seams — a step far larger than the motion's own rhythm");
685
839
  for (const event of found.seams.slice(0, 6)) {
@@ -714,6 +868,11 @@ async function main(argv) {
714
868
  process.exit(2);
715
869
  }
716
870
 
871
+ if (options.probe) {
872
+ console.log(probe(options.probe));
873
+ return;
874
+ }
875
+
717
876
  try {
718
877
  const result = await watch(options);
719
878
  console.log(
@@ -1,126 +1,126 @@
1
- "use client";
2
-
3
- import { useGSAP } from "@gsap/react";
4
- import gsap from "gsap";
5
- import { ScrollTrigger } from "gsap/ScrollTrigger";
6
- import { useRef } from "react";
7
-
8
- /**
9
- * Starting point for a scroll-driven scene.
10
- *
11
- * The shape here is the lesson: one trigger owning one timeline, a genuinely
12
- * different strategy on mobile rather than a smaller one, and `ease: "none"`
13
- * throughout because the scrollbar — not an ease curve — is the playhead.
14
- *
15
- * Copy it, rename it, replace the beats. Typechecked for the same reason as
16
- * the sibling template.
17
- */
18
- gsap.registerPlugin(useGSAP, ScrollTrigger);
19
-
20
- /** Scroll distance the pinned sequence is spread across, in pixels. */
21
- const SCROLL_LENGTH = 2400;
22
-
23
- export function useScrollScene() {
24
- const rootRef = useRef<HTMLDivElement>(null);
25
-
26
- useGSAP(
27
- () => {
28
- const root = rootRef.current;
29
- if (!root) return;
30
-
31
- const mm = gsap.matchMedia();
32
-
33
- mm.add(
34
- {
35
- desktop: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
36
- mobile: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
37
- reduced: "(prefers-reduced-motion: reduce)",
38
- },
39
- (context) => {
40
- const { desktop, reduced } = context.conditions as Record<
41
- string,
42
- boolean
43
- >;
44
-
45
- /**
46
- * Scroll-scrubbed pinning is a textbook vestibular trigger: the
47
- * content moves while the page does not. Under `reduce` the beats
48
- * simply exist, composed, with no pin and no scrub.
49
- */
50
- if (reduced) {
51
- gsap.set("[data-beat]", { autoAlpha: 1, y: 0 });
52
- return;
53
- }
54
-
55
- /**
56
- * Not the desktop sequence scaled down. A 2400px pin on a phone is a
57
- * long time to be stuck, and pinning fights the address bar
58
- * collapsing — so each beat becomes an ordinary in-view reveal
59
- * instead. Same content, different strategy.
60
- */
61
- if (!desktop) {
62
- gsap.utils.toArray<HTMLElement>("[data-beat]").forEach((beat) => {
63
- gsap.from(beat, {
64
- autoAlpha: 0,
65
- y: 24,
66
- duration: 0.6,
67
- ease: "power3.out",
68
- scrollTrigger: { trigger: beat, start: "top 78%" },
69
- });
70
- });
71
- return;
72
- }
73
-
74
- gsap
75
- .timeline({
76
- /**
77
- * `ease: "none"` on every child. Easing inside a scrubbed
78
- * timeline fights the visitor's own scrolling and reads as lag —
79
- * `scrub` is where the smoothing belongs.
80
- */
81
- defaults: { ease: "none" },
82
- scrollTrigger: {
83
- trigger: root,
84
- start: "top top",
85
- end: `+=${SCROLL_LENGTH}`,
86
- pin: true,
87
- scrub: 1,
88
- /** Reduces the flicker when the pin engages at speed. */
89
- anticipatePin: 1,
90
- /** Recomputes measured start values after a resize. */
91
- invalidateOnRefresh: true,
92
- markers: process.env.NODE_ENV === "development",
93
- },
94
- })
95
- /**
96
- * Labels rather than offsets. A scrubbed sequence gets retimed
97
- * many times, and two children sharing a label stay together when
98
- * the beat before them changes length.
99
- */
100
- .addLabel("open")
101
- .from("[data-beat='1']", { autoAlpha: 0, y: 24, duration: 0.4 }, "open")
102
-
103
- .addLabel("detail")
104
- .to("[data-beat='1']", { autoAlpha: 0, duration: 0.3 }, "detail")
105
- .from("[data-beat='2']", { autoAlpha: 0, y: 24, duration: 0.4 }, "detail+=0.3")
106
-
107
- /**
108
- * Placed at position 0 with the timeline's full span, so it tracks
109
- * overall progress regardless of what else is happening.
110
- */
111
- .to("[data-progress]", { scaleX: 1, transformOrigin: "0% 50%" }, 0);
112
- },
113
- );
114
-
115
- /**
116
- * ScrollTriggers and the `matchMedia` above are both reverted with the
117
- * context — each registers itself when constructed inside one. Only
118
- * things GSAP never sees (timers, raw listeners, observers) need a
119
- * cleanup returned from here.
120
- */
121
- },
122
- { scope: rootRef },
123
- );
124
-
125
- return rootRef;
126
- }
1
+ "use client";
2
+
3
+ import { useGSAP } from "@gsap/react";
4
+ import gsap from "gsap";
5
+ import { ScrollTrigger } from "gsap/ScrollTrigger";
6
+ import { useRef } from "react";
7
+
8
+ /**
9
+ * Starting point for a scroll-driven scene.
10
+ *
11
+ * The shape here is the lesson: one trigger owning one timeline, a genuinely
12
+ * different strategy on mobile rather than a smaller one, and `ease: "none"`
13
+ * throughout because the scrollbar — not an ease curve — is the playhead.
14
+ *
15
+ * Copy it, rename it, replace the beats. Typechecked for the same reason as
16
+ * the sibling template.
17
+ */
18
+ gsap.registerPlugin(useGSAP, ScrollTrigger);
19
+
20
+ /** Scroll distance the pinned sequence is spread across, in pixels. */
21
+ const SCROLL_LENGTH = 2400;
22
+
23
+ export function useScrollScene() {
24
+ const rootRef = useRef<HTMLDivElement>(null);
25
+
26
+ useGSAP(
27
+ () => {
28
+ const root = rootRef.current;
29
+ if (!root) return;
30
+
31
+ const mm = gsap.matchMedia();
32
+
33
+ mm.add(
34
+ {
35
+ desktop: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
36
+ mobile: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
37
+ reduced: "(prefers-reduced-motion: reduce)",
38
+ },
39
+ (context) => {
40
+ const { desktop, reduced } = context.conditions as Record<
41
+ string,
42
+ boolean
43
+ >;
44
+
45
+ /**
46
+ * Scroll-scrubbed pinning is a textbook vestibular trigger: the
47
+ * content moves while the page does not. Under `reduce` the beats
48
+ * simply exist, composed, with no pin and no scrub.
49
+ */
50
+ if (reduced) {
51
+ gsap.set("[data-beat]", { autoAlpha: 1, y: 0 });
52
+ return;
53
+ }
54
+
55
+ /**
56
+ * Not the desktop sequence scaled down. A 2400px pin on a phone is a
57
+ * long time to be stuck, and pinning fights the address bar
58
+ * collapsing — so each beat becomes an ordinary in-view reveal
59
+ * instead. Same content, different strategy.
60
+ */
61
+ if (!desktop) {
62
+ gsap.utils.toArray<HTMLElement>("[data-beat]").forEach((beat) => {
63
+ gsap.from(beat, {
64
+ autoAlpha: 0,
65
+ y: 24,
66
+ duration: 0.6,
67
+ ease: "power3.out",
68
+ scrollTrigger: { trigger: beat, start: "top 78%" },
69
+ });
70
+ });
71
+ return;
72
+ }
73
+
74
+ gsap
75
+ .timeline({
76
+ /**
77
+ * `ease: "none"` on every child. Easing inside a scrubbed
78
+ * timeline fights the visitor's own scrolling and reads as lag —
79
+ * `scrub` is where the smoothing belongs.
80
+ */
81
+ defaults: { ease: "none" },
82
+ scrollTrigger: {
83
+ trigger: root,
84
+ start: "top top",
85
+ end: `+=${SCROLL_LENGTH}`,
86
+ pin: true,
87
+ scrub: 1,
88
+ /** Reduces the flicker when the pin engages at speed. */
89
+ anticipatePin: 1,
90
+ /** Recomputes measured start values after a resize. */
91
+ invalidateOnRefresh: true,
92
+ markers: process.env.NODE_ENV === "development",
93
+ },
94
+ })
95
+ /**
96
+ * Labels rather than offsets. A scrubbed sequence gets retimed
97
+ * many times, and two children sharing a label stay together when
98
+ * the beat before them changes length.
99
+ */
100
+ .addLabel("open")
101
+ .from("[data-beat='1']", { autoAlpha: 0, y: 24, duration: 0.4 }, "open")
102
+
103
+ .addLabel("detail")
104
+ .to("[data-beat='1']", { autoAlpha: 0, duration: 0.3 }, "detail")
105
+ .from("[data-beat='2']", { autoAlpha: 0, y: 24, duration: 0.4 }, "detail+=0.3")
106
+
107
+ /**
108
+ * Placed at position 0 with the timeline's full span, so it tracks
109
+ * overall progress regardless of what else is happening.
110
+ */
111
+ .to("[data-progress]", { scaleX: 1, transformOrigin: "0% 50%" }, 0);
112
+ },
113
+ );
114
+
115
+ /**
116
+ * ScrollTriggers and the `matchMedia` above are both reverted with the
117
+ * context — each registers itself when constructed inside one. Only
118
+ * things GSAP never sees (timers, raw listeners, observers) need a
119
+ * cleanup returned from here.
120
+ */
121
+ },
122
+ { scope: rootRef },
123
+ );
124
+
125
+ return rootRef;
126
+ }