@mehshekari/gsap-motion 4.2.1 → 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.
@@ -49,6 +49,8 @@ import {
49
49
  const HINT = {
50
50
  matchMediaNeverRuns:
51
51
  "A matchMedia callback runs only while at least one of its named conditions matches, and a visitor with no reduced-motion preference matches none of these — so the branch for them is dead code and nothing animates. Add `motion: \"(prefers-reduced-motion: no-preference)\"` beside it, or use two separate `add` calls.",
52
+ contextSafeMisuse:
53
+ "The callback of `gsap.matchMedia().add`, `gsap.context` and `useGSAP` receives `(context, contextSafe)`: take the second argument, as in `mm.add(query, (context, contextSafe) => …)`, and wrap the delayed callback in it. `context.add(fn)` runs `fn` straight away instead.",
52
54
  stackedFrom:
53
55
  "A `from` or `fromTo` applies its start state the moment it is built. Built more than once against the same element, the last one wins, and holds that element at its start state until the playhead reaches it — invisible, if the start state hides it. Pass `immediateRender: false`, and set the element's first state yourself with `gsap.set`.",
54
56
  orphanTween:
@@ -59,6 +61,10 @@ const HINT = {
59
61
  "Keep it under a name and revert it when the component goes: `onUnmounted` in Vue, the function returned from `onMount` or `$effect` in Svelte, the `useEffect` cleanup in React, `astro:before-swap` in Astro. Everything created inside the context goes with it.",
60
62
  tweenPerFrame:
61
63
  "A tween per frame allocates an object per frame, and each one fights the last. Create a `gsap.quickTo()` or `quickSetter()` once outside the callback and call it inside — or, for scroll, scrub a timeline instead of writing values by hand.",
64
+ allocationPerFrame:
65
+ "Every frame's object is garbage by the next, and the collector reclaims it in a pause that lands as a dropped frame. Build it once, outside the callback — at module scope, or in a ref or useMemo — and reuse it with `.set()` or `.copy()`.",
66
+ layoutReadPerFrame:
67
+ "If anything has changed layout since the last frame, this read makes the browser compute it now, every frame. Measure once and again only on resize — a ScrollTrigger's `onRefresh`, or a ResizeObserver — and read the cached value here.",
62
68
  paintProperty:
63
69
  "Filters and shadows repaint the layer every frame, and the cost grows with the painted area. Animate `opacity` on a pre-blurred or pre-shadowed copy, or move a transform instead, and measure before defending anything else.",
64
70
  unownedLoop:
@@ -102,6 +108,7 @@ const HINT = {
102
108
  const MESSAGE = {
103
109
  easedLoop: "An infinite repeat with an ease other than `none`.",
104
110
  tweenPerFrame: "A tween is created here on every frame.",
111
+ layoutReadPerFrame: "Layout is read here on every frame.",
105
112
  unownedLoop: "An infinite repeat that nothing pauses.",
106
113
  ungatedHover: "A hover animation with no `(hover: hover)` gate.",
107
114
  delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
@@ -171,10 +178,19 @@ function layoutHint(property, percent) {
171
178
  const swap = TRANSFORM_FOR[property];
172
179
  return swap
173
180
  ? `Use \`${swap}\` instead — a compositor-only property.` +
181
+ (property === "height" ? ` ${HEIGHT_CAVEAT}` : "") +
174
182
  (percent ? ` ${PERCENT_CAVEAT}` : "")
175
183
  : "Prefer transform and opacity; they never touch layout.";
176
184
  }
177
185
 
186
+ /**
187
+ * A scale squashes what is inside. An accordion's text is the usual case, and
188
+ * a real project kept its `height` tween for exactly that reason, which the
189
+ * hint above alone would have argued against.
190
+ */
191
+ const HEIGHT_CAVEAT =
192
+ "Unless what is inside must keep its shape — an accordion's text: then transition `grid-template-rows` from `0fr` to `1fr`, or use Flip.";
193
+
178
194
  /** Whether a transform's from-value, as written, leaves the element untransformed. */
179
195
  function identityTransform(property, value) {
180
196
  const v = value.trim().replace(/^["'`]|["'`]$/g, "");
@@ -587,6 +603,36 @@ const PAINT_PROPERTIES = new Set([
587
603
  "boxShadow",
588
604
  ]);
589
605
 
606
+ /** Three's math types: what a render loop allocates when it should reuse. */
607
+ const THREE_MATH = new Set([
608
+ "Vector2",
609
+ "Vector3",
610
+ "Vector4",
611
+ "Quaternion",
612
+ "Euler",
613
+ "Matrix3",
614
+ "Matrix4",
615
+ "Color",
616
+ "Box3",
617
+ "Sphere",
618
+ "Ray",
619
+ "Plane",
620
+ "Spherical",
621
+ ]);
622
+
623
+ /** Reads that return layout, and so make the browser compute it if it is stale. */
624
+ const LAYOUT_READ_METHODS = new Set(["getBoundingClientRect", "getClientRects"]);
625
+ const LAYOUT_READ_PROPERTIES = new Set([
626
+ "offsetWidth",
627
+ "offsetHeight",
628
+ "offsetTop",
629
+ "offsetLeft",
630
+ "clientWidth",
631
+ "clientHeight",
632
+ "scrollWidth",
633
+ "scrollHeight",
634
+ ]);
635
+
590
636
  const HOVER_EVENTS = new Set([
591
637
  "pointerenter",
592
638
  "pointerover",
@@ -685,6 +731,45 @@ function stopped(ast, name) {
685
731
  );
686
732
  }
687
733
 
734
+ /**
735
+ * Every `add` call on a `gsap.matchMedia()`: made on it directly, or on a name
736
+ * the file binds it to, at declaration or later.
737
+ */
738
+ function matchMediaAdds(file) {
739
+ const media = new Set(
740
+ findAll(
741
+ file.ast,
742
+ (node) =>
743
+ (node.type === "VariableDeclarator" &&
744
+ node.id.type === "Identifier" &&
745
+ /**
746
+ * `let tl;` has no initializer, and `calleeName` does not accept
747
+ * null. Every fixture initialised its declarations, so 133 passing
748
+ * tests missed this; the first real project threw on it and took
749
+ * the whole audit down with it.
750
+ */
751
+ Boolean(node.init) &&
752
+ calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
753
+ (node.type === "AssignmentExpression" &&
754
+ node.left.type === "Identifier" &&
755
+ calleeName(unwrap(node.right)) === "gsap.matchMedia"),
756
+ ).map((node) => (node.type === "VariableDeclarator" ? node.id.name : node.left.name)),
757
+ );
758
+
759
+ const onMatchMedia = (call) => {
760
+ const callee = unwrap(call.callee);
761
+ if (callee?.type !== "MemberExpression") return false;
762
+ const object = unwrap(callee.object);
763
+ if (object?.type === "Identifier") return media.has(object.name);
764
+ return calleeName(object) === "gsap.matchMedia";
765
+ };
766
+
767
+ return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node));
768
+ }
769
+
770
+ /** Nodes a value passes through unchanged on its way to whatever uses it. */
771
+ const TRANSPARENT = new Set(["ParenthesizedExpression", "ChainExpression", "TSAsExpression", "TSNonNullExpression"]);
772
+
688
773
  export const RULES = [
689
774
  // --- Lifecycle ------------------------------------------------------------
690
775
  {
@@ -968,7 +1053,7 @@ export const RULES = [
968
1053
  return findAll(file.ast, animates).flatMap(
969
1054
  (call) => {
970
1055
  const seen = new Set();
971
- return varsObjects(call, tweenMethod(call))
1056
+ return varsObjects(call, tweenMethod(call), file.ast)
972
1057
  .flatMap((vars) => vars.properties)
973
1058
  .filter((property) => {
974
1059
  const key = keyName(property);
@@ -1046,7 +1131,7 @@ export const RULES = [
1046
1131
  test(file) {
1047
1132
  return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
1048
1133
  (call) => {
1049
- const vars = ownVars(call, tweenMethod(call));
1134
+ const vars = ownVars(call, tweenMethod(call), file.ast);
1050
1135
  if (!vars) return [];
1051
1136
  const repeat = propertyOf(vars, "repeat");
1052
1137
  if (!repeat || numberValue(repeat.value) !== -1) return [];
@@ -1360,6 +1445,91 @@ export const RULES = [
1360
1445
  return findings;
1361
1446
  },
1362
1447
  },
1448
+ {
1449
+ id: "allocation-per-frame",
1450
+ level: "warn",
1451
+ description:
1452
+ "A Three.js vector, colour, matrix or quaternion built every frame — in `useFrame`, the ticker, `onUpdate` or a `requestAnimationFrame` loop",
1453
+ /**
1454
+ * Only Three's math types, by name. Any `new` in a frame allocates, but a
1455
+ * `Set` or a `Map` there is usually deliberate and rarely hot; these are
1456
+ * the objects a render loop builds by the thousand, once per object per
1457
+ * frame, and each has a `set` or `copy` that makes the allocation pointless.
1458
+ */
1459
+ test(file) {
1460
+ const scopes = perFrameScopes(file.ast);
1461
+ if (scopes.length === 0) return [];
1462
+
1463
+ const built = findAll(file.ast, (node) => {
1464
+ if (node.type !== "NewExpression") return false;
1465
+ const name = dottedName(unwrap(node.callee))?.split(".").at(-1);
1466
+ return THREE_MATH.has(name);
1467
+ });
1468
+
1469
+ const reported = new Set();
1470
+ return built.flatMap((node) => {
1471
+ if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
1472
+ return [];
1473
+ }
1474
+ reported.add(node.start);
1475
+ const name = dottedName(unwrap(node.callee)).split(".").at(-1);
1476
+ return [
1477
+ {
1478
+ index: node.start,
1479
+ message: `A \`${name}\` is allocated here on every frame.`,
1480
+ hint: HINT.allocationPerFrame,
1481
+ },
1482
+ ];
1483
+ });
1484
+ },
1485
+ },
1486
+ {
1487
+ id: "layout-read-per-frame",
1488
+ level: "info",
1489
+ description:
1490
+ "A layout read — `getBoundingClientRect`, `offsetWidth`, `getComputedStyle` — in a callback that runs every frame",
1491
+ /**
1492
+ * Info, because a read is only expensive when something has invalidated
1493
+ * layout since the last frame, and that depends on what else runs — a
1494
+ * transform does not, a `width` tween does. What is certain is that the
1495
+ * value is re-measured every frame when it usually changes only on resize.
1496
+ */
1497
+ test(file) {
1498
+ const scopes = perFrameScopes(file.ast);
1499
+ if (scopes.length === 0) return [];
1500
+
1501
+ const reads = findAll(file.ast, (node) => {
1502
+ if (node.type === "CallExpression") {
1503
+ const name = calleeName(node);
1504
+ return (
1505
+ name === "getComputedStyle" ||
1506
+ name === "window.getComputedStyle" ||
1507
+ LAYOUT_READ_METHODS.has(methodName(node))
1508
+ );
1509
+ }
1510
+ return (
1511
+ node.type === "MemberExpression" &&
1512
+ !node.computed &&
1513
+ LAYOUT_READ_PROPERTIES.has(node.property.name)
1514
+ );
1515
+ });
1516
+
1517
+ const reported = new Set();
1518
+ return reads.flatMap((node) => {
1519
+ if (reported.has(node.start) || !scopes.some((scope) => contains(scope, node))) {
1520
+ return [];
1521
+ }
1522
+ reported.add(node.start);
1523
+ return [
1524
+ {
1525
+ index: node.start,
1526
+ message: MESSAGE.layoutReadPerFrame,
1527
+ hint: HINT.layoutReadPerFrame,
1528
+ },
1529
+ ];
1530
+ });
1531
+ },
1532
+ },
1363
1533
  {
1364
1534
  id: "paint-property",
1365
1535
  level: "warn",
@@ -1374,7 +1544,7 @@ export const RULES = [
1374
1544
 
1375
1545
  return findAll(file.ast, animates).flatMap((call) => {
1376
1546
  const seen = new Set();
1377
- return varsObjects(call, tweenMethod(call))
1547
+ return varsObjects(call, tweenMethod(call), file.ast)
1378
1548
  .flatMap((vars) => vars.properties)
1379
1549
  .filter((property) => {
1380
1550
  const key = keyName(property);
@@ -1421,7 +1591,7 @@ export const RULES = [
1421
1591
 
1422
1592
  return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
1423
1593
  if (inside(call)) return [];
1424
- const vars = ownVars(call, tweenMethod(call));
1594
+ const vars = ownVars(call, tweenMethod(call), file.ast);
1425
1595
  if (!vars) return [];
1426
1596
  const repeat = propertyOf(vars, "repeat");
1427
1597
  if (!repeat || numberValue(repeat.value) !== -1) return [];
@@ -1477,7 +1647,7 @@ export const RULES = [
1477
1647
 
1478
1648
  for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
1479
1649
  if (chainStart(call) !== call) continue;
1480
- const vars = ownVars(call, tweenMethod(call));
1650
+ const vars = ownVars(call, tweenMethod(call), file.ast);
1481
1651
  const delay = vars && propertyOf(vars, "delay");
1482
1652
  if (!delay || !(numberValue(delay.value) > 0)) continue;
1483
1653
  const scope = scopeOf(file.ast, call) ?? file.ast;
@@ -1628,35 +1798,6 @@ export const RULES = [
1628
1798
  const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
1629
1799
  const PREFERENCE = /prefers-reduced-motion/i;
1630
1800
 
1631
- /** Names bound to `gsap.matchMedia()` in this file, at declaration or later. */
1632
- const media = new Set(
1633
- findAll(
1634
- file.ast,
1635
- (node) =>
1636
- (node.type === "VariableDeclarator" &&
1637
- node.id.type === "Identifier" &&
1638
- /**
1639
- * `let tl;` has no initializer, and `calleeName` does not accept
1640
- * null. Every fixture initialised its declarations, so 133 passing
1641
- * tests missed this; the first real project threw on it and took
1642
- * the whole audit down with it.
1643
- */
1644
- Boolean(node.init) &&
1645
- calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
1646
- (node.type === "AssignmentExpression" &&
1647
- node.left.type === "Identifier" &&
1648
- calleeName(unwrap(node.right)) === "gsap.matchMedia"),
1649
- ).map((node) => (node.type === "VariableDeclarator" ? node.id.name : node.left.name)),
1650
- );
1651
-
1652
- const onMatchMedia = (call) => {
1653
- const callee = unwrap(call.callee);
1654
- if (callee?.type !== "MemberExpression") return false;
1655
- const object = unwrap(callee.object);
1656
- if (object?.type === "Identifier") return media.has(object.name);
1657
- return calleeName(object) === "gsap.matchMedia";
1658
- };
1659
-
1660
1801
  /** The conditions as written: in the call, or in a `const` the file declares once. */
1661
1802
  const conditionsOf = (argument) => {
1662
1803
  const target = unwrap(argument);
@@ -1714,7 +1855,7 @@ export const RULES = [
1714
1855
  }).length > 0;
1715
1856
  };
1716
1857
 
1717
- return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node))
1858
+ return matchMediaAdds(file)
1718
1859
  .filter((call) => {
1719
1860
  if (!everyReduces(conditionsOf(call.arguments[0]))) return false;
1720
1861
  const callback = resolveFunction(file.ast, call.arguments[1]);
@@ -1728,6 +1869,93 @@ export const RULES = [
1728
1869
  }));
1729
1870
  },
1730
1871
  },
1872
+ {
1873
+ id: "context-safe-misuse",
1874
+ level: "warn",
1875
+ description:
1876
+ "A callback made context-safe with the context itself, which runs it at once instead",
1877
+ /**
1878
+ * The callback of `gsap.matchMedia().add`, `gsap.context` or `useGSAP`
1879
+ * receives `(context, contextSafe)`. Two ways of getting that wrong both
1880
+ * ship a blank page:
1881
+ *
1882
+ * - `({ contextSafe }) => …` reads `contextSafe` from the context, which has
1883
+ * none. It is `undefined`, and the first call throws.
1884
+ * - `context.add(fn)` handed on as a callback. `add` with a function runs it
1885
+ * at once, passing the context, and returns what it returned, so the
1886
+ * caller receives no callback and `fn` ran with the wrong argument.
1887
+ *
1888
+ * Found in the 4.3 creative A/B: three of 27 runs, in both arms, crashed on
1889
+ * load this way, every one inside a matchMedia. `add` as a statement runs a
1890
+ * block inside the context and is correct; a named `add("onClick", fn)` and
1891
+ * `add(null, fn)` return a wrapper and are correct too. Only an `add` whose
1892
+ * result is used, given a function, is reported.
1893
+ */
1894
+ test(file) {
1895
+ const callbacks = new Set(
1896
+ [
1897
+ ...matchMediaAdds(file).map((call) => call.arguments[1]),
1898
+ ...findAll(
1899
+ file.ast,
1900
+ (node) =>
1901
+ node.type === "CallExpression" &&
1902
+ ["gsap.context", "useGSAP"].includes(calleeName(node)),
1903
+ ).map((call) => call.arguments[0]),
1904
+ ]
1905
+ .map((argument) => resolveFunction(file.ast, argument))
1906
+ .filter(Boolean),
1907
+ );
1908
+
1909
+ /** Is the call's result handed on, rather than thrown away as a statement? */
1910
+ const used = (call) => {
1911
+ let child = call;
1912
+ let parent = parentOf(file.ast, call);
1913
+ while (parent && TRANSPARENT.has(parent.type)) {
1914
+ child = parent;
1915
+ parent = parentOf(file.ast, parent);
1916
+ }
1917
+ if (!parent || parent.type === "ExpressionStatement" || parent.type === "SequenceExpression") {
1918
+ return false;
1919
+ }
1920
+ return !(parent.type === "CallExpression" && parent.callee === child);
1921
+ };
1922
+
1923
+ const findings = [];
1924
+ for (const fn of callbacks) {
1925
+ const [param] = fn.params;
1926
+ if (param?.type === "ObjectPattern") {
1927
+ const property = param.properties.find((p) => keyName(p) === "contextSafe");
1928
+ if (property) {
1929
+ findings.push({
1930
+ index: property.start,
1931
+ message:
1932
+ "`contextSafe` is read from the callback's first argument, the context, which has none: it is undefined, and the first call throws.",
1933
+ hint: HINT.contextSafeMisuse,
1934
+ });
1935
+ }
1936
+ continue;
1937
+ }
1938
+ if (param?.type !== "Identifier") continue;
1939
+ const adds = findAll(fn.body, (node) => {
1940
+ if (methodName(node) !== "add") return false;
1941
+ const receiver = unwrap(unwrap(node.callee).object);
1942
+ return receiver?.type === "Identifier" && receiver.name === param.name;
1943
+ });
1944
+ for (const call of adds) {
1945
+ const target = unwrap(call.arguments[0]);
1946
+ if (!target || target.type === "Literal" || target.type === "TemplateLiteral") continue;
1947
+ if (!isFunction(target) && target.type !== "Identifier") continue;
1948
+ if (!used(call)) continue;
1949
+ findings.push({
1950
+ index: call.start,
1951
+ message: `\`${param.name}.add(fn)\` runs fn at once and returns its result, so what is handed on here is not a callback.`,
1952
+ hint: HINT.contextSafeMisuse,
1953
+ });
1954
+ }
1955
+ }
1956
+ return findings;
1957
+ },
1958
+ },
1731
1959
  {
1732
1960
  id: "stacked-from",
1733
1961
  level: "warn",
@@ -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(