@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.
@@ -19,13 +19,24 @@
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 {
31
+ connect,
32
+ exitFor,
33
+ findChrome,
34
+ gpuNote,
35
+ parseViewport,
36
+ prepareOut,
37
+ scrollExpression,
38
+ undone,
39
+ } from "./capture-motion.mjs";
29
40
 
30
41
  // --- What a recording means ---------------------------------------------------
31
42
 
@@ -199,7 +210,7 @@ export function afterScroll(found, scrolledAt, { settle = 300 } = {}) {
199
210
  */
200
211
  export function seamEvents(torn, { window = 50 } = {}) {
201
212
  const flat = torn.flatMap((element) =>
202
- element.seams.map((seam) => ({ key: element.key, at: seam.at, jump: seam.jump })),
213
+ element.seams.map((seam) => ({ key: element.key, box: element.box, at: seam.at, jump: seam.jump })),
203
214
  );
204
215
  flat.sort((a, b) => a.at - b.at);
205
216
 
@@ -215,6 +226,7 @@ export function seamEvents(torn, { window = 50 } = {}) {
215
226
  events.push({
216
227
  at: seam.at,
217
228
  elements: new Set([seam.key]),
229
+ box: seam.box,
218
230
  least: seam.jump,
219
231
  most: seam.jump,
220
232
  });
@@ -224,11 +236,44 @@ export function seamEvents(torn, { window = 50 } = {}) {
224
236
  at: event.at,
225
237
  count: event.elements.size,
226
238
  example: [...event.elements][0],
239
+ /**
240
+ * Where the example was on screen, as [x, y, width, height]. A key like
241
+ * `div.absolute` names hundreds of elements in a Tailwind project; a real
242
+ * report could not find the one that tore.
243
+ */
244
+ box: event.box ?? null,
227
245
  least: event.least,
228
246
  most: event.most,
229
247
  }));
230
248
  }
231
249
 
250
+ /**
251
+ * The long frames, with the sampler's own time taken out of each.
252
+ *
253
+ * Reading every moving element's box and style is work inside every frame
254
+ * this records, so a long frame is partly the recorder's. `cost` is that work,
255
+ * per frame, as `[at, ms]`; what falls inside a long frame is subtracted, and
256
+ * the rest is the page's own. Null when the browser has no Long Animation
257
+ * Frames API, which is not the same as no long frames.
258
+ */
259
+ export function longFrameSummary(longFrames, cost = []) {
260
+ if (!longFrames) return null;
261
+ const own = (frame) =>
262
+ cost
263
+ .filter(([at]) => at >= frame.at && at <= frame.at + frame.duration)
264
+ .reduce((sum, [, ms]) => sum + ms, 0);
265
+ const frames = longFrames.map((frame) => ({
266
+ ...frame,
267
+ page: Math.max(0, Math.round(frame.duration - own(frame))),
268
+ }));
269
+ const longest = frames.reduce((a, b) => (b.duration > (a?.duration ?? -1) ? b : a), null);
270
+ return {
271
+ count: frames.length,
272
+ longest,
273
+ blocking: frames.reduce((sum, frame) => sum + frame.blocking, 0),
274
+ };
275
+ }
276
+
232
277
  /** Each element and the moment it first moved, in that order. */
233
278
  export function starts(log) {
234
279
  const first = new Map();
@@ -253,12 +298,15 @@ export function parseOptions(argv) {
253
298
  watch: 3000,
254
299
  scroll: null,
255
300
  mobile: false,
301
+ viewport: null,
256
302
  reduced: false,
257
303
  dark: false,
258
304
  cpu: 1,
259
305
  out: null,
260
306
  json: false,
261
307
  chrome: null,
308
+ headed: false,
309
+ probe: null,
262
310
  errors: [],
263
311
  };
264
312
 
@@ -300,9 +348,18 @@ export function parseOptions(argv) {
300
348
  case "--chrome":
301
349
  options.chrome = next() ?? null;
302
350
  break;
351
+ case "--headed":
352
+ options.headed = true;
353
+ break;
303
354
  case "--mobile":
304
355
  options.mobile = true;
305
356
  break;
357
+ case "--viewport": {
358
+ const size = parseViewport(next());
359
+ if (size) options.viewport = size;
360
+ else options.errors.push("--viewport takes a width and height, such as --viewport 1440x900.");
361
+ break;
362
+ }
306
363
  case "--reduced":
307
364
  options.reduced = true;
308
365
  break;
@@ -312,12 +369,27 @@ export function parseOptions(argv) {
312
369
  case "--json":
313
370
  options.json = true;
314
371
  break;
372
+ case "--probe": {
373
+ const kind = next();
374
+ if (kind !== "install" && kind !== "read") {
375
+ options.errors.push("--probe takes install or read.");
376
+ } else {
377
+ options.probe = kind;
378
+ }
379
+ break;
380
+ }
315
381
  default:
316
382
  options.errors.push(`Unknown option: ${argument}`);
317
383
  }
318
384
  }
319
385
 
320
- if (options.url === null) options.errors.push("No URL. Pass the page to review.");
386
+ /** A probe is printed, not run, so it needs no page. */
387
+ if (options.url === null && options.probe === null) {
388
+ options.errors.push("No URL. Pass the page to review.");
389
+ }
390
+ if (options.mobile && options.viewport) {
391
+ options.errors.push("--mobile sets its own viewport; drop one of --mobile and --viewport.");
392
+ }
321
393
  return options;
322
394
  }
323
395
 
@@ -366,9 +438,46 @@ const RECORDER = `(() => {
366
438
  return id;
367
439
  };
368
440
 
441
+ /**
442
+ * Long frames and layout shifts, where the browser reports them. The sampler
443
+ * below is itself work inside every frame, so its own time is kept too, and a
444
+ * long frame is reported with that time taken out. A read never shifts
445
+ * layout, so the shifts are the page's alone.
446
+ */
447
+ const supported = (type) =>
448
+ typeof PerformanceObserver !== "undefined" &&
449
+ (PerformanceObserver.supportedEntryTypes || []).includes(type);
450
+ const longFrames = supported("long-animation-frame") ? [] : null;
451
+ const shift = supported("layout-shift") ? { total: 0, count: 0, largest: null } : null;
452
+ const cost = [];
453
+ if (longFrames) {
454
+ new PerformanceObserver((list) => {
455
+ for (const entry of list.getEntries()) {
456
+ longFrames.push({
457
+ at: Math.round(entry.startTime - started),
458
+ duration: Math.round(entry.duration),
459
+ blocking: Math.round(entry.blockingDuration || 0),
460
+ });
461
+ }
462
+ }).observe({ type: "long-animation-frame" });
463
+ }
464
+ if (shift) {
465
+ new PerformanceObserver((list) => {
466
+ for (const entry of list.getEntries()) {
467
+ if (entry.hadRecentInput) continue;
468
+ shift.total += entry.value;
469
+ shift.count += 1;
470
+ if (!shift.largest || entry.value > shift.largest.value) {
471
+ shift.largest = { at: Math.round(entry.startTime - started), value: entry.value };
472
+ }
473
+ }
474
+ }).observe({ type: "layout-shift" });
475
+ }
476
+
369
477
  const tick =() => {
478
+ const began = performance.now();
370
479
  frames += 1;
371
- const at = Math.round(performance.now() - started);
480
+ const at = Math.round(began - started);
372
481
  const now = new Map();
373
482
  const moved = [];
374
483
  /**
@@ -428,6 +537,7 @@ const RECORDER = `(() => {
428
537
 
429
538
  previous = now;
430
539
  if (moved.length) log.push({ at, moved, scrolled });
540
+ cost.push([at, Math.round((performance.now() - began) * 10) / 10]);
431
541
  requestAnimationFrame(tick);
432
542
  };
433
543
  requestAnimationFrame(tick);
@@ -471,6 +581,10 @@ const RECORDER = `(() => {
471
581
  viewport: { width: innerWidth, height: innerHeight },
472
582
  text: textLines(),
473
583
  log,
584
+ longFrames,
585
+ shift,
586
+ cost,
587
+ canvases: document.querySelectorAll("canvas").length,
474
588
  });
475
589
  return true;
476
590
  })()`;
@@ -485,7 +599,7 @@ async function watch(options) {
485
599
  throw new Error("No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.");
486
600
  }
487
601
 
488
- const browser = connect(executable);
602
+ const browser = connect(executable, { headless: !options.headed });
489
603
  const notes = [];
490
604
 
491
605
  try {
@@ -518,6 +632,9 @@ async function watch(options) {
518
632
  });
519
633
  await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
520
634
  }
635
+ if (options.viewport) {
636
+ await call("Emulation.setDeviceMetricsOverride", { ...options.viewport, deviceScaleFactor: 1, mobile: false });
637
+ }
521
638
  if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
522
639
 
523
640
  await call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDER });
@@ -526,19 +643,13 @@ async function watch(options) {
526
643
  await call("Page.navigate", { url: options.url });
527
644
  await wait(options.watch);
528
645
 
646
+ const notDone = [];
529
647
  if (options.scroll !== null) {
530
- const pixels = asNumber(options.scroll);
531
- const moved = await evaluate(
532
- pixels === null
533
- ? `(() => {
534
- const element = document.querySelector(${JSON.stringify(options.scroll)});
535
- if (!element) return null;
536
- element.scrollIntoView({ block: "center" });
537
- return Math.round(window.scrollY);
538
- })()`
539
- : `(window.scrollTo(0, ${pixels}), Math.round(window.scrollY))`,
540
- );
541
- notes.push(moved === null ? `scroll: nothing matched ${options.scroll}` : `scroll: to ${moved}px`);
648
+ const step = { type: "scroll", value: options.scroll };
649
+ const moved = await evaluate(scrollExpression(options.scroll));
650
+ const missed = undone(step, moved);
651
+ if (missed) notDone.push(missed);
652
+ else notes.push(`scroll: to ${moved.y}px`);
542
653
  await wait(options.watch);
543
654
  }
544
655
 
@@ -557,9 +668,9 @@ async function watch(options) {
557
668
 
558
669
  let shot = null;
559
670
  if (options.out) {
560
- mkdirSync(resolve(options.out), { recursive: true });
671
+ const out = prepareOut(options.out);
561
672
  const png = await call("Page.captureScreenshot", { format: "png" });
562
- shot = join(resolve(options.out), "review.png");
673
+ shot = join(out, "review.png");
563
674
  writeFileSync(shot, Buffer.from(png.data, "base64"));
564
675
  }
565
676
 
@@ -570,6 +681,7 @@ async function watch(options) {
570
681
  recalcs: delta("RecalcStyleCount"),
571
682
  shot,
572
683
  notes,
684
+ notDone,
573
685
  };
574
686
  } finally {
575
687
  await browser.close();
@@ -590,6 +702,7 @@ export function conclude(result) {
590
702
  /** One track per element, to find the loops that tear. */
591
703
  const tracks = new Map();
592
704
  const named = new Map();
705
+ const boxes = new Map();
593
706
  for (const frame of log) {
594
707
  for (const moved of frame.moved) {
595
708
  const id = moved.id ?? moved.key;
@@ -597,6 +710,7 @@ export function conclude(result) {
597
710
  track.push({ at: frame.at, x: moved.x });
598
711
  tracks.set(id, track);
599
712
  named.set(id, moved.key);
713
+ boxes.set(id, moved.box);
600
714
  }
601
715
  }
602
716
  const scrolledAt = log.filter((frame) => frame.scrolled).map((frame) => frame.at);
@@ -606,7 +720,12 @@ export function conclude(result) {
606
720
  if (track.length < 20) continue;
607
721
  const found = afterScroll(seams(track), scrolledAt);
608
722
  if (found.length) {
609
- torn.push({ key: named.get(key) ?? key, seams: found.slice(0, 3), frames: track.length });
723
+ torn.push({
724
+ key: named.get(key) ?? key,
725
+ box: boxes.get(key) ?? null,
726
+ seams: found.slice(0, 3),
727
+ frames: track.length,
728
+ });
610
729
  }
611
730
  }
612
731
 
@@ -629,7 +748,48 @@ export function conclude(result) {
629
748
  overText,
630
749
  began: began.slice(0, 8),
631
750
  spread: began.length > 1 ? began.at(-1).at - began[0].at : 0,
751
+ /** Both absent from a recording made before they were measured: unknown, not zero. */
752
+ longFrames: longFrameSummary(recorded.longFrames, recorded.cost),
753
+ canvases: recorded.canvases ?? null,
754
+ shift: recorded.shift
755
+ ? { ...recorded.shift, total: Math.round(recorded.shift.total * 1000) / 1000 }
756
+ : null,
757
+ };
758
+ }
759
+
760
+ // --- In a browser this does not drive -----------------------------------------
761
+
762
+ /**
763
+ * The recorder and its conclusions, as functions for a browser something else
764
+ * already holds — a DevTools MCP server's `evaluate_script`, where a project
765
+ * allows no second Chrome.
766
+ *
767
+ * `install` records from the moment it runs, not from navigation, so first
768
+ * motion is measured from there. `read` concludes in the page with the same
769
+ * functions this file uses, and returns only the findings: the frame log never
770
+ * crosses the wire. Emulation is the driving tool's own to set.
771
+ */
772
+ export function probe(kind) {
773
+ if (kind === "install") return `() => ${RECORDER}`;
774
+ const helpers = {
775
+ overlap,
776
+ coverage,
777
+ seams,
778
+ motionOverText,
779
+ afterScroll,
780
+ seamEvents,
781
+ starts,
782
+ firstMotion,
783
+ longFrameSummary,
784
+ conclude,
632
785
  };
786
+ return `() => {
787
+ if (!window.__gsapMotionReview) return null;
788
+ ${Object.entries(helpers)
789
+ .map(([name, fn]) => ` const ${name} = ${fn};`)
790
+ .join("\n")}
791
+ return conclude({ recorded: window.__gsapMotionReview() });
792
+ }`;
633
793
  }
634
794
 
635
795
  // --- Report --------------------------------------------------------------------
@@ -643,9 +803,14 @@ export function report(result, options) {
643
803
  options.dark && "dark",
644
804
  options.mobile && "mobile 390x844",
645
805
  options.cpu > 1 && `CPU ÷${options.cpu}`,
806
+ options.headed && "headed, on the GPU",
646
807
  ].filter(Boolean);
647
808
  if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
809
+ const { viewport } = result.recorded;
810
+ if (viewport) lines.push(` viewport: ${viewport.width}x${viewport.height}`);
648
811
  for (const note of result.notes) lines.push(` ${note}`);
812
+ const gpu = gpuNote(found.canvases, options);
813
+ if (gpu) lines.push(` ${gpu}`);
649
814
 
650
815
  lines.push("", "What moved");
651
816
  if (found.firstMotion === null) {
@@ -655,7 +820,7 @@ export function report(result, options) {
655
820
  lines.push(` first motion at ${found.firstMotion}ms`);
656
821
  lines.push(
657
822
  ` ${found.movers} elements moved, ${found.gsapMovers} of them GSAP's` +
658
- `${found.movers > found.gsapMovers ? " (the rest is CSS, which the source audit cannot see)" : ""}`,
823
+ `${found.movers > found.gsapMovers ? " (the rest is CSS, Web Animations or other script, which the source audit cannot see)" : ""}`,
659
824
  );
660
825
  if (found.spread) lines.push(` they started over a span of ${found.spread}ms`);
661
826
  }
@@ -680,13 +845,36 @@ export function report(result, options) {
680
845
  */
681
846
  lines.push(" layout work is not measured here — sampling forces it; use inspect for that");
682
847
 
848
+ const long = found.longFrames;
849
+ if (long === null) {
850
+ lines.push(" long frames: not measured — this browser has no Long Animation Frames API");
851
+ } else if (long.count === 0) {
852
+ lines.push(" no frame took longer than 50ms");
853
+ } else {
854
+ lines.push(
855
+ ` ${long.count} frame${long.count === 1 ? "" : "s"} over 50ms; the longest` +
856
+ ` ${long.longest.duration}ms at ${long.longest.at}ms, ${long.longest.page}ms of it` +
857
+ " the page's own rather than the sampler's",
858
+ );
859
+ }
860
+ const shift = found.shift;
861
+ if (shift === null) {
862
+ lines.push(" layout shift: not measured — this browser does not report it");
863
+ } else {
864
+ lines.push(
865
+ ` layout shift: ${shift.total} over ${shift.count} shift${shift.count === 1 ? "" : "s"}` +
866
+ `${shift.largest ? `, the largest ${Math.round(shift.largest.value * 1000) / 1000} at ${shift.largest.at}ms` : ""}`,
867
+ );
868
+ }
869
+
683
870
  if (found.seams.length) {
684
871
  lines.push("", "Seams — a step far larger than the motion's own rhythm");
685
872
  for (const event of found.seams.slice(0, 6)) {
686
873
  const size = event.least === event.most ? `${event.most}px` : `${event.least}-${event.most}px`;
874
+ const where = event.box ? ` at ${event.box.map(Math.round).join(",")}` : "";
687
875
  lines.push(
688
876
  ` ${event.at}ms ${event.count === 1 ? event.example : `${event.count} elements (${event.example})`}` +
689
- ` jumps ${size}`,
877
+ `${where} jumps ${size}`,
690
878
  );
691
879
  }
692
880
  }
@@ -701,6 +889,11 @@ export function report(result, options) {
701
889
  if (result.shot) lines.push("", `Frame: ${result.shot}`);
702
890
 
703
891
  lines.push("", "These are measurements. Composition, rhythm and taste are yours to judge.");
892
+ /** Last, so a reader who sees only the end still sees it. */
893
+ if (result.notDone?.length) {
894
+ lines.push("", "Not done — the run exits non-zero");
895
+ for (const line of result.notDone) lines.push(` ${line}`);
896
+ }
704
897
  return lines.join("\n");
705
898
  }
706
899
 
@@ -714,13 +907,19 @@ async function main(argv) {
714
907
  process.exit(2);
715
908
  }
716
909
 
910
+ if (options.probe) {
911
+ console.log(probe(options.probe));
912
+ return;
913
+ }
914
+
717
915
  try {
718
916
  const result = await watch(options);
719
917
  console.log(
720
918
  options.json
721
- ? JSON.stringify({ url: result.url, ...conclude(result) }, null, 2)
919
+ ? JSON.stringify({ url: result.url, ...conclude(result), notDone: result.notDone }, null, 2)
722
920
  : report(result, options),
723
921
  );
922
+ process.exitCode = exitFor(result.notDone);
724
923
  } catch (error) {
725
924
  console.error(error.message);
726
925
  process.exit(1);