@mehshekari/gsap-motion 3.4.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,8 @@ No dependencies, no install scripts, Node.js 18 or later.
22
22
  | `audit-svg <file...>` | Reports what DrawSVG can draw, what MorphSVG can morph, and what will fail silently. `--morph a.svg b.svg` compares a pair; `--hues` also reports hard-coded hues. |
23
23
  | `inspect <url>` | Watches a page animate over the DevTools Protocol and reports what it saw: a filmstrip, the timeline GSAP is running, and the layouts, style recalculations and frames the browser did. `--scroll`, `--hover` and `--click` reach the state first; `--reduced`, `--dark`, `--mobile` and `--cpu` change the conditions. Needs a Chrome on the machine; installs nothing. |
24
24
  | `explain <file...|url>` | Maps an animation and changes nothing: where it lives, its preference branches, its beats in order with the reasons their comments give, its scroll configuration, and what the audit says. Given a URL it reads the running page instead, where the timeline is the one GSAP actually built. |
25
+ | `review <url>` | Measures what the motion actually did, by sampling the DOM every frame rather than by reading GSAP's timeline — which a bundled app never exposes, and which would make it work on demo pages and fail on real ones. Reports time to first motion, the share of the screen moving at its busiest, loop seams, and motion over text being read. Sampling forces layout, so it does not report layout counts; `inspect` measures those. Takes the same `--scroll`, `--reduced`, `--dark`, `--mobile` and `--cpu`. |
26
+ | `patterns check [file]` | Validates the Patterns section of your `ANIMATION.md`: the fields each status needs, the recorded uses behind `validated`, the named reviewer behind `canonical`, and entries verified against an older GSAP than the one installed. Exits 1 on a claim the evidence does not support, so it belongs in `lint` beside `audit`. A project with no Patterns section passes. |
25
27
  | `doctor` | Checks Node.js, the installed skill's version for this project and your user, your project's `gsap` and `@gsap/react`, and whether an `ANIMATION.md` exists. |
26
28
 
27
29
  ## In CI
@@ -30,7 +32,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
30
32
  build without warning:
31
33
 
32
34
  ```json
33
- "lint": "eslint && npx @mehshekari/gsap-motion@3.4.1 audit src --quiet"
35
+ "lint": "eslint && npx @mehshekari/gsap-motion@3.6.0 audit src --quiet"
34
36
  ```
35
37
 
36
38
  Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
@@ -9,6 +9,8 @@
9
9
  * npx @mehshekari/gsap-motion audit-svg <file.svg...> [--morph] [--hues]
10
10
  * npx @mehshekari/gsap-motion inspect <url> [--at ...] [--hover ...] [--reduced]
11
11
  * npx @mehshekari/gsap-motion explain <file...|url>
12
+ * npx @mehshekari/gsap-motion review <url> [--scroll ...] [--mobile]
13
+ * npx @mehshekari/gsap-motion patterns check [ANIMATION.md]
12
14
  * npx @mehshekari/gsap-motion doctor
13
15
  *
14
16
  * Installed as a dependency, the same commands run as `gsap-motion <command>`.
@@ -84,6 +86,19 @@ Commands
84
86
  explain <file...> Map an animation: scopes, branches, beats, triggers
85
87
  Give it a URL instead to read the running page
86
88
  --json Machine-readable output
89
+ review <url> Measure what the motion did: time to first motion,
90
+ how much moves at once, loop seams, motion over text
91
+ --watch <ms> How long to watch. Default 3000
92
+ --scroll <px|sel> Scroll, then watch again
93
+ --reduced --dark Emulate a preference
94
+ --mobile 390x844 with touch input
95
+ --cpu 4 Throttle the CPU by this factor
96
+ --out <dir> Save a frame alongside the measurements
97
+ --json Machine-readable output
98
+ patterns check [file] Check the Patterns section of your ANIMATION.md:
99
+ the fields each status needs, the uses behind
100
+ validated, the reviewer behind canonical
101
+ --json Machine-readable output
87
102
  doctor Check Node, the installed skill and your project's GSAP
88
103
  --version Print the version
89
104
 
@@ -322,6 +337,12 @@ switch (command) {
322
337
  case "explain":
323
338
  runScript("explain-motion.mjs", args);
324
339
  break;
340
+ case "review":
341
+ runScript("review-motion.mjs", args);
342
+ break;
343
+ case "patterns":
344
+ runScript("patterns.mjs", args);
345
+ break;
325
346
  case "doctor":
326
347
  doctor();
327
348
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehshekari/gsap-motion",
3
- "version": "3.4.1",
3
+ "version": "3.6.0",
4
4
  "description": "Install the gsap-creative-animation Agent Skill, and audit GSAP animation for leaks, jank and accessibility failures — with npx.",
5
5
  "keywords": [
6
6
  "gsap",
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
4
4
  license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
5
5
  compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 18+ and nothing else.
6
6
  metadata:
7
- version: "3.4.1"
7
+ version: "3.6.0"
8
8
  verified-gsap: "3.15"
9
9
  verified-gsap-react: "2.1"
10
10
  ---
@@ -101,8 +101,8 @@ intent → precedent → targets → trigger → motion language → technique
101
101
  2. **Precedent** — grep for the animation this codebase already has. Its
102
102
  trigger point, easing, durations, stagger shape and attribute naming are the
103
103
  house language: match them, and differ only for a reason you can state.
104
- Nothing downstream catches a skipped precedent — the code compiles, the
105
- audit passes, and the page reads as two different sites.
104
+ Nothing downstream catches a skipped precedent: the code compiles, the audit
105
+ passes, and the page reads as two different sites.
106
106
  3. **Targets** — which elements move, and can they be reached without a
107
107
  positional selector that breaks when the markup moves?
108
108
  4. **Trigger** — mount, in-view, scroll position, pointer, click, route, loop.
@@ -112,8 +112,8 @@ intent → precedent → targets → trigger → motion language → technique
112
112
  8. **Implementation** — typed, cleaned up, responsive.
113
113
  9. **Performance** — transform and opacity; measure before defending anything else.
114
114
  10. **Accessibility** — the reduced-motion design, focus, keyboard.
115
- 11. **Validate** — [refine.md](reference/refine.md): audit, fix in order,
116
- re-run, then the checklist in [motion-design.md](reference/motion-design.md).
115
+ 11. **Validate** — [refine.md](reference/refine.md): audit, fix in order, re-run,
116
+ then the checklist in [motion-design.md](reference/motion-design.md).
117
117
 
118
118
  ## Output format
119
119
 
@@ -178,16 +178,16 @@ node <skill-dir>/scripts/audit-svg.mjs <file.svg>
178
178
  node <skill-dir>/scripts/audit-svg.mjs --morph <a.svg> <b.svg>
179
179
  node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg>
180
180
 
181
- # Watch a page animate: frames, timeline, browser work.
182
- # Needs a Chrome; observations, not verdicts.
181
+ # Both need a Chrome. Watch a page animate; then measure what it did:
182
+ # first motion, screen share, seams, motion over text.
183
183
  node <skill-dir>/scripts/capture-motion.mjs <url> --at 0,300,900
184
+ node <skill-dir>/scripts/review-motion.mjs <url> --scroll 2000
184
185
  ```
185
186
 
186
- Run `audit-gsap` first in an `audit` and after writing animation — as a plugin,
187
- a hook already runs it on every edit. A false finding is a bug in the script,
188
- not something to work around.
187
+ Run `audit-gsap` in an `audit` and after writing animation; as a plugin, a hook
188
+ runs it on each edit. A false finding is a bug in the script, never something to
189
+ work around.
189
190
 
190
191
  `MotionPathHelper`, `GSDevTools`, `markers: true` and
191
- `MorphSVGPlugin.findShapeIndex()` are development-only, and a static import
192
- ships whatever `if` surrounds it. Import them dynamically behind a `NODE_ENV`
193
- check, and delete it once its value is baked in.
192
+ `MorphSVGPlugin.findShapeIndex()` are development-only, and a static import ships
193
+ whatever `if` surrounds it. Import them dynamically behind a `NODE_ENV` check.
@@ -83,10 +83,76 @@ one is short, and each section is an instruction rather than an essay:
83
83
 
84
84
  ## Precedents
85
85
  <!-- Files that already do this well, by path, and what each one taught. -->
86
+
87
+ ## Patterns
88
+ <!-- Shapes this project has proven. Format and statuses below; `patterns
89
+ check` validates this section. Omit the section until there is one. -->
86
90
  ````
87
91
 
88
92
  ---
89
93
 
94
+ ## Patterns: what the project has proven
95
+
96
+ A pattern is a shape of animation this project has used and can use again. The
97
+ section is optional, and empty is the honest state for a new project.
98
+
99
+ **Nothing becomes guidance by having been written.** A pattern loaded as
100
+ guidance repeats in every session after it, so a wrong one is not one mistake,
101
+ it is every future mistake. That is why a status is evidence, not a claim.
102
+
103
+ | Status | Reached when | Loaded as |
104
+ |---|---|---|
105
+ | `experimental` | Built for one request, its claims verified, the audit clean. **Offered in the answer, never written to the file.** | Nothing |
106
+ | `candidate` | The user accepts it into ANIMATION.md. That acceptance is its first use. | An option to mention, never a default |
107
+ | `validated` | Used again in a **different component** — two uses by default — each recorded with its file and commit, and each audit clean | The house way, unless the request gives a reason not to |
108
+ | `canonical` | A **named person** has reviewed it | A precedent, followed like the house motion language |
109
+ | `retired` | Disproved. Kept, with the reason, so it is not reinvented | A warning only |
110
+
111
+ **Who moves an entry.** The skill offers entries and proposes promotions with
112
+ their evidence; it never changes a status on its own. `candidate` needs your
113
+ yes. `validated` is counted by `patterns check` from the recorded uses, never
114
+ asserted by the entry. `canonical` needs a reviewer's name.
115
+
116
+ **Moving down is evidence too.** A bug traced to a pattern returns it to
117
+ `candidate` with the failure recorded. A GSAP release past its verified version
118
+ marks it for re-verification — it stays loaded, marked. A use later reverted
119
+ does not count.
120
+
121
+ ### The format
122
+
123
+ ```markdown
124
+ ### Ink behind a moving pen
125
+
126
+ - **Status:** candidate
127
+ - **Concept:** a mask shares the pen's duration and ease, so ink appears only
128
+ where the nib has already been.
129
+ - **Fixed:** the mask and the path share one duration and one ease.
130
+ - **Parameters:** duration, ease, nib radius.
131
+ - **Not when:** the stroke crosses itself — the mask reveals the crossing early.
132
+ - **Reduced motion:** the finished stroke is set, and no tween runs.
133
+ - **Verified against:** gsap 3.15, DrawSVGPlugin
134
+ - **Uses:**
135
+ - `src/components/Signature.tsx` — `a1b2c3d` — audit clean
136
+ - `src/components/Hero.tsx` — `e4f5a6b` — audit clean, review: no seams
137
+ - **Reviewed by:** <!-- a name, for canonical only -->
138
+ ```
139
+
140
+ `Concept`, `Not when`, `Reduced motion` and `Verified against` are required of
141
+ every entry that is in the file. A `retired` entry needs only **Why**. Two uses
142
+ in the same file count once: one component is one use.
143
+
144
+ Run it with the rest of the checks:
145
+
146
+ ```bash
147
+ gsap-motion patterns check # or: patterns check path/to/ANIMATION.md
148
+ ```
149
+
150
+ It exits 1 on a claim the evidence does not support, so it belongs in `lint`
151
+ beside `audit`. Where a candidate has earned `validated`, it says so and leaves
152
+ the promotion to you.
153
+
154
+ ---
155
+
90
156
  ## Rules worth knowing without a file
91
157
 
92
158
  Each of these fails silently in any project that has the condition.
@@ -78,6 +78,41 @@ Reach the state first, and change the conditions that usually break things:
78
78
 
79
79
  **Without a browser, report the stage as not run.** Not "fine".
80
80
 
81
+ ## Measure it, once you have watched it
82
+
83
+ `capture-motion.mjs` shows you frames. `review-motion.mjs` measures what the
84
+ motion did, and is where the visual stage of this loop gets its evidence:
85
+
86
+ ```bash
87
+ node <skill-dir>/scripts/review-motion.mjs http://localhost:3000 --scroll 2000
88
+ ```
89
+
90
+ It samples the DOM every frame rather than reading `gsap.globalTimeline`,
91
+ because a bundled app never publishes `gsap` to the page — so it works on a real
92
+ Next, Vite, Nuxt, Astro or Svelte app, and it sees CSS and Web Animations motion
93
+ that no source rule can reach.
94
+
95
+ | It says | Read it as |
96
+ | --- | --- |
97
+ | **first motion at N ms** | How long the page is still. Late enough and the visitor has scrolled past the thing you built. |
98
+ | **N elements moved, M of them GSAP's** | The gap is motion the audit never sees. A surprise there is worth chasing. |
99
+ | **X% of the screen in motion** | Everything moving at once reads as noise, not as choreography. |
100
+ | **seams** | A step far larger than that motion's own rhythm: a loop put back by hand instead of carried. |
101
+ | **motion over text** | Something painted over a line a reader is in the middle of. Hit-tested, so it is what is really on top. |
102
+
103
+ Three things it deliberately does not do, so you do not read them into it:
104
+
105
+ - **It does not report layout cost.** Reading every box every frame is what
106
+ forces layout, so its numbers would be partly its own doing. `inspect`
107
+ measures that without sampling; use it for cost.
108
+ - **It does not judge taste.** Composition, rhythm and whether the thing is any
109
+ good are yours, with the filmstrip in front of you.
110
+ - **It does not report a seam a scroll caused.** Jumping the scrollbar is how it
111
+ reaches a scene, and a scrubbed animation answers instantly and correctly.
112
+
113
+ A clean report is evidence, not proof: it watched one window, on one machine, at
114
+ one size. Say which.
115
+
81
116
  ## When to stop
82
117
 
83
118
  Stop when every check is clean, or after **three rounds**, reporting what is
@@ -103,6 +138,8 @@ The answer's **Verification** section is one line per check, with its evidence:
103
138
  ```text
104
139
  Verification
105
140
  - audit-gsap: clean (3 warns waived: layout-property on the grid, with reasons)
141
+ - review: first motion 410ms, 38% of the screen at its busiest, no seams,
142
+ nothing over text (1280x800, one 3s window)
106
143
  - reduced motion: emulated, the static branch sets the end state
107
144
  - StrictMode: mounted twice in dev, no doubled tweens
108
145
  - not checked: frame cost on a real phone — no device to hand
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * patterns — what a project has proven, and what it has only tried.
4
+ *
5
+ * A skill that writes down whatever it just invented teaches itself its own
6
+ * mistakes: a bad pattern, once loaded as guidance, repeats in every session
7
+ * after it. So a pattern's standing is not something it can claim. It is
8
+ * counted from evidence recorded in the project's own ANIMATION.md:
9
+ *
10
+ * experimental built once, verified, offered in an answer. Never written.
11
+ * candidate the user put it in the file. Mentionable, never a default.
12
+ * validated used again, in another component, each use with its commit.
13
+ * canonical a named person reviewed it.
14
+ * retired disproved, kept with the reason so it is not reinvented.
15
+ *
16
+ * `validated` is computed here rather than asserted there, and `canonical`
17
+ * needs a name, because those are the two a keen assistant would otherwise
18
+ * grant itself. This command only reads and reports; it never edits the file.
19
+ *
20
+ * Node.js 18+, no dependencies.
21
+ */
22
+ import { readFileSync } from "node:fs";
23
+ import { resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ export const STATUSES = ["experimental", "candidate", "validated", "canonical", "retired"];
27
+
28
+ /** How many separate components a pattern must appear in to be validated. */
29
+ export const USES_FOR_VALIDATED = 2;
30
+
31
+ const FIELDS = {
32
+ concept: "concept",
33
+ fixed: "fixed",
34
+ parameters: "parameters",
35
+ "not when": "notWhen",
36
+ "reduced motion": "reducedMotion",
37
+ "verified against": "verifiedAgainst",
38
+ "reviewed by": "reviewedBy",
39
+ why: "why",
40
+ status: "status",
41
+ uses: "uses",
42
+ };
43
+
44
+ /**
45
+ * Read the Patterns section of an ANIMATION.md.
46
+ *
47
+ * The format is what a person would write by hand — a heading per pattern and a
48
+ * bold-labelled list under it — because a format nobody will write is a format
49
+ * nothing will be recorded in. A field may wrap over several lines.
50
+ */
51
+ export function parse(markdown) {
52
+ const lines = markdown.split(/\r?\n/);
53
+ const entries = [];
54
+
55
+ let inSection = false;
56
+ let entry = null;
57
+ let field = null;
58
+
59
+ const closeField = () => {
60
+ if (entry && field && entry.fields[field] !== undefined) {
61
+ entry.fields[field] = entry.fields[field].trim();
62
+ }
63
+ field = null;
64
+ };
65
+
66
+ for (const line of lines) {
67
+ const heading = /^(#{2,3})\s+(.*)$/.exec(line);
68
+ if (heading) {
69
+ const [, hashes, title] = heading;
70
+ if (hashes === "##") {
71
+ closeField();
72
+ entry = null;
73
+ inSection = /^patterns\b/i.test(title.trim());
74
+ continue;
75
+ }
76
+ if (inSection) {
77
+ closeField();
78
+ entry = { name: title.trim(), status: null, fields: {}, uses: [] };
79
+ entries.push(entry);
80
+ }
81
+ continue;
82
+ }
83
+ if (!inSection || !entry) continue;
84
+
85
+ const labelled = /^\s*-\s+\*\*(.+?):\*\*\s*(.*)$/.exec(line);
86
+ if (labelled) {
87
+ closeField();
88
+ const key = FIELDS[labelled[1].trim().toLowerCase()];
89
+ if (!key) continue;
90
+ if (key === "status") {
91
+ entry.status = labelled[2].trim().toLowerCase() || null;
92
+ continue;
93
+ }
94
+ if (key === "uses") {
95
+ field = "uses";
96
+ continue;
97
+ }
98
+ field = key;
99
+ entry.fields[key] = labelled[2];
100
+ continue;
101
+ }
102
+
103
+ /** A use: a file, the commit it landed in, and what was checked. */
104
+ const use = /^\s+-\s+`([^`]+)`\s*[—-]\s*`([^`]+)`\s*(?:[—-]\s*(.*))?$/.exec(line);
105
+ if (use && field === "uses") {
106
+ entry.uses.push({ file: use[1].trim(), commit: use[2].trim(), note: (use[3] ?? "").trim() });
107
+ continue;
108
+ }
109
+
110
+ /** A continuation of the field above, wrapped by a person or a formatter. */
111
+ if (field && field !== "uses" && /^\s+\S/.test(line)) {
112
+ entry.fields[field] = `${entry.fields[field] ?? ""} ${line.trim()}`;
113
+ }
114
+ }
115
+ closeField();
116
+
117
+ return entries;
118
+ }
119
+
120
+ /** Components, not lines: two commits to one file is one pattern used once. */
121
+ const distinctUses = (entry) => new Set(entry.uses.map((use) => use.file)).size;
122
+
123
+ const REQUIRED = ["concept", "notWhen", "reducedMotion", "verifiedAgainst"];
124
+ const LABEL = {
125
+ concept: "concept",
126
+ notWhen: "not when",
127
+ reducedMotion: "reduced motion",
128
+ verifiedAgainst: "verified against",
129
+ };
130
+
131
+ /**
132
+ * What the section gets wrong.
133
+ *
134
+ * Errors are claims the evidence does not support; warnings are entries that
135
+ * are fine but should not be in the file yet. Nothing here is a judgement about
136
+ * whether the pattern is any good.
137
+ */
138
+ export function check(entries, { uses = USES_FOR_VALIDATED } = {}) {
139
+ const found = [];
140
+ const at = (entry, level, message) => found.push({ name: entry.name, level, message });
141
+
142
+ for (const entry of entries) {
143
+ if (!entry.status) {
144
+ at(entry, "error", `"${entry.name}" has no status. One of: ${STATUSES.join(", ")}.`);
145
+ continue;
146
+ }
147
+ if (!STATUSES.includes(entry.status)) {
148
+ at(
149
+ entry,
150
+ "error",
151
+ `"${entry.name}": ${entry.status} is not a status. One of: ${STATUSES.join(", ")}.`,
152
+ );
153
+ continue;
154
+ }
155
+
156
+ if (entry.status === "retired") {
157
+ /** A retired pattern is kept only so it is not reinvented, so it owes one thing. */
158
+ if (!entry.fields.why) {
159
+ at(entry, "error", `"${entry.name}" is retired but does not say why it was retired.`);
160
+ }
161
+ continue;
162
+ }
163
+
164
+ if (entry.status === "experimental") {
165
+ at(
166
+ entry,
167
+ "warn",
168
+ `"${entry.name}" is experimental, which is offered, not written. ` +
169
+ "Make it a candidate when it is accepted, or take it out.",
170
+ );
171
+ continue;
172
+ }
173
+
174
+ const missing = REQUIRED.filter((key) => !entry.fields[key]);
175
+ if (missing.length) {
176
+ at(
177
+ entry,
178
+ "error",
179
+ `"${entry.name}" is missing: ${missing.map((key) => LABEL[key]).join(", ")}.`,
180
+ );
181
+ }
182
+
183
+ if (entry.status === "validated" || entry.status === "canonical") {
184
+ const count = distinctUses(entry);
185
+ if (count < uses) {
186
+ at(
187
+ entry,
188
+ "error",
189
+ `"${entry.name}" claims ${entry.status} with ${count} recorded use${count === 1 ? "" : "s"}. ` +
190
+ `${uses} in separate components are needed, each with its file and commit.`,
191
+ );
192
+ }
193
+ }
194
+
195
+ if (entry.status === "canonical" && !entry.fields.reviewedBy) {
196
+ at(
197
+ entry,
198
+ "error",
199
+ `"${entry.name}" claims canonical with nobody named. Add "Reviewed by".`,
200
+ );
201
+ }
202
+ }
203
+
204
+ return found;
205
+ }
206
+
207
+ const asVersion = (text) => {
208
+ const found = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(text ?? "");
209
+ return found ? [Number(found[1]), Number(found[2]), Number(found[3] ?? 0)] : null;
210
+ };
211
+
212
+ /**
213
+ * Patterns verified against a GSAP older than the installed one.
214
+ *
215
+ * They stay loaded — a pattern does not stop working because a minor version
216
+ * shipped — but they are marked, because the claim behind them was checked
217
+ * against something else. A patch difference is not worth a word.
218
+ */
219
+ export function stale(entries, { gsap } = {}) {
220
+ const installed = asVersion(gsap);
221
+ if (!installed) return [];
222
+
223
+ const found = [];
224
+ for (const entry of entries) {
225
+ if (entry.status === "retired") continue;
226
+ const verified = asVersion(entry.fields.verifiedAgainst);
227
+ if (!verified) continue;
228
+ const older = verified[0] < installed[0] || (verified[0] === installed[0] && verified[1] < installed[1]);
229
+ if (older) {
230
+ found.push({
231
+ name: entry.name,
232
+ level: "warn",
233
+ message:
234
+ `"${entry.name}" was verified against gsap ${verified[0]}.${verified[1]}, ` +
235
+ `and ${gsap} is installed. Re-verify it, or record the version you checked.`,
236
+ });
237
+ }
238
+ }
239
+ return found;
240
+ }
241
+
242
+ /**
243
+ * Candidates that have earned validated.
244
+ *
245
+ * Proposed, never applied: the skill offers a promotion with its evidence and a
246
+ * person accepts it. Canonical is absent on purpose — no amount of use earns a
247
+ * review.
248
+ */
249
+ export function promotable(entries, { uses = USES_FOR_VALIDATED } = {}) {
250
+ return entries
251
+ .filter((entry) => entry.status === "candidate" && distinctUses(entry) >= uses)
252
+ .filter((entry) => REQUIRED.every((key) => entry.fields[key]))
253
+ .map((entry) => ({
254
+ name: entry.name,
255
+ to: "validated",
256
+ uses: distinctUses(entry),
257
+ files: [...new Set(entry.uses.map((use) => use.file))],
258
+ }));
259
+ }
260
+
261
+ // --- The command ---------------------------------------------------------------
262
+
263
+ const installedGsap = (root) => {
264
+ for (const path of ["node_modules/gsap/package.json", "package.json"]) {
265
+ try {
266
+ const json = JSON.parse(readFileSync(resolve(root, path), "utf8"));
267
+ const version = path.endsWith("gsap/package.json")
268
+ ? json.version
269
+ : json.dependencies?.gsap ?? json.devDependencies?.gsap;
270
+ if (version) return String(version).replace(/^[\^~]/, "");
271
+ } catch {
272
+ /** No package.json, or no gsap in it: there is simply nothing to compare. */
273
+ }
274
+ }
275
+ return null;
276
+ };
277
+
278
+ export function report(entries, findings, { file, json, quiet }) {
279
+ if (json) {
280
+ return JSON.stringify({ file, patterns: entries.length, findings }, null, 2);
281
+ }
282
+
283
+ /**
284
+ * In a lint script, a check with nothing to say should say nothing. Errors
285
+ * still print, because they are why the run fails.
286
+ */
287
+ if (quiet) {
288
+ const errors = findings.filter((finding) => finding.level === "error");
289
+ return errors.map((finding) => ` error ${finding.message}`).join("\n");
290
+ }
291
+
292
+ const lines = [];
293
+ if (!entries.length) {
294
+ lines.push(`No Patterns section in ${file}.`);
295
+ lines.push("Nothing to check. A pattern is written there once it has been accepted.");
296
+ return lines.join("\n");
297
+ }
298
+
299
+ const by = (status) => entries.filter((entry) => entry.status === status).length;
300
+ lines.push(`${entries.length} pattern${entries.length === 1 ? "" : "s"} in ${file}`);
301
+ lines.push(
302
+ " " +
303
+ STATUSES.map((status) => `${by(status)} ${status}`)
304
+ .filter((part) => !part.startsWith("0 "))
305
+ .join(" · "),
306
+ );
307
+
308
+ if (findings.length) {
309
+ lines.push("");
310
+ for (const finding of findings) {
311
+ lines.push(` ${finding.level === "error" ? "error" : " warn"} ${finding.message}`);
312
+ }
313
+ }
314
+
315
+ const ready = promotable(entries);
316
+ if (ready.length) {
317
+ lines.push("", "Earned a promotion — yours to grant, with the evidence:");
318
+ for (const entry of ready) {
319
+ lines.push(` ${entry.name} → validated (${entry.uses} uses: ${entry.files.join(", ")})`);
320
+ }
321
+ }
322
+
323
+ if (!findings.length) lines.push("", "patterns: clean");
324
+ return lines.join("\n");
325
+ }
326
+
327
+ async function main(argv) {
328
+ const json = argv.includes("--json");
329
+ const quiet = argv.includes("--quiet");
330
+ const rest = argv.filter(
331
+ (argument) => !["--json", "--quiet", "check"].includes(argument),
332
+ );
333
+ const file = rest[0] ?? "ANIMATION.md";
334
+ const root = process.cwd();
335
+
336
+ let markdown;
337
+ try {
338
+ markdown = readFileSync(resolve(root, file), "utf8");
339
+ } catch {
340
+ console.error(`Cannot read ${file}. Pass the path to your ANIMATION.md.`);
341
+ process.exit(2);
342
+ }
343
+
344
+ const entries = parse(markdown);
345
+ const findings = [...check(entries), ...stale(entries, { gsap: installedGsap(root) })];
346
+ const text = report(entries, findings, { file, json, quiet });
347
+ if (text) console.log(text);
348
+ process.exit(findings.some((finding) => finding.level === "error") ? 1 : 0);
349
+ }
350
+
351
+ const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
352
+ if (invoked) await main(process.argv.slice(2));
@@ -0,0 +1,731 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * review — the visual audit: what the motion actually did, measured.
4
+ *
5
+ * `audit` reads the source and says what is likely wrong. `inspect` watches and
6
+ * reports what it saw. This sits between them: it watches, and then draws
7
+ * conclusions that can be checked — time to first motion, how much of the screen
8
+ * moves at once, motion over text being read, loop seams, and layout work per
9
+ * frame.
10
+ *
11
+ * It does not read `gsap.globalTimeline`. A bundled app never publishes `gsap`,
12
+ * so an audit built on the timeline would work on demo pages and fail on real
13
+ * ones. Everything here comes from sampling the DOM: every frame, the box,
14
+ * opacity and transform of each element that can move, diffed against the frame
15
+ * before. That sees CSS and Web Animations motion too, which the source audit
16
+ * cannot see at all.
17
+ *
18
+ * What it does not do is judge taste. Composition, rhythm and visual quality are
19
+ * for a reviewer looking at the filmstrip; this reports evidence, with the time
20
+ * and the element, and leaves the verdict to them.
21
+ *
22
+ * Node.js 18+, no dependencies, whatever Chrome the machine already has.
23
+ */
24
+ import { mkdirSync, writeFileSync } from "node:fs";
25
+ import { join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ import { connect, findChrome } from "./capture-motion.mjs";
29
+
30
+ // --- What a recording means ---------------------------------------------------
31
+
32
+ /** The first moment anything moved, or null when nothing ever did. */
33
+ export function firstMotion(log) {
34
+ return log.length ? log[0].at : null;
35
+ }
36
+
37
+ /**
38
+ * The share of the viewport in motion.
39
+ *
40
+ * Summing the boxes is wrong and wildly so: on a real page the moving elements
41
+ * are nested, and the first spike reported 34 viewports in motion on a viewport
42
+ * that has one. This is the area of their union, clipped to the screen — the
43
+ * fraction a viewer would say is moving.
44
+ *
45
+ * The union is computed by sweeping the distinct x edges and, for each strip,
46
+ * merging the y ranges that cover it. With the handful of boxes a frame holds,
47
+ * that is exact and fast enough to run per frame.
48
+ */
49
+ export function coverage(boxes, viewport) {
50
+ const clipped = [];
51
+ for (const [x, y, width, height] of boxes) {
52
+ const x0 = Math.max(0, x);
53
+ const y0 = Math.max(0, y);
54
+ const x1 = Math.min(viewport.width, x + width);
55
+ const y1 = Math.min(viewport.height, y + height);
56
+ if (x1 > x0 && y1 > y0) clipped.push([x0, y0, x1, y1]);
57
+ }
58
+ if (!clipped.length) return 0;
59
+
60
+ const edges = [...new Set(clipped.flatMap(([x0, , x1]) => [x0, x1]))].sort((a, b) => a - b);
61
+ let area = 0;
62
+ for (let i = 0; i < edges.length - 1; i += 1) {
63
+ const left = edges[i];
64
+ const right = edges[i + 1];
65
+ const width = right - left;
66
+ if (!width) continue;
67
+
68
+ const spans = clipped
69
+ .filter(([x0, , x1]) => x0 <= left && x1 >= right)
70
+ .map(([, y0, , y1]) => [y0, y1])
71
+ .sort((a, b) => a[0] - b[0]);
72
+
73
+ let covered = 0;
74
+ let openFrom = null;
75
+ let openTo = null;
76
+ for (const [y0, y1] of spans) {
77
+ if (openTo === null || y0 > openTo) {
78
+ if (openTo !== null) covered += openTo - openFrom;
79
+ openFrom = y0;
80
+ openTo = y1;
81
+ } else if (y1 > openTo) {
82
+ openTo = y1;
83
+ }
84
+ }
85
+ if (openTo !== null) covered += openTo - openFrom;
86
+ area += width * covered;
87
+ }
88
+
89
+ return Math.round((area / (viewport.width * viewport.height)) * 1000) / 1000;
90
+ }
91
+
92
+ /**
93
+ * Where a loop jumps instead of travelling.
94
+ *
95
+ * A seam is a frame whose step is unlike every other step: a marquee that
96
+ * restarts at zero rather than being carried back, and shows a tear. Easing is
97
+ * not a seam, which is why this compares against the median step rather than
98
+ * the mean — an eased tween's steps vary smoothly, and no single one stands out
99
+ * against the middle of the distribution.
100
+ *
101
+ * A ratio alone is not enough. A nearly-still element has a median step of a
102
+ * fraction of a pixel, so the smallest wobble is many times it: the first run
103
+ * against a real page reported an orbiting dot that moved 7.6px. A tear has to
104
+ * be big enough to see, so a seam is both unlike its neighbours and at least
105
+ * `least` pixels.
106
+ */
107
+ export function seams(track, { factor = 6, minimum = 4, least = 16 } = {}) {
108
+ if (track.length < minimum) return [];
109
+
110
+ const steps = [];
111
+ /**
112
+ * From index 2: a track begins when the element first moves, so its first
113
+ * step is the leap out of rest that a `from` tween makes when it sets its
114
+ * starting offset. A real page reported every such entrance as a 633px tear.
115
+ */
116
+ for (let i = 2; i < track.length; i += 1) {
117
+ steps.push({ at: track[i].at, delta: Math.abs(track[i].x - track[i - 1].x) });
118
+ }
119
+ if (!steps.length) return [];
120
+
121
+ const sorted = [...steps].map((step) => step.delta).sort((a, b) => a - b);
122
+ const median = sorted[Math.floor(sorted.length / 2)];
123
+ /** A still element has no normal step to be unlike, so nothing is a seam. */
124
+ if (!median) return [];
125
+
126
+ return steps
127
+ .filter((step) => step.delta > median * factor && step.delta >= least)
128
+ .map((step) => ({ at: step.at, jump: Math.round(step.delta * 10) / 10 }));
129
+ }
130
+
131
+ /** How much of `a` lies under `b`, as a share of `a`. */
132
+ const overlap = ([ax, ay, aw, ah], [bx, by, bw, bh]) => {
133
+ const width = Math.min(ax + aw, bx + bw) - Math.max(ax, bx);
134
+ const height = Math.min(ay + ah, by + bh) - Math.max(ay, by);
135
+ if (width <= 0 || height <= 0) return 0;
136
+ return (width * height) / (aw * ah);
137
+ };
138
+
139
+ /**
140
+ * Motion passing over text a reader is in the middle of.
141
+ *
142
+ * A shared edge is not a problem — a rule sliding under a heading covers a few
143
+ * pixels of it — so this only reports motion over a quarter of the line or
144
+ * more. An element moving over itself is not motion over text, or every reveal
145
+ * of a paragraph would be reported.
146
+ */
147
+ export function motionOverText(moving, text, { least = 0.25, visible = 0.5 } = {}) {
148
+ const found = [];
149
+ for (const line of text) {
150
+ for (const mover of moving) {
151
+ if (mover.key === line.key) continue;
152
+ /** A sheet at two percent opacity is over the text but not in the way. */
153
+ if (mover.opacity !== undefined && mover.opacity < visible) continue;
154
+ /**
155
+ * Nor is a wrapper that paints nothing. Geometry cannot tell a positioning
156
+ * box from a sheet: on a real page a transparent full-screen div was
157
+ * reported as covering every line on it. The page is asked whether the
158
+ * element paints at all, and only then is it in the way.
159
+ */
160
+ if (mover.paints === false) continue;
161
+ /**
162
+ * When the page hit-tested the line, believe it: `covered` is the element
163
+ * actually painted on top of it, and null means nothing is.
164
+ */
165
+ if (line.covered !== undefined && mover.id !== line.covered) continue;
166
+ const share = overlap(line.box, mover.box);
167
+ if (share >= least) {
168
+ found.push({
169
+ text: line.key,
170
+ over: mover.key,
171
+ share: Math.round(share * 100) / 100,
172
+ });
173
+ }
174
+ }
175
+ }
176
+ return found;
177
+ }
178
+
179
+ /**
180
+ * Seams that a scroll did not cause.
181
+ *
182
+ * Jumping the scrollbar is how this tool reaches a scene, and a scrubbed
183
+ * animation answers an instant scroll by moving instantly — correctly. A real
184
+ * page reported 575px and 77px tears at the exact moment of the scroll, which
185
+ * says something about how it was driven, not about the animation. Anything
186
+ * within `settle` of a frame that scrolled is therefore not reported.
187
+ */
188
+ export function afterScroll(found, scrolledAt, { settle = 300 } = {}) {
189
+ return found.filter((seam) => !scrolledAt.some((at) => seam.at >= at && seam.at - at <= settle));
190
+ }
191
+
192
+ /**
193
+ * One lurch is one finding, however many elements were in it.
194
+ *
195
+ * Elements that move together tear together: a real page reported five rects
196
+ * jumping 25-30px, which reads as five problems and is one. Seams within
197
+ * `window` of each other are the same event, and the reader is told how many
198
+ * elements were in it.
199
+ */
200
+ export function seamEvents(torn, { window = 50 } = {}) {
201
+ const flat = torn.flatMap((element) =>
202
+ element.seams.map((seam) => ({ key: element.key, at: seam.at, jump: seam.jump })),
203
+ );
204
+ flat.sort((a, b) => a.at - b.at);
205
+
206
+ const events = [];
207
+ for (const seam of flat) {
208
+ const open = events.at(-1);
209
+ if (open && seam.at - open.at <= window) {
210
+ open.elements.add(seam.key);
211
+ open.least = Math.min(open.least, seam.jump);
212
+ open.most = Math.max(open.most, seam.jump);
213
+ continue;
214
+ }
215
+ events.push({
216
+ at: seam.at,
217
+ elements: new Set([seam.key]),
218
+ least: seam.jump,
219
+ most: seam.jump,
220
+ });
221
+ }
222
+
223
+ return events.map((event) => ({
224
+ at: event.at,
225
+ count: event.elements.size,
226
+ example: [...event.elements][0],
227
+ least: event.least,
228
+ most: event.most,
229
+ }));
230
+ }
231
+
232
+ /** Each element and the moment it first moved, in that order. */
233
+ export function starts(log) {
234
+ const first = new Map();
235
+ for (const frame of log) {
236
+ for (const moved of frame.moved) {
237
+ if (!first.has(moved.key)) first.set(moved.key, frame.at);
238
+ }
239
+ }
240
+ return [...first].map(([key, at]) => ({ key, at }));
241
+ }
242
+
243
+ // --- Options -----------------------------------------------------------------
244
+
245
+ const asNumber = (value) => {
246
+ if (!/^\d+$/.test(String(value ?? ""))) return null;
247
+ return Number(value);
248
+ };
249
+
250
+ export function parseOptions(argv) {
251
+ const options = {
252
+ url: null,
253
+ watch: 3000,
254
+ scroll: null,
255
+ mobile: false,
256
+ reduced: false,
257
+ dark: false,
258
+ cpu: 1,
259
+ out: null,
260
+ json: false,
261
+ chrome: null,
262
+ errors: [],
263
+ };
264
+
265
+ for (let i = 0; i < argv.length; i += 1) {
266
+ const argument = argv[i];
267
+ const next = () => argv[(i += 1)];
268
+
269
+ if (!argument.startsWith("--")) {
270
+ if (options.url === null) options.url = argument;
271
+ else options.errors.push(`More than one URL: ${argument}`);
272
+ continue;
273
+ }
274
+
275
+ switch (argument) {
276
+ case "--watch": {
277
+ const ms = asNumber(next());
278
+ if (ms === null || ms < 200) {
279
+ options.errors.push("--watch takes milliseconds, 200 or more.");
280
+ } else {
281
+ options.watch = ms;
282
+ }
283
+ break;
284
+ }
285
+ case "--scroll":
286
+ options.scroll = next() ?? null;
287
+ break;
288
+ case "--out":
289
+ options.out = next() ?? null;
290
+ break;
291
+ case "--cpu": {
292
+ const rate = Number(next());
293
+ if (!Number.isFinite(rate) || rate < 1) {
294
+ options.errors.push("--cpu takes a factor of 1 or more.");
295
+ } else {
296
+ options.cpu = rate;
297
+ }
298
+ break;
299
+ }
300
+ case "--chrome":
301
+ options.chrome = next() ?? null;
302
+ break;
303
+ case "--mobile":
304
+ options.mobile = true;
305
+ break;
306
+ case "--reduced":
307
+ options.reduced = true;
308
+ break;
309
+ case "--dark":
310
+ options.dark = true;
311
+ break;
312
+ case "--json":
313
+ options.json = true;
314
+ break;
315
+ default:
316
+ options.errors.push(`Unknown option: ${argument}`);
317
+ }
318
+ }
319
+
320
+ if (options.url === null) options.errors.push("No URL. Pass the page to review.");
321
+ return options;
322
+ }
323
+
324
+ // --- What runs in the page ----------------------------------------------------
325
+
326
+ /**
327
+ * The recorder, installed before the page's own scripts so it sees the first
328
+ * frame. It keeps its log in the page and hands the whole thing over at the end:
329
+ * one round trip, not one per frame, because a round trip per frame would slow
330
+ * the page it is measuring.
331
+ */
332
+ const RECORDER = `(() => {
333
+ if (window.__gsapMotionReview) return true;
334
+ const log = [];
335
+ const started = performance.now();
336
+ let frames = 0;
337
+ let previous = new Map();
338
+ let lastScrollX = window.scrollX;
339
+ let lastScrollY = window.scrollY;
340
+
341
+ /** Does this element put anything on the screen, or only position things? */
342
+ const paints = (style) =>
343
+ style.backgroundImage !== "none" ||
344
+ style.backdropFilter !== "none" ||
345
+ !/^rgba\(.*,\s*0(\.0+)?\)$/.test(style.backgroundColor);
346
+
347
+ const keyOf = (el) => {
348
+ const cls = typeof el.className === "string" ? el.className.trim().split(/\\s+/)[0] : "";
349
+ return el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (cls ? "." + cls : "");
350
+ };
351
+
352
+ /**
353
+ * A readable key is not an identity. An SVG holds many rect elements with no
354
+ * id and no class, and on a real page every one of them collapsed into a
355
+ * single track whose interleaved positions looked like a 633px tear. Each
356
+ * element carries its own number; the key is only what the reader is shown.
357
+ */
358
+ let counter = 0;
359
+ const ids = new WeakMap();
360
+ const idOf = (el) => {
361
+ let id = ids.get(el);
362
+ if (id === undefined) {
363
+ id = counter += 1;
364
+ ids.set(el, id);
365
+ }
366
+ return id;
367
+ };
368
+
369
+ const tick =() => {
370
+ frames += 1;
371
+ const at = Math.round(performance.now() - started);
372
+ const now = new Map();
373
+ const moved = [];
374
+ /**
375
+ * Scrolling moves every box on the screen at once. Measured against the
376
+ * viewport, a scroll reads as the whole page animating and as a jump of
377
+ * hundreds of pixels in every track — the first run against a real page
378
+ * reported exactly that. Motion is therefore measured against the document,
379
+ * and only coverage and motion-over-text use where a thing is on screen.
380
+ */
381
+ const sx = window.scrollX;
382
+ const sy = window.scrollY;
383
+ const scrolled = sx !== lastScrollX || sy !== lastScrollY;
384
+ lastScrollX = sx;
385
+ lastScrollY = sy;
386
+
387
+ for (const el of document.querySelectorAll("*")) {
388
+ const style = getComputedStyle(el);
389
+ const transform = style.transform === "none" ? "" : style.transform;
390
+ const opacity = Math.round(parseFloat(style.opacity) * 100) / 100;
391
+ /** Only what can move: GSAP's own, or anything transformed or faded. */
392
+ if (!el._gsap && !transform && opacity === 1) continue;
393
+
394
+ const box = el.getBoundingClientRect();
395
+ const state = {
396
+ x: Math.round((box.x + sx) * 10) / 10,
397
+ y: Math.round((box.y + sy) * 10) / 10,
398
+ w: Math.round(box.width * 10) / 10,
399
+ h: Math.round(box.height * 10) / 10,
400
+ o: opacity,
401
+ t: transform,
402
+ screen: [
403
+ Math.round(box.x * 10) / 10,
404
+ Math.round(box.y * 10) / 10,
405
+ Math.round(box.width * 10) / 10,
406
+ Math.round(box.height * 10) / 10,
407
+ ],
408
+ };
409
+ now.set(el, state);
410
+
411
+ const before = previous.get(el);
412
+ if (
413
+ before &&
414
+ (before.x !== state.x || before.y !== state.y || before.w !== state.w ||
415
+ before.h !== state.h || before.o !== state.o || before.t !== state.t)
416
+ ) {
417
+ moved.push({
418
+ id: idOf(el),
419
+ key: keyOf(el),
420
+ gsap: Boolean(el._gsap),
421
+ box: state.screen,
422
+ x: state.x,
423
+ opacity: state.o,
424
+ paints: paints(style),
425
+ });
426
+ }
427
+ }
428
+
429
+ previous = now;
430
+ if (moved.length) log.push({ at, moved, scrolled });
431
+ requestAnimationFrame(tick);
432
+ };
433
+ requestAnimationFrame(tick);
434
+
435
+ /** The text on the page, read once at the end: what a reader could be reading. */
436
+ const textLines = () => {
437
+ const lines = [];
438
+ for (const el of document.querySelectorAll("p, h1, h2, h3, h4, li, blockquote, figcaption")) {
439
+ const text = (el.textContent || "").trim();
440
+ if (text.length < 20) continue;
441
+ const box = el.getBoundingClientRect();
442
+ if (box.width < 40 || box.height < 10) continue;
443
+ if (box.y > innerHeight || box.y + box.height < 0) continue;
444
+ /**
445
+ * Boxes cannot express stacking. A full-screen backdrop sits under the
446
+ * text and still covers it geometrically; a real page reported one as
447
+ * covering every line on it. Hit testing is the browser's own answer to
448
+ * what is on top, so the covering element is read rather than inferred.
449
+ */
450
+ const onTop = document.elementFromPoint(
451
+ Math.min(innerWidth - 1, Math.max(0, box.x + box.width / 2)),
452
+ Math.min(innerHeight - 1, Math.max(0, box.y + box.height / 2)),
453
+ );
454
+ const covered = onTop && onTop !== el && !el.contains(onTop) ? idOf(onTop) : null;
455
+
456
+ lines.push({
457
+ key: el.tagName.toLowerCase() + (el.id ? "#" + el.id : ""),
458
+ covered,
459
+ box: [
460
+ Math.round(box.x), Math.round(box.y),
461
+ Math.round(box.width), Math.round(box.height),
462
+ ],
463
+ });
464
+ }
465
+ return lines;
466
+ };
467
+
468
+ window.__gsapMotionReview = () => ({
469
+ frames,
470
+ elapsed: Math.round(performance.now() - started),
471
+ viewport: { width: innerWidth, height: innerHeight },
472
+ text: textLines(),
473
+ log,
474
+ });
475
+ return true;
476
+ })()`;
477
+
478
+ // --- Watching ------------------------------------------------------------------
479
+
480
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
481
+
482
+ async function watch(options) {
483
+ const executable = findChrome(options.chrome);
484
+ if (!executable) {
485
+ throw new Error("No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.");
486
+ }
487
+
488
+ const browser = connect(executable);
489
+ const notes = [];
490
+
491
+ try {
492
+ const { targetId } = await browser.send("Target.createTarget", { url: "about:blank" });
493
+ const { sessionId } = await browser.send("Target.attachToTarget", { targetId, flatten: true });
494
+ const call = (method, params) => browser.send(method, params, sessionId);
495
+ const evaluate = async (expression) => {
496
+ const { result } = await call("Runtime.evaluate", {
497
+ expression,
498
+ returnByValue: true,
499
+ awaitPromise: true,
500
+ });
501
+ return result.value;
502
+ };
503
+
504
+ await call("Page.enable");
505
+ await call("Runtime.enable");
506
+ await call("Performance.enable");
507
+
508
+ const features = [];
509
+ if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
510
+ if (options.dark) features.push({ name: "prefers-color-scheme", value: "dark" });
511
+ if (features.length) await call("Emulation.setEmulatedMedia", { features });
512
+ if (options.mobile) {
513
+ await call("Emulation.setDeviceMetricsOverride", {
514
+ width: 390,
515
+ height: 844,
516
+ deviceScaleFactor: 3,
517
+ mobile: true,
518
+ });
519
+ await call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 5 });
520
+ }
521
+ if (options.cpu > 1) await call("Emulation.setCPUThrottlingRate", { rate: options.cpu });
522
+
523
+ await call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDER });
524
+
525
+ const before = await call("Performance.getMetrics");
526
+ await call("Page.navigate", { url: options.url });
527
+ await wait(options.watch);
528
+
529
+ 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`);
542
+ await wait(options.watch);
543
+ }
544
+
545
+ const recorded = await evaluate(
546
+ "window.__gsapMotionReview ? window.__gsapMotionReview() : null",
547
+ );
548
+ const after = await call("Performance.getMetrics");
549
+
550
+ if (!recorded) throw new Error("The recorder never ran. The page may have blocked scripts.");
551
+
552
+ const delta = (name) => {
553
+ const start = before.metrics.find((m) => m.name === name)?.value ?? 0;
554
+ const end = after.metrics.find((m) => m.name === name)?.value ?? 0;
555
+ return end - start;
556
+ };
557
+
558
+ let shot = null;
559
+ if (options.out) {
560
+ mkdirSync(resolve(options.out), { recursive: true });
561
+ const png = await call("Page.captureScreenshot", { format: "png" });
562
+ shot = join(resolve(options.out), "review.png");
563
+ writeFileSync(shot, Buffer.from(png.data, "base64"));
564
+ }
565
+
566
+ return {
567
+ url: options.url,
568
+ recorded,
569
+ layouts: delta("LayoutCount"),
570
+ recalcs: delta("RecalcStyleCount"),
571
+ shot,
572
+ notes,
573
+ };
574
+ } finally {
575
+ await browser.close();
576
+ }
577
+ }
578
+
579
+ // --- Conclusions ---------------------------------------------------------------
580
+
581
+ /** Turn one recording into the findings a reviewer can act on. */
582
+ export function conclude(result) {
583
+ const { recorded } = result;
584
+ const { log, viewport, text, frames, elapsed } = recorded;
585
+
586
+ const busiest = log
587
+ .map((frame) => ({ at: frame.at, share: coverage(frame.moved.map((m) => m.box), viewport) }))
588
+ .sort((a, b) => b.share - a.share)[0] ?? null;
589
+
590
+ /** One track per element, to find the loops that tear. */
591
+ const tracks = new Map();
592
+ const named = new Map();
593
+ for (const frame of log) {
594
+ for (const moved of frame.moved) {
595
+ const id = moved.id ?? moved.key;
596
+ const track = tracks.get(id) ?? [];
597
+ track.push({ at: frame.at, x: moved.x });
598
+ tracks.set(id, track);
599
+ named.set(id, moved.key);
600
+ }
601
+ }
602
+ const scrolledAt = log.filter((frame) => frame.scrolled).map((frame) => frame.at);
603
+ const torn = [];
604
+ for (const [key, track] of tracks) {
605
+ /** Only long-running motion can have a seam; a reveal simply ends. */
606
+ if (track.length < 20) continue;
607
+ const found = afterScroll(seams(track), scrolledAt);
608
+ if (found.length) {
609
+ torn.push({ key: named.get(key) ?? key, seams: found.slice(0, 3), frames: track.length });
610
+ }
611
+ }
612
+
613
+ /** Motion over text, judged at the busiest moment rather than every frame. */
614
+ const atBusiest = busiest ? log.find((frame) => frame.at === busiest.at) : null;
615
+ const overText = atBusiest ? motionOverText(atBusiest.moved, text) : [];
616
+
617
+ const began = starts(log);
618
+ return {
619
+ firstMotion: firstMotion(log),
620
+ frames,
621
+ elapsed,
622
+ fps: elapsed ? Math.round((frames / elapsed) * 1000 * 10) / 10 : null,
623
+ busiest,
624
+ movers: tracks.size,
625
+ gsapMovers: new Set(
626
+ log.flatMap((frame) => frame.moved.filter((m) => m.gsap).map((m) => m.id ?? m.key)),
627
+ ).size,
628
+ seams: seamEvents(torn),
629
+ overText,
630
+ began: began.slice(0, 8),
631
+ spread: began.length > 1 ? began.at(-1).at - began[0].at : 0,
632
+ };
633
+ }
634
+
635
+ // --- Report --------------------------------------------------------------------
636
+
637
+ export function report(result, options) {
638
+ const found = conclude(result);
639
+ const lines = [`Reviewed ${result.url}`];
640
+
641
+ const emulated = [
642
+ options.reduced && "reduced motion",
643
+ options.dark && "dark",
644
+ options.mobile && "mobile 390x844",
645
+ options.cpu > 1 && `CPU ÷${options.cpu}`,
646
+ ].filter(Boolean);
647
+ if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
648
+ for (const note of result.notes) lines.push(` ${note}`);
649
+
650
+ lines.push("", "What moved");
651
+ if (found.firstMotion === null) {
652
+ lines.push(" nothing moved in the whole window — the animation never started,");
653
+ lines.push(" or it needs a scroll or a pointer to begin");
654
+ } else {
655
+ lines.push(` first motion at ${found.firstMotion}ms`);
656
+ lines.push(
657
+ ` ${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)" : ""}`,
659
+ );
660
+ if (found.spread) lines.push(` they started over a span of ${found.spread}ms`);
661
+ }
662
+
663
+ if (found.busiest) {
664
+ lines.push("", "How much moves at once");
665
+ lines.push(
666
+ ` at its busiest, ${Math.round(found.busiest.share * 100)}% of the screen was in motion` +
667
+ ` (${found.busiest.at}ms)`,
668
+ );
669
+ }
670
+
671
+ lines.push("", "Cost");
672
+ lines.push(
673
+ ` ${found.frames} frames over ${found.elapsed}ms${found.fps ? ` (${found.fps}/s)` : ""}`,
674
+ );
675
+ /**
676
+ * Layout counts are deliberately absent. Reading every box every frame is
677
+ * what forces layout, so this run's counts are the sampler's own work as much
678
+ * as the page's — measured on a real page, sampling quadrupled them. `inspect`
679
+ * measures that without sampling, and is the honest place to read it.
680
+ */
681
+ lines.push(" layout work is not measured here — sampling forces it; use inspect for that");
682
+
683
+ if (found.seams.length) {
684
+ lines.push("", "Seams — a step far larger than the motion's own rhythm");
685
+ for (const event of found.seams.slice(0, 6)) {
686
+ const size = event.least === event.most ? `${event.most}px` : `${event.least}-${event.most}px`;
687
+ lines.push(
688
+ ` ${event.at}ms ${event.count === 1 ? event.example : `${event.count} elements (${event.example})`}` +
689
+ ` jumps ${size}`,
690
+ );
691
+ }
692
+ }
693
+
694
+ if (found.overText.length) {
695
+ lines.push("", "Motion over text");
696
+ for (const o of found.overText.slice(0, 5)) {
697
+ lines.push(` ${o.over} covers ${Math.round(o.share * 100)}% of ${o.text}`);
698
+ }
699
+ }
700
+
701
+ if (result.shot) lines.push("", `Frame: ${result.shot}`);
702
+
703
+ lines.push("", "These are measurements. Composition, rhythm and taste are yours to judge.");
704
+ return lines.join("\n");
705
+ }
706
+
707
+ // --- Entry ---------------------------------------------------------------------
708
+
709
+ async function main(argv) {
710
+ const options = parseOptions(argv);
711
+ if (options.errors.length) {
712
+ for (const error of options.errors) console.error(error);
713
+ console.error("\nUsage: node review-motion.mjs <url> [--watch 3000] [--scroll <px|sel>] …");
714
+ process.exit(2);
715
+ }
716
+
717
+ try {
718
+ const result = await watch(options);
719
+ console.log(
720
+ options.json
721
+ ? JSON.stringify({ url: result.url, ...conclude(result) }, null, 2)
722
+ : report(result, options),
723
+ );
724
+ } catch (error) {
725
+ console.error(error.message);
726
+ process.exit(1);
727
+ }
728
+ }
729
+
730
+ const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
731
+ if (invoked) await main(process.argv.slice(2));