@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.
- package/README.md +1 -1
- package/bin/gsap-motion.mjs +7 -0
- 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 +7 -2
- 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 +15 -2
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +8 -0
- package/skill/reference/motion-design.md +1 -2
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +54 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +4 -0
- package/skill/scripts/capture-motion.mjs +120 -10
- package/skill/scripts/lib/ast.mjs +58 -9
- package/skill/scripts/lib/rules.mjs +263 -35
- package/skill/scripts/review-motion.mjs +167 -8
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
648
|
+
const out = prepareOut(options.out);
|
|
561
649
|
const png = await call("Page.captureScreenshot", { format: "png" });
|
|
562
|
-
shot = join(
|
|
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(
|