@mehshekari/gsap-motion 3.6.0 → 4.2.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 +3 -3
- package/bin/gsap-motion.mjs +85 -8
- package/package.json +2 -2
- package/skill/SKILL.md +15 -22
- package/skill/reference/accessibility.md +15 -0
- package/skill/reference/motion-design.md +19 -14
- package/skill/reference/project-rules.md +19 -0
- package/skill/reference/refine.md +27 -0
- package/skill/reference/routing.md +67 -11
- package/skill/reference/timeline.md +32 -0
- package/skill/scripts/capture-motion.mjs +1 -1
- package/skill/scripts/lib/rules.mjs +315 -0
- package/skill/scripts/patterns.mjs +1 -1
- package/skill/scripts/review-motion.mjs +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npx @mehshekari/gsap-motion audit src # find silent GSAP failures; exits 1
|
|
|
10
10
|
npx @mehshekari/gsap-motion doctor # what is installed, and is your GSAP recent enough
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
No dependencies, no install scripts, Node.js
|
|
13
|
+
No dependencies, no install scripts, Node.js 22 or later.
|
|
14
14
|
|
|
15
15
|
## Commands
|
|
16
16
|
|
|
@@ -24,7 +24,7 @@ No dependencies, no install scripts, Node.js 18 or later.
|
|
|
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
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
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. |
|
|
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. |
|
|
27
|
+
| `doctor` | `--json` for the same report a script can read. 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. |
|
|
28
28
|
|
|
29
29
|
## In CI
|
|
30
30
|
|
|
@@ -32,7 +32,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
32
32
|
build without warning:
|
|
33
33
|
|
|
34
34
|
```json
|
|
35
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@
|
|
35
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@4.2.0 audit src --quiet"
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
package/bin/gsap-motion.mjs
CHANGED
|
@@ -100,6 +100,7 @@ Commands
|
|
|
100
100
|
validated, the reviewer behind canonical
|
|
101
101
|
--json Machine-readable output
|
|
102
102
|
doctor Check Node, the installed skill and your project's GSAP
|
|
103
|
+
--json Machine-readable output
|
|
103
104
|
--version Print the version
|
|
104
105
|
|
|
105
106
|
Docs: https://github.com/mehShekari/gsap-motion`;
|
|
@@ -217,32 +218,88 @@ function runScript(script, args) {
|
|
|
217
218
|
process.exit(result.status ?? 1);
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
function doctor() {
|
|
221
|
+
function doctor(args = []) {
|
|
222
|
+
const json = args.includes("--json");
|
|
221
223
|
const bundled = skillInfo(SKILL_DIR);
|
|
222
224
|
const mark = { ok: green("✓"), warn: yellow("!"), info: dim("·") };
|
|
225
|
+
/**
|
|
226
|
+
* Every check is collected, so `--json` reports the same run the text one
|
|
227
|
+
* does rather than a second, thinner version of it. A half-JSON doctor is
|
|
228
|
+
* worse than none: it invites a script to trust a field nobody filled in.
|
|
229
|
+
*/
|
|
230
|
+
const checks = [];
|
|
223
231
|
const report = (state, message, hint) => {
|
|
232
|
+
checks.push({ state, message, ...(hint ? { hint } : {}) });
|
|
233
|
+
if (json) return;
|
|
224
234
|
console.log(`${mark[state]} ${message}`);
|
|
225
235
|
if (hint && state !== "ok") console.log(` ${dim(hint)}`);
|
|
226
236
|
};
|
|
227
237
|
|
|
228
|
-
|
|
238
|
+
if (!json) {
|
|
239
|
+
console.log(`${bold("gsap-motion")} ${pkg.version}, carrying ${SKILL_NAME} ${bundled.version}\n`);
|
|
240
|
+
}
|
|
229
241
|
|
|
230
242
|
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
231
243
|
report(
|
|
232
|
-
nodeMajor >=
|
|
244
|
+
nodeMajor >= 22 ? "ok" : "warn",
|
|
233
245
|
`Node.js ${process.versions.node}`,
|
|
234
|
-
"The audit scripts need Node.js
|
|
246
|
+
"The audit scripts need Node.js 22 or later.",
|
|
235
247
|
);
|
|
236
248
|
|
|
249
|
+
/**
|
|
250
|
+
* A plugin may already be providing the skill, and then a copy in
|
|
251
|
+
* `.claude/skills` is not a missing install, it is the thing that shadows the
|
|
252
|
+
* plugin and goes stale. Found in a real project: a checked-in copy three
|
|
253
|
+
* releases behind a plugin that was current, while doctor recommended the
|
|
254
|
+
* command that creates exactly that.
|
|
255
|
+
*/
|
|
256
|
+
const pluginProvides = () => {
|
|
257
|
+
for (const root of [process.cwd(), homedir()]) {
|
|
258
|
+
for (const file of ["settings.json", "settings.local.json"]) {
|
|
259
|
+
try {
|
|
260
|
+
const settings = JSON.parse(readFileSync(join(root, ".claude", file), "utf8"));
|
|
261
|
+
const enabled = Object.entries(settings.enabledPlugins ?? {})
|
|
262
|
+
.filter(([, on]) => on)
|
|
263
|
+
.map(([name]) => name)
|
|
264
|
+
.filter((name) => name.startsWith("gsap-motion@"));
|
|
265
|
+
if (enabled.length) return enabled[0];
|
|
266
|
+
} catch {
|
|
267
|
+
/** No settings file, or not JSON: nothing is claimed either way. */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const provider = pluginProvides();
|
|
275
|
+
if (provider) {
|
|
276
|
+
report("ok", `Provided by the ${provider} plugin`);
|
|
277
|
+
}
|
|
278
|
+
|
|
237
279
|
for (const [scope, root, command] of [
|
|
238
280
|
["this project", process.cwd(), `npx ${pkg.name} add`],
|
|
239
281
|
["your user", homedir(), `npx ${pkg.name} add --global`],
|
|
240
282
|
]) {
|
|
241
283
|
const location = join(root, ".claude", "skills", SKILL_NAME);
|
|
242
284
|
const installed = skillInfo(location);
|
|
243
|
-
if (!installed)
|
|
244
|
-
|
|
245
|
-
|
|
285
|
+
if (!installed) {
|
|
286
|
+
/**
|
|
287
|
+
* Recommending an install on top of a plugin is how a second, ageing copy
|
|
288
|
+
* gets created, so with a plugin present this is a fact, not a suggestion.
|
|
289
|
+
*/
|
|
290
|
+
if (provider) report("info", `No separate copy for ${scope}`);
|
|
291
|
+
else report("info", `Not installed for ${scope}`, `${command} installs it at ${location}`);
|
|
292
|
+
} else if (installed.version === bundled.version) {
|
|
293
|
+
report("ok", `Installed for ${scope}: ${installed.version}`);
|
|
294
|
+
} else {
|
|
295
|
+
report(
|
|
296
|
+
"warn",
|
|
297
|
+
`Installed for ${scope}: ${installed.version}`,
|
|
298
|
+
provider
|
|
299
|
+
? `The ${provider} plugin also provides it, and a local copy shadows the plugin. Remove ${location}, or ${command} to update it to ${bundled.version}.`
|
|
300
|
+
: `${command} updates it to ${bundled.version}.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
246
303
|
}
|
|
247
304
|
|
|
248
305
|
const manifest = join(process.cwd(), "package.json");
|
|
@@ -310,6 +367,26 @@ function doctor() {
|
|
|
310
367
|
rules ? "ANIMATION.md found — the skill follows it" : "No ANIMATION.md",
|
|
311
368
|
"Optional. Add one so the skill follows your project's rules; see reference/project-rules.md in the skill.",
|
|
312
369
|
);
|
|
370
|
+
|
|
371
|
+
if (json) {
|
|
372
|
+
console.log(
|
|
373
|
+
JSON.stringify(
|
|
374
|
+
{
|
|
375
|
+
cli: pkg.version,
|
|
376
|
+
skill: bundled.version,
|
|
377
|
+
node: process.versions.node,
|
|
378
|
+
adapters: ORDER.filter((name) => picked.has(name)),
|
|
379
|
+
plugin: provider,
|
|
380
|
+
animationMd: rules,
|
|
381
|
+
/** Nothing here is a verdict on the project; `worst` is only the highest state reached. */
|
|
382
|
+
worst: checks.some((check) => check.state === "warn") ? "warn" : "ok",
|
|
383
|
+
checks,
|
|
384
|
+
},
|
|
385
|
+
null,
|
|
386
|
+
2,
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
313
390
|
}
|
|
314
391
|
|
|
315
392
|
const [command, ...args] = process.argv.slice(2);
|
|
@@ -344,7 +421,7 @@ switch (command) {
|
|
|
344
421
|
runScript("patterns.mjs", args);
|
|
345
422
|
break;
|
|
346
423
|
case "doctor":
|
|
347
|
-
doctor();
|
|
424
|
+
doctor(args);
|
|
348
425
|
break;
|
|
349
426
|
case "--version":
|
|
350
427
|
case "-v":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehshekari/gsap-motion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.2.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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"NOTICE.md"
|
|
36
36
|
],
|
|
37
37
|
"engines": {
|
|
38
|
-
"node": ">=
|
|
38
|
+
"node": ">=22"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"bundle": "node scripts/bundle-skill.mjs",
|
package/skill/SKILL.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
name: gsap-creative-animation
|
|
3
3
|
description: Senior motion engineer for GSAP. Turns a visual or interaction idea into production-ready animation across DOM, SVG, text, images, scroll, pointer, layout and 3D — entrance reveals, scroll-driven sequences, pinned and horizontal scroll, magnetic cursors, kinetic typography, marquees, card stacks, page and modal transitions, loaders and site intros, SVG draw/morph/path, Flip layout transitions, and GSAP orchestration of Three.js/R3F. React and Next.js first; also vanilla JS, Vue, Svelte and Astro. Use whenever the user wants something animated, wants motion to feel cinematic/premium/alive/futuristic, wants an existing animation retimed, audited, or made cheaper, or mentions gsap, ScrollTrigger, DrawSVG, MorphSVG, MotionPath, SplitText, Flip, Observer, انیمیشن, موشن, اسکرول, لودینگ, مورفینگ.
|
|
4
4
|
license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
|
|
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
|
|
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 22+ and nothing else.
|
|
6
6
|
metadata:
|
|
7
|
-
version: "
|
|
7
|
+
version: "4.2.0"
|
|
8
8
|
verified-gsap: "3.15"
|
|
9
9
|
verified-gsap-react: "2.1"
|
|
10
10
|
---
|
|
@@ -60,33 +60,25 @@ Core principles:
|
|
|
60
60
|
|
|
61
61
|
## Commands
|
|
62
62
|
|
|
63
|
-
|
|
|
64
|
-
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
| `ambient [target]` | Build | Marquees, orbits, floating, breathing loops | [preset/marquee.md](preset/marquee.md) |
|
|
72
|
-
| `transition [target]` | Build | Page, route, modal and layout transitions | [reference/flip.md](reference/flip.md) |
|
|
73
|
-
| `loader` | Build | Loading and progress animation | [preset/loader.md](preset/loader.md) |
|
|
74
|
-
| `intro` | Build | Site or section entry sequence | [preset/cinematic.md](preset/cinematic.md) |
|
|
75
|
-
| `three [target]` | Build | GSAP orchestrating Three.js / R3F | [adapter/three.md](adapter/three.md) |
|
|
76
|
-
| `audit [target]` | Evaluate | Check an existing animation: leaks, cost, a11y | [reference/performance.md](reference/performance.md) |
|
|
77
|
-
| `tune [target]` | Refine | Retime, re-ease, fix rhythm — no new motion | [reference/motion-design.md](reference/motion-design.md) |
|
|
78
|
-
| `strip [target]` | Fix | Remove motion that is not earning its place | [reference/motion-design.md](reference/motion-design.md) |
|
|
79
|
-
|
|
80
|
-
Routing:
|
|
63
|
+
| Group | Commands |
|
|
64
|
+
|---|---|
|
|
65
|
+
| **Build** | `create` — the front door: classify the request, then route · `design` — plan only, no code · `build` — implement an approved plan · and the ten domain commands, unchanged: `reveal` `scroll` `text` `svg` `interact` `ambient` `transition` `loader` `intro` `three` |
|
|
66
|
+
| **Evaluate** | `audit` — a verdict, from the source · `inspect` — what a browser saw · `explain` — the architecture · `review` — what the motion measured |
|
|
67
|
+
| **Refine** | `tune` — timing and easing only · `strip` — remove motion that is not earning its place · `evolve` — audit, fix, tune, then offer patterns |
|
|
68
|
+
| **Knowledge** | `patterns` — what this project has proven, by status · `learn` — offer a verified finding as a candidate |
|
|
69
|
+
|
|
70
|
+
`animate` is an alias of `create`. Routing:
|
|
81
71
|
|
|
82
72
|
- **No argument, or no target:** read [routing.md](reference/routing.md) and
|
|
83
73
|
ask what should move. "Add some animation to my homepage" names no target:
|
|
84
74
|
never invent one.
|
|
85
75
|
- **A command, explicit or implied:** load its reference and follow it.
|
|
86
76
|
- **A description** ("make this feel like Apple", "the hero is boring"): that is
|
|
87
|
-
`
|
|
77
|
+
`create`. Classify with routing.md's table, then load what that needs.
|
|
88
78
|
- **Two commands fit:** ask once. A scroll-driven text reveal is `scroll` and
|
|
89
79
|
`text` together, not a coin toss.
|
|
80
|
+
- **`design` stops at the plan.** `build` implements one that was approved, and
|
|
81
|
+
needs it in hand. `evolve` improves what is there and never adds motion.
|
|
90
82
|
|
|
91
83
|
## The pipeline
|
|
92
84
|
|
|
@@ -119,7 +111,8 @@ intent → precedent → targets → trigger → motion language → technique
|
|
|
119
111
|
|
|
120
112
|
For anything beyond a one-line tweak, answer in this shape, each part short:
|
|
121
113
|
|
|
122
|
-
- **Analysis** — what moves, why,
|
|
114
|
+
- **Analysis** — what moves, why, on what trigger, and the intent you read it
|
|
115
|
+
as, with the values that follow.
|
|
123
116
|
- **Motion strategy** — the sequence, as numbered beats.
|
|
124
117
|
- **Architecture** — timeline shape, plugins and why each is needed, refs,
|
|
125
118
|
handlers, what cleans up.
|
|
@@ -54,6 +54,21 @@ mm.add(
|
|
|
54
54
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
**Both conditions are required, and dropping `motion` breaks everything.** The
|
|
58
|
+
callback runs only while at least one named condition matches. With `reduced`
|
|
59
|
+
alone, a visitor who has no preference — nearly all of them — matches nothing,
|
|
60
|
+
so the callback never runs and **the page ships with no animation at all**,
|
|
61
|
+
silently: no error, and every test with reduced motion on still passes. The
|
|
62
|
+
audit reports it as `matchmedia-never-runs`.
|
|
63
|
+
|
|
64
|
+
A reduced-only `add` is still correct when it does not branch — the shape below
|
|
65
|
+
is GSAP's own, and each callback is meant to run only for its own visitors:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
mm.add("(prefers-reduced-motion: no-preference)", () => { /* the animation */ });
|
|
69
|
+
mm.add("(prefers-reduced-motion: reduce)", () => { /* the end states */ });
|
|
70
|
+
```
|
|
71
|
+
|
|
57
72
|
Built inside a `useGSAP` body this needs no explicit teardown — `matchMedia`
|
|
58
73
|
registers with the active context and is reverted with it.
|
|
59
74
|
|
|
@@ -43,14 +43,14 @@ default.
|
|
|
43
43
|
| Ease | Feel | Use |
|
|
44
44
|
|---|---|---|
|
|
45
45
|
| `power2.out` | quick start, soft landing | the default for entrances |
|
|
46
|
-
| `power3.out` / `power4.out` | sharper, more expensive-feeling |
|
|
46
|
+
| `power3.out` / `power4.out` | sharper, more expensive-feeling | see intents |
|
|
47
47
|
| `power2.in` | gathers then leaves | exits |
|
|
48
48
|
| `power2.inOut` | symmetric | moves between two resting states |
|
|
49
49
|
| `none` | mechanical, constant | **anything looping**, scrub, marquee |
|
|
50
|
-
| `back.out(1.4)` | overshoot | playful — never
|
|
50
|
+
| `back.out(1.4)` | overshoot | playful — never two at once |
|
|
51
51
|
| `elastic.out(1, 0.4)` | wobble | rarely; reads as a toy |
|
|
52
|
-
| `sine.inOut` | gentle breath | ambient loops
|
|
53
|
-
| `expo.out` | dramatic arrival | one hero moment
|
|
52
|
+
| `sine.inOut` | gentle breath | ambient loops |
|
|
53
|
+
| `expo.out` | dramatic arrival | one hero moment |
|
|
54
54
|
|
|
55
55
|
Two failures worth naming. **An ease other than `none` on a repeating tween**
|
|
56
56
|
puts a visible stutter at the seam — the loop decelerates into its own restart.
|
|
@@ -119,16 +119,21 @@ takeover — in [routing.md](routing.md).
|
|
|
119
119
|
|
|
120
120
|
## Reading a request
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
|
126
|
-
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
|
|
|
122
|
+
A feeling resolves to values from the tables above. **Say them in Analysis**: a
|
|
123
|
+
reviewer can argue with a reading, not with code.
|
|
124
|
+
|
|
125
|
+
| Intent | Duration | Ease | Competing | Deliberately still |
|
|
126
|
+
|---|---|---|---|---|
|
|
127
|
+
| cinematic | 600–900ms, 1s+ for one hero beat | `power3.out`, `expo.out` once | one | the background |
|
|
128
|
+
| premium | 350–500ms | `power3.out`, no overshoot | few | anything that would bounce |
|
|
129
|
+
| playful | 200–350ms | `back.out(1.4)`, one element | few | every second overshoot |
|
|
130
|
+
| alive | 1s+, looping | `sine.inOut`, `none` | ambient only | the content itself |
|
|
131
|
+
| technical | 200–500ms | `none`, `power2.out`, stepped | a grid, together | curves |
|
|
132
|
+
| organic | 600–900ms, overlapping | `sine.inOut`, soft | several | straight lines |
|
|
133
|
+
|
|
134
|
+
**They compose, one column at a time.** "Cinematic but fast" keeps the ease and
|
|
135
|
+
the focus, and moves down a duration band. [routing.md](routing.md) reads the
|
|
136
|
+
common adjustments.
|
|
132
137
|
|
|
133
138
|
## Validation
|
|
134
139
|
|
|
@@ -151,6 +151,25 @@ It exits 1 on a claim the evidence does not support, so it belongs in `lint`
|
|
|
151
151
|
beside `audit`. Where a candidate has earned `validated`, it says so and leaves
|
|
152
152
|
the promotion to you.
|
|
153
153
|
|
|
154
|
+
### `patterns` and `learn`
|
|
155
|
+
|
|
156
|
+
- **`patterns`** lists what the project has proven, by status, and what has
|
|
157
|
+
earned a promotion. Asked to apply one, it uses `validated` and `canonical`
|
|
158
|
+
entries by default and a `candidate` only when you name it — a candidate is an
|
|
159
|
+
option to mention, not a default to reach for.
|
|
160
|
+
- **`learn`** offers a finding as a `candidate`, once you agree, and writes
|
|
161
|
+
nothing before that. It offers only what it has verified: the claim tested
|
|
162
|
+
with a minimal repro, the audit clean, and the entry carrying its concept,
|
|
163
|
+
what is fixed, when not to use it, its reduced-motion branch, and what it was
|
|
164
|
+
verified against. **An unverified finding is not offered at all**, and neither
|
|
165
|
+
command ever changes a status — `validated` is counted by `patterns check`,
|
|
166
|
+
and `canonical` is a person's signature.
|
|
167
|
+
|
|
168
|
+
A new shape is composed from what exists before anything is invented, and a
|
|
169
|
+
behaviour claim is verified one variable at a time before it is written down.
|
|
170
|
+
Something that turns out to be general is worth an upstream issue; something
|
|
171
|
+
mechanically detectable is worth an audit rule, with its fires and quiet cases.
|
|
172
|
+
|
|
154
173
|
---
|
|
155
174
|
|
|
156
175
|
## Rules worth knowing without a file
|
|
@@ -147,3 +147,30 @@ Verification
|
|
|
147
147
|
|
|
148
148
|
Say what you did not check, too. "Not checked" is information; silence reads as
|
|
149
149
|
"checked and fine", and that is the one thing it must never mean.
|
|
150
|
+
|
|
151
|
+
### "Verified" names what you saw
|
|
152
|
+
|
|
153
|
+
**A check is a measurement, not a verb.** "Verified live", "tested at 390px" and
|
|
154
|
+
"works as expected" say that something was run; they do not say what came back,
|
|
155
|
+
so nobody can tell whether it would have caught the failure.
|
|
156
|
+
|
|
157
|
+
A head-to-head test made the cost concrete. An answer written with this skill
|
|
158
|
+
reported *"verified live, no horizontal overflow at 390px"* — and shipped two
|
|
159
|
+
bugs that a 390px browser shows at a glance: the visual pushed below the fold,
|
|
160
|
+
and a label invisible through its whole first state. Overflow was the one thing
|
|
161
|
+
checked, and it was not what was broken.
|
|
162
|
+
|
|
163
|
+
So a line states the value observed, at a time and a size:
|
|
164
|
+
|
|
165
|
+
| Not this | This |
|
|
166
|
+
|---|---|
|
|
167
|
+
| verified live | badge label opacity 1 at t=0 and at every state label, 1280x800 |
|
|
168
|
+
| works on mobile | 390x844: the visual's top edge at 96px, above the fold; no overflow |
|
|
169
|
+
| reduced motion handled | reduced emulated: heading and lead at end state, no tween ran |
|
|
170
|
+
| animation runs | `review`: first motion at 410ms; 0 elements invisible for the whole window |
|
|
171
|
+
|
|
172
|
+
**Check the failure, not the feature.** A reveal that works is easy to observe;
|
|
173
|
+
the failures that ship are an element that never appears, one that appears too
|
|
174
|
+
late, and one that is off screen at the size you did not look at. Look for those
|
|
175
|
+
by name. And a claim you did not observe is worse than "not checked": it tells
|
|
176
|
+
the reader to stop looking.
|
|
@@ -25,19 +25,34 @@ offer the menu below — grouped, so the question is easy to answer.
|
|
|
25
25
|
|
|
26
26
|
Most requests are a feeling, not a command. Classify, then route.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
**A named feeling routes here, and resolves to values** in
|
|
29
|
+
[motion-design](motion-design.md)'s *Reading a request* — duration, ease, how
|
|
30
|
+
many move at once, and what stays still. They compose, so a request that names
|
|
31
|
+
two is not a coin toss.
|
|
32
|
+
|
|
33
|
+
| What they say | Route to |
|
|
34
|
+
|---|---|
|
|
35
|
+
| "make it cinematic" | `intro` · [cinematic](../preset/cinematic.md) |
|
|
36
|
+
| "make it feel premium" | `tune` · [motion-design](motion-design.md) |
|
|
37
|
+
| "make it feel alive" | `ambient` · [marquee](../preset/marquee.md) |
|
|
38
|
+
| "futuristic / technical" | `text` + `svg` |
|
|
39
|
+
| "liquid / organic" | `svg` · [loader](../preset/loader.md) |
|
|
40
|
+
| "like Apple" | `scroll` · [scroll-storytelling](../preset/scroll-storytelling.md) |
|
|
41
|
+
|
|
42
|
+
**A diagnosis is not a feeling**, and routes on what is actually wrong:
|
|
43
|
+
|
|
44
|
+
| What they say | What it usually is | Route to |
|
|
29
45
|
|---|---|---|
|
|
30
|
-
| "make it
|
|
31
|
-
| "
|
|
32
|
-
| "
|
|
33
|
-
| "make it pop / bolder" | more contrast in timing, not more elements moving | `tune` · [motion-design](motion-design.md) |
|
|
34
|
-
| "like Apple" | scroll-scrubbed, pinned, one idea per screen | `scroll` · [scroll-storytelling](../preset/scroll-storytelling.md) |
|
|
35
|
-
| "futuristic / technical" | scramble, draw-on, mechanical easing, grid reveals | `text` + `svg` |
|
|
36
|
-
| "liquid / organic" | morph, path motion, overlapping soft easing | `svg` · [loader](../preset/loader.md) |
|
|
37
|
-
| "something crazy" | layered techniques with one clear concept | see below |
|
|
46
|
+
| "make it pop / bolder" | contrast in timing, not more elements moving | `tune` |
|
|
47
|
+
| "the hero is boring" | a hierarchy problem before a motion problem | `tune`, then `reveal` |
|
|
48
|
+
| "it feels janky" | cost or trigger | `audit` · [performance](performance.md) |
|
|
38
49
|
| "smooth image reveal" | clip-path or mask, not opacity | [reveal](../preset/reveal.md) |
|
|
39
|
-
| "
|
|
40
|
-
| "
|
|
50
|
+
| "something crazy" | layered techniques, one clear concept | see below |
|
|
51
|
+
| "smoother" | longer and softer — or a cost problem, so check which | `tune`, or `audit` |
|
|
52
|
+
| "snappier" | shorter, less stagger | `tune` |
|
|
53
|
+
| "subtle" | less distance, before less duration | `tune` |
|
|
54
|
+
| "it feels cheap" | linear easing, or one uniform duration | `tune` |
|
|
55
|
+
| "it feels slow" | usually too sequential, not too long: try overlap | `tune` |
|
|
41
56
|
|
|
42
57
|
### "Something crazy"
|
|
43
58
|
|
|
@@ -90,6 +105,47 @@ single hover takes [interaction](interaction.md) and nothing else from its row.
|
|
|
90
105
|
Do not load a domain file "for completeness". Loading `adapter/three.md` for a
|
|
91
106
|
button hover is the failure this map exists to prevent.
|
|
92
107
|
|
|
108
|
+
## `create`, `design` and `build`
|
|
109
|
+
|
|
110
|
+
Three commands for one piece of work, separated so that the expensive part —
|
|
111
|
+
writing code — is not where a disagreement about the idea surfaces.
|
|
112
|
+
|
|
113
|
+
- **`create`** is the front door, and the alias `animate` still reaches it. It
|
|
114
|
+
classifies the request and routes: a domain command if one fits, `design`
|
|
115
|
+
first if the request is large or vague, straight to the work if it is small
|
|
116
|
+
and clear. A one-element hover does not need a plan; a site intro does.
|
|
117
|
+
- **`design`** produces a plan and **no code**: what moves and why, the beats in
|
|
118
|
+
order, the device and reduced-motion strategy, the techniques with the reason
|
|
119
|
+
each is needed, and what it will cost against the project's motion budget.
|
|
120
|
+
It ends by asking whether to build it. Writing code here defeats the point —
|
|
121
|
+
the plan is cheap to argue with, and an implementation is not.
|
|
122
|
+
- **`build`** implements a plan that was approved, and needs one in hand. Given
|
|
123
|
+
no plan it asks for one rather than inventing something to build; given a plan
|
|
124
|
+
it follows it through the pipeline, and says plainly where it departed and
|
|
125
|
+
why. A departure is not a failure — a plan meets the real markup for the first
|
|
126
|
+
time during `build` — but a silent one is.
|
|
127
|
+
|
|
128
|
+
## `evolve`
|
|
129
|
+
|
|
130
|
+
Improve motion that already exists: `audit`, fix what it found, `tune` the
|
|
131
|
+
timing, then offer what was learned. It adds no new motion; that is `create`.
|
|
132
|
+
|
|
133
|
+
Its guards, all four of which exist because an improvement loop is the easiest
|
|
134
|
+
place for an assistant to do damage while feeling useful:
|
|
135
|
+
|
|
136
|
+
- **It needs a target, and keeps to one component or scene.** "Evolve the site"
|
|
137
|
+
is not a request, it is an invitation to churn.
|
|
138
|
+
- **It shows its plan before a large change.** A retime is not a large change;
|
|
139
|
+
restructuring a timeline is.
|
|
140
|
+
- **It stops after three rounds** and reports what is left, rather than trying a
|
|
141
|
+
fourth shape of the same idea.
|
|
142
|
+
- **It leaves approved motion alone.** Something the project accepted is not a
|
|
143
|
+
defect because this run would have done it differently.
|
|
144
|
+
- **It offers at most an `experimental` pattern, and never changes a status.**
|
|
145
|
+
See [project-rules.md](project-rules.md): standing comes from evidence, and a
|
|
146
|
+
loop that promotes its own output is the failure the maturity model exists to
|
|
147
|
+
prevent.
|
|
148
|
+
|
|
93
149
|
## `audit`
|
|
94
150
|
|
|
95
151
|
**Run the checker first.** It is deterministic, it costs a second, and it finds
|
|
@@ -186,3 +186,35 @@ ScrollTrigger's `scrub` is this same mechanism with scroll as the value.
|
|
|
186
186
|
| Entrance replays on loop | `repeat: -1` on the timeline, not the child |
|
|
187
187
|
| Flash of the end state before it runs | `from` without a `set`, outside a layout effect |
|
|
188
188
|
| Two beats drift apart after an edit | both timed by number instead of sharing a label |
|
|
189
|
+
| An element is invisible until much later than it should be | several `from`/`fromTo` on it, each applying its start state when built |
|
|
190
|
+
|
|
191
|
+
### `from` renders when it is built, not when it plays
|
|
192
|
+
|
|
193
|
+
`from` and `fromTo` default to `immediateRender: true`: the start state is
|
|
194
|
+
written to the element **the moment the tween is created**, not when the
|
|
195
|
+
playhead reaches it. With one tween per element that is exactly what you want —
|
|
196
|
+
nothing flashes before the entrance.
|
|
197
|
+
|
|
198
|
+
Build several against the same element and they all write at once, in order, so
|
|
199
|
+
**the last one wins and holds the element there until the playhead arrives.**
|
|
200
|
+
A helper that adds a beat per state is the usual way in: one call site, run in
|
|
201
|
+
a loop, and a label that stays hidden through every state before the last.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const addBeat = (state: string) => {
|
|
205
|
+
tl.addLabel(state).fromTo(
|
|
206
|
+
badge,
|
|
207
|
+
{ autoAlpha: 0, y: 6 },
|
|
208
|
+
{ autoAlpha: 1, y: 0, immediateRender: false }, // written when it plays
|
|
209
|
+
state,
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
gsap.set(badge, { autoAlpha: 1, y: 0 }); // the first state, set once, on purpose
|
|
214
|
+
STATES.forEach(addBeat);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The conflict is per property. Two tweens on one element that set **different**
|
|
218
|
+
properties — one fading in on `opacity`, another turning on `rotationY` — hide
|
|
219
|
+
nothing, and need no change. The audit reports the shape that does hide:
|
|
220
|
+
`stacked-from`.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* It drives Chrome over the DevTools Protocol through a pipe — `stdin` and
|
|
13
13
|
* `stdout` of two extra file descriptors — rather than a WebSocket, because
|
|
14
|
-
* Node
|
|
14
|
+
* Node 22 has no global WebSocket and this skill installs nothing. It uses
|
|
15
15
|
* whatever Chrome the machine already has.
|
|
16
16
|
*
|
|
17
17
|
* Usage, from the project root:
|
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
|
|
48
48
|
/** What each rule says, in one place. */
|
|
49
49
|
const HINT = {
|
|
50
|
+
matchMediaNeverRuns:
|
|
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
|
+
stackedFrom:
|
|
53
|
+
"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`.",
|
|
50
54
|
orphanTween:
|
|
51
55
|
"Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. A helper counts as inside only when every use of it is a call from inside a context.",
|
|
52
56
|
unmanagedInstance:
|
|
@@ -1598,4 +1602,315 @@ export const RULES = [
|
|
|
1598
1602
|
}));
|
|
1599
1603
|
},
|
|
1600
1604
|
},
|
|
1605
|
+
{
|
|
1606
|
+
id: "matchmedia-never-runs",
|
|
1607
|
+
level: "warn",
|
|
1608
|
+
description:
|
|
1609
|
+
"A matchMedia callback that branches on its conditions, when every condition needs reduced motion",
|
|
1610
|
+
/**
|
|
1611
|
+
* `mm.add({ reduced: "(prefers-reduced-motion: reduce)" }, (context) => …)`
|
|
1612
|
+
* whose callback reads `context.conditions`. The callback runs only while a
|
|
1613
|
+
* named condition matches, and a visitor with no preference matches none —
|
|
1614
|
+
* so the branch written for them never runs, and neither does the
|
|
1615
|
+
* animation in it. Found in a head-to-head test on a real hero, where the
|
|
1616
|
+
* whole page shipped without motion.
|
|
1617
|
+
*
|
|
1618
|
+
* The reading of `conditions` is the whole signal. A reduced-only `add`
|
|
1619
|
+
* that does not branch is GSAP's documented pattern for setting end states,
|
|
1620
|
+
* meant to run for those visitors only, and is correct.
|
|
1621
|
+
*/
|
|
1622
|
+
test(file) {
|
|
1623
|
+
const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
|
|
1624
|
+
|
|
1625
|
+
/** Names bound to `gsap.matchMedia()` in this file. */
|
|
1626
|
+
const media = new Set(
|
|
1627
|
+
findAll(
|
|
1628
|
+
file.ast,
|
|
1629
|
+
(node) =>
|
|
1630
|
+
node.type === "VariableDeclarator" &&
|
|
1631
|
+
node.id.type === "Identifier" &&
|
|
1632
|
+
/**
|
|
1633
|
+
* `let tl;` has no initializer, and `calleeName` does not accept
|
|
1634
|
+
* null. Every fixture initialised its declarations, so 133 passing
|
|
1635
|
+
* tests missed this; the first real project threw on it and took
|
|
1636
|
+
* the whole audit down with it.
|
|
1637
|
+
*/
|
|
1638
|
+
Boolean(node.init) &&
|
|
1639
|
+
calleeName(unwrap(node.init)) === "gsap.matchMedia",
|
|
1640
|
+
).map((node) => node.id.name),
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
const onMatchMedia = (call) => {
|
|
1644
|
+
const callee = unwrap(call.callee);
|
|
1645
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
1646
|
+
const object = unwrap(callee.object);
|
|
1647
|
+
if (object?.type === "Identifier") return media.has(object.name);
|
|
1648
|
+
return calleeName(object) === "gsap.matchMedia";
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
/** Does the callback read the conditions it was handed? */
|
|
1652
|
+
const branches = (fn) => {
|
|
1653
|
+
const [param] = fn.params;
|
|
1654
|
+
if (!param) return false;
|
|
1655
|
+
if (param.type === "ObjectPattern") {
|
|
1656
|
+
return param.properties.some((p) => keyName(p) === "conditions");
|
|
1657
|
+
}
|
|
1658
|
+
if (param.type !== "Identifier") return false;
|
|
1659
|
+
return findAll(fn.body, (node) => {
|
|
1660
|
+
const member = unwrap(node);
|
|
1661
|
+
return (
|
|
1662
|
+
member?.type === "MemberExpression" &&
|
|
1663
|
+
!member.computed &&
|
|
1664
|
+
unwrap(member.object)?.type === "Identifier" &&
|
|
1665
|
+
unwrap(member.object).name === param.name &&
|
|
1666
|
+
member.property.name === "conditions"
|
|
1667
|
+
);
|
|
1668
|
+
}).length > 0;
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node))
|
|
1672
|
+
.filter((call) => {
|
|
1673
|
+
const conditions = unwrap(call.arguments[0]);
|
|
1674
|
+
if (conditions?.type !== "ObjectExpression" || !conditions.properties.length) return false;
|
|
1675
|
+
const everyReduces = conditions.properties.every((property) => {
|
|
1676
|
+
const value = staticString(property.value);
|
|
1677
|
+
return typeof value === "string" && REDUCE.test(value);
|
|
1678
|
+
});
|
|
1679
|
+
if (!everyReduces) return false;
|
|
1680
|
+
const callback = resolveFunction(file.ast, call.arguments[1]);
|
|
1681
|
+
return Boolean(callback) && branches(callback);
|
|
1682
|
+
})
|
|
1683
|
+
.map((call) => ({
|
|
1684
|
+
index: call.start,
|
|
1685
|
+
message:
|
|
1686
|
+
"This matchMedia callback branches on its conditions, but every condition needs reduced motion — so it never runs for a visitor without that preference.",
|
|
1687
|
+
hint: HINT.matchMediaNeverRuns,
|
|
1688
|
+
}));
|
|
1689
|
+
},
|
|
1690
|
+
},
|
|
1691
|
+
{
|
|
1692
|
+
id: "stacked-from",
|
|
1693
|
+
level: "warn",
|
|
1694
|
+
description:
|
|
1695
|
+
"A timeline's `from` or `fromTo` built more than once against the same target",
|
|
1696
|
+
/**
|
|
1697
|
+
* A `from` or `fromTo` on a timeline, without `immediateRender: false`,
|
|
1698
|
+
* that is built more than once against the same element: written out
|
|
1699
|
+
* twice, or inside a loop, or inside a helper a loop calls. Each applies
|
|
1700
|
+
* its start state the moment it is built, so the last one wins and holds
|
|
1701
|
+
* the element hidden until the playhead arrives. Found in a real hero,
|
|
1702
|
+
* where one call site in a helper ran six times and a badge stayed
|
|
1703
|
+
* invisible through the whole first state.
|
|
1704
|
+
*
|
|
1705
|
+
* Two shapes must stay quiet, and both sat in that same file. A target that
|
|
1706
|
+
* varies with the loop — `forEach((card) => tl.from(card, …))` — is a new
|
|
1707
|
+
* element each pass. And a tween inside a callback that is only passed
|
|
1708
|
+
* along is not known to run each pass, so it is not reported.
|
|
1709
|
+
*/
|
|
1710
|
+
test(file) {
|
|
1711
|
+
const LOOPS = ["ForStatement", "ForOfStatement", "ForInStatement", "WhileStatement", "DoWhileStatement"];
|
|
1712
|
+
const isLoopCallback = (fn) => {
|
|
1713
|
+
const parent = parentOf(file.ast, fn);
|
|
1714
|
+
return (
|
|
1715
|
+
parent?.type === "CallExpression" &&
|
|
1716
|
+
unwrap(parent.arguments[0]) === fn &&
|
|
1717
|
+
["forEach", "map", "flatMap"].includes(methodName(parent))
|
|
1718
|
+
);
|
|
1719
|
+
};
|
|
1720
|
+
const renders = (call) =>
|
|
1721
|
+
!call.arguments.some((arg) => {
|
|
1722
|
+
const object = unwrap(arg);
|
|
1723
|
+
return (
|
|
1724
|
+
object?.type === "ObjectExpression" &&
|
|
1725
|
+
object.properties.some((p) => {
|
|
1726
|
+
const value = unwrap(p.value);
|
|
1727
|
+
return keyName(p) === "immediateRender" && value?.type === "Literal" && value.value === false;
|
|
1728
|
+
})
|
|
1729
|
+
);
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
const paramNames = (fn) =>
|
|
1733
|
+
fn.params.flatMap((param) =>
|
|
1734
|
+
findAll(param, (n) => n.type === "Identifier").map((n) => n.name),
|
|
1735
|
+
);
|
|
1736
|
+
const loopNames = (loop) => {
|
|
1737
|
+
const left = loop.left ?? loop.init;
|
|
1738
|
+
return left ? findAll(left, (n) => n.type === "Identifier").map((n) => n.name) : [];
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1741
|
+
/** Is this node inside a loop body or a loop callback? */
|
|
1742
|
+
const inLoop = (node) =>
|
|
1743
|
+
ancestorsOf(file.ast, node).some((a) => LOOPS.includes(a.type) || (isFunction(a) && isLoopCallback(a)));
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Walk out from the tween. The names that change each pass are gathered on
|
|
1747
|
+
* the way; the walk stops at the first function that is neither a loop's
|
|
1748
|
+
* own callback nor a named helper some loop calls, because nothing then
|
|
1749
|
+
* says it runs more than once.
|
|
1750
|
+
*/
|
|
1751
|
+
/**
|
|
1752
|
+
* Does a guard on the way out pick exactly one pass? `key === "money"`
|
|
1753
|
+
* does; `key !== "brand"` runs on most passes and still stacks. Only an
|
|
1754
|
+
* equality against a literal, on a name that changes each pass, counts —
|
|
1755
|
+
* the version of a real hero built without the skill guarded its one
|
|
1756
|
+
* per-state tween that way, and a first version of this rule credited it
|
|
1757
|
+
* with a bug it did not have.
|
|
1758
|
+
*/
|
|
1759
|
+
const selectsOnePass = (guards, varying) =>
|
|
1760
|
+
guards.some((test) =>
|
|
1761
|
+
findAll(test, (node) => {
|
|
1762
|
+
if (node.type !== "BinaryExpression" || !["===", "=="].includes(node.operator)) return false;
|
|
1763
|
+
const sides = [unwrap(node.left), unwrap(node.right)];
|
|
1764
|
+
const name = sides.find((side) => side?.type === "Identifier" && varying.has(side.name));
|
|
1765
|
+
const literal = sides.find((side) => side?.type === "Literal");
|
|
1766
|
+
return Boolean(name && literal);
|
|
1767
|
+
}).length > 0,
|
|
1768
|
+
);
|
|
1769
|
+
|
|
1770
|
+
/** Names that change each pass: its variable, and whatever its body declares. */
|
|
1771
|
+
const declaredIn = (body) =>
|
|
1772
|
+
body
|
|
1773
|
+
? findAll(body, (n) => n.type === "VariableDeclarator" && n.id.type === "Identifier").map((n) => n.id.name)
|
|
1774
|
+
: [];
|
|
1775
|
+
|
|
1776
|
+
const repetition = (tween, timeline) => {
|
|
1777
|
+
const varying = new Set();
|
|
1778
|
+
const guards = [];
|
|
1779
|
+
const settle = (loopBody) => {
|
|
1780
|
+
declaredIn(loopBody).forEach((n) => varying.add(n));
|
|
1781
|
+
return selectsOnePass(guards, varying) ? null : varying;
|
|
1782
|
+
};
|
|
1783
|
+
let inner = tween;
|
|
1784
|
+
for (const ancestor of ancestorsOf(file.ast, tween)) {
|
|
1785
|
+
if (contains(ancestor, timeline)) return null;
|
|
1786
|
+
if ((ancestor.type === "IfStatement" || ancestor.type === "ConditionalExpression") && contains(ancestor.consequent, inner)) {
|
|
1787
|
+
guards.push(ancestor.test);
|
|
1788
|
+
}
|
|
1789
|
+
inner = ancestor;
|
|
1790
|
+
if (LOOPS.includes(ancestor.type)) {
|
|
1791
|
+
loopNames(ancestor).forEach((n) => varying.add(n));
|
|
1792
|
+
return settle(ancestor.body);
|
|
1793
|
+
}
|
|
1794
|
+
if (!isFunction(ancestor)) continue;
|
|
1795
|
+
paramNames(ancestor).forEach((n) => varying.add(n));
|
|
1796
|
+
if (isLoopCallback(ancestor)) return settle(ancestor.body);
|
|
1797
|
+
|
|
1798
|
+
const holder = parentOf(file.ast, ancestor);
|
|
1799
|
+
const name =
|
|
1800
|
+
holder?.type === "VariableDeclarator" && holder.id.type === "Identifier"
|
|
1801
|
+
? holder.id.name
|
|
1802
|
+
: ancestor.type === "FunctionDeclaration"
|
|
1803
|
+
? ancestor.id?.name
|
|
1804
|
+
: null;
|
|
1805
|
+
if (!name) return null;
|
|
1806
|
+
const calledInLoop = findAll(
|
|
1807
|
+
file.ast,
|
|
1808
|
+
(n) =>
|
|
1809
|
+
n.type === "CallExpression" &&
|
|
1810
|
+
unwrap(n.callee)?.type === "Identifier" &&
|
|
1811
|
+
unwrap(n.callee).name === name &&
|
|
1812
|
+
inLoop(n),
|
|
1813
|
+
).length > 0;
|
|
1814
|
+
return calledInLoop ? varying : null;
|
|
1815
|
+
}
|
|
1816
|
+
return null;
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* The properties a tween's start state sets, or null when they cannot be
|
|
1821
|
+
* read. "Last one wins" is per property: two from-tweens on one element
|
|
1822
|
+
* that set different properties hide nothing, and a real project relied on
|
|
1823
|
+
* exactly that — a line flying in on `opacity` and `y` while a second tween
|
|
1824
|
+
* turned it on `rotationY`. `autoAlpha` sets opacity, so they count as one.
|
|
1825
|
+
*/
|
|
1826
|
+
const CONFIG = new Set([
|
|
1827
|
+
"duration", "delay", "ease", "stagger", "repeat", "yoyo", "repeatDelay",
|
|
1828
|
+
"immediateRender", "overwrite", "paused", "id", "data", "callbackScope",
|
|
1829
|
+
"onStart", "onUpdate", "onComplete", "onRepeat", "onReverseComplete", "scrollTrigger",
|
|
1830
|
+
]);
|
|
1831
|
+
const startKeys = (tween) => {
|
|
1832
|
+
const vars = unwrap(tween.arguments[1]);
|
|
1833
|
+
if (vars?.type !== "ObjectExpression") return null;
|
|
1834
|
+
if (vars.properties.some((p) => p.type !== "Property" || p.computed)) return null;
|
|
1835
|
+
return new Set(
|
|
1836
|
+
vars.properties
|
|
1837
|
+
.map((p) => keyName(p))
|
|
1838
|
+
.filter((key) => key && !CONFIG.has(key))
|
|
1839
|
+
.map((key) => (key === "autoAlpha" ? "opacity" : key)),
|
|
1840
|
+
);
|
|
1841
|
+
};
|
|
1842
|
+
const overlap = (a, b) => {
|
|
1843
|
+
if (!a || !b) return false;
|
|
1844
|
+
for (const key of a) if (b.has(key)) return true;
|
|
1845
|
+
return false;
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* Two calls that cannot both run: opposite branches of one `if` or one
|
|
1850
|
+
* ternary, or two cases of one `switch`. A real project built a fade-in in
|
|
1851
|
+
* the `if` and a fade-out in the `else` against the same element, and only
|
|
1852
|
+
* ever ran one of them.
|
|
1853
|
+
*/
|
|
1854
|
+
const exclusive = (a, b) => {
|
|
1855
|
+
const upB = new Set(ancestorsOf(file.ast, b));
|
|
1856
|
+
for (const node of ancestorsOf(file.ast, a)) {
|
|
1857
|
+
if (!upB.has(node)) continue;
|
|
1858
|
+
if ((node.type === "IfStatement" || node.type === "ConditionalExpression") && node.alternate) {
|
|
1859
|
+
if (contains(node.alternate, a) !== contains(node.alternate, b)) return true;
|
|
1860
|
+
}
|
|
1861
|
+
if (node.type === "SwitchStatement") {
|
|
1862
|
+
const caseOf = (n) => node.cases.find((c) => contains(c, n));
|
|
1863
|
+
if (caseOf(a) && caseOf(a) !== caseOf(b)) return true;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
return false;
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
const mentions = (text, names) =>
|
|
1870
|
+
[...names].some((name) => new RegExp(`(^|[^\\w$])${name.replace(/\$/g, "\\$")}([^\\w$]|$)`).test(text));
|
|
1871
|
+
|
|
1872
|
+
const findings = [];
|
|
1873
|
+
const timelines = findAll(file.ast, (node) => calleeName(node) === "gsap.timeline");
|
|
1874
|
+
for (const timeline of timelines) {
|
|
1875
|
+
const tweens = timelineLinks(file.ast, timeline).filter(
|
|
1876
|
+
(call) => ["from", "fromTo"].includes(methodName(call)) && call.arguments[0] && renders(call),
|
|
1877
|
+
);
|
|
1878
|
+
const reported = new Set();
|
|
1879
|
+
/** Earlier from-tweens on each target, to compare a later one against. */
|
|
1880
|
+
const earlier = new Map();
|
|
1881
|
+
|
|
1882
|
+
for (const tween of tweens) {
|
|
1883
|
+
const target = unwrap(tween.arguments[0]);
|
|
1884
|
+
const text = file.raw.slice(target.start, target.end).replace(/\s+/g, "");
|
|
1885
|
+
if (reported.has(text)) continue;
|
|
1886
|
+
|
|
1887
|
+
/** One call site built repeatedly always shares its own properties. */
|
|
1888
|
+
const varying = repetition(tween, timeline);
|
|
1889
|
+
const repeatedInPlace = varying !== null && !mentions(text, varying);
|
|
1890
|
+
|
|
1891
|
+
const keys = startKeys(tween);
|
|
1892
|
+
const before = earlier.get(text) ?? [];
|
|
1893
|
+
const writtenTwice = before.some(
|
|
1894
|
+
(other) => !exclusive(other.tween, tween) && overlap(other.keys, keys),
|
|
1895
|
+
);
|
|
1896
|
+
earlier.set(text, [...before, { tween, keys }]);
|
|
1897
|
+
|
|
1898
|
+
if (repeatedInPlace || writtenTwice) {
|
|
1899
|
+
reported.add(text);
|
|
1900
|
+
findings.push({
|
|
1901
|
+
/**
|
|
1902
|
+
* The method's own position, not the call's. A chained call starts
|
|
1903
|
+
* where its chain starts, so `tween.start` pointed a real report 25
|
|
1904
|
+
* lines above the `fromTo` it was about.
|
|
1905
|
+
*/
|
|
1906
|
+
index: unwrap(tween.callee)?.property?.start ?? tween.start,
|
|
1907
|
+
message: `\`${text}\` gets a \`${methodName(tween)}\` more than once on this timeline, and each applies its start state as soon as it is built.`,
|
|
1908
|
+
hint: HINT.stackedFrom,
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
return findings;
|
|
1914
|
+
},
|
|
1915
|
+
},
|
|
1601
1916
|
];
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* needs a name, because those are the two a keen assistant would otherwise
|
|
18
18
|
* grant itself. This command only reads and reports; it never edits the file.
|
|
19
19
|
*
|
|
20
|
-
* Node.js
|
|
20
|
+
* Node.js 22+, no dependencies.
|
|
21
21
|
*/
|
|
22
22
|
import { readFileSync } from "node:fs";
|
|
23
23
|
import { resolve } from "node:path";
|
|
@@ -19,7 +19,7 @@
|
|
|
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
|
+
* Node.js 22+, no dependencies, whatever Chrome the machine already has.
|
|
23
23
|
*/
|
|
24
24
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { join, resolve } from "node:path";
|