@mehshekari/gsap-motion 3.5.0 → 4.1.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 +4 -3
- package/bin/gsap-motion.mjs +93 -8
- package/package.json +2 -2
- package/skill/SKILL.md +19 -26
- package/skill/reference/motion-design.md +19 -14
- package/skill/reference/project-rules.md +85 -0
- package/skill/reference/routing.md +67 -11
- package/skill/scripts/capture-motion.mjs +1 -1
- package/skill/scripts/patterns.mjs +352 -0
- 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
|
|
|
@@ -23,7 +23,8 @@ No dependencies, no install scripts, Node.js 18 or later.
|
|
|
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
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` | `--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. |
|
|
27
28
|
|
|
28
29
|
## In CI
|
|
29
30
|
|
|
@@ -31,7 +32,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
31
32
|
build without warning:
|
|
32
33
|
|
|
33
34
|
```json
|
|
34
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@
|
|
35
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@4.0.0 audit src --quiet"
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
package/bin/gsap-motion.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* npx @mehshekari/gsap-motion inspect <url> [--at ...] [--hover ...] [--reduced]
|
|
11
11
|
* npx @mehshekari/gsap-motion explain <file...|url>
|
|
12
12
|
* npx @mehshekari/gsap-motion review <url> [--scroll ...] [--mobile]
|
|
13
|
+
* npx @mehshekari/gsap-motion patterns check [ANIMATION.md]
|
|
13
14
|
* npx @mehshekari/gsap-motion doctor
|
|
14
15
|
*
|
|
15
16
|
* Installed as a dependency, the same commands run as `gsap-motion <command>`.
|
|
@@ -94,7 +95,12 @@ Commands
|
|
|
94
95
|
--cpu 4 Throttle the CPU by this factor
|
|
95
96
|
--out <dir> Save a frame alongside the measurements
|
|
96
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
|
|
97
102
|
doctor Check Node, the installed skill and your project's GSAP
|
|
103
|
+
--json Machine-readable output
|
|
98
104
|
--version Print the version
|
|
99
105
|
|
|
100
106
|
Docs: https://github.com/mehShekari/gsap-motion`;
|
|
@@ -212,32 +218,88 @@ function runScript(script, args) {
|
|
|
212
218
|
process.exit(result.status ?? 1);
|
|
213
219
|
}
|
|
214
220
|
|
|
215
|
-
function doctor() {
|
|
221
|
+
function doctor(args = []) {
|
|
222
|
+
const json = args.includes("--json");
|
|
216
223
|
const bundled = skillInfo(SKILL_DIR);
|
|
217
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 = [];
|
|
218
231
|
const report = (state, message, hint) => {
|
|
232
|
+
checks.push({ state, message, ...(hint ? { hint } : {}) });
|
|
233
|
+
if (json) return;
|
|
219
234
|
console.log(`${mark[state]} ${message}`);
|
|
220
235
|
if (hint && state !== "ok") console.log(` ${dim(hint)}`);
|
|
221
236
|
};
|
|
222
237
|
|
|
223
|
-
|
|
238
|
+
if (!json) {
|
|
239
|
+
console.log(`${bold("gsap-motion")} ${pkg.version}, carrying ${SKILL_NAME} ${bundled.version}\n`);
|
|
240
|
+
}
|
|
224
241
|
|
|
225
242
|
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
226
243
|
report(
|
|
227
|
-
nodeMajor >=
|
|
244
|
+
nodeMajor >= 22 ? "ok" : "warn",
|
|
228
245
|
`Node.js ${process.versions.node}`,
|
|
229
|
-
"The audit scripts need Node.js
|
|
246
|
+
"The audit scripts need Node.js 22 or later.",
|
|
230
247
|
);
|
|
231
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
|
+
|
|
232
279
|
for (const [scope, root, command] of [
|
|
233
280
|
["this project", process.cwd(), `npx ${pkg.name} add`],
|
|
234
281
|
["your user", homedir(), `npx ${pkg.name} add --global`],
|
|
235
282
|
]) {
|
|
236
283
|
const location = join(root, ".claude", "skills", SKILL_NAME);
|
|
237
284
|
const installed = skillInfo(location);
|
|
238
|
-
if (!installed)
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
}
|
|
241
303
|
}
|
|
242
304
|
|
|
243
305
|
const manifest = join(process.cwd(), "package.json");
|
|
@@ -305,6 +367,26 @@ function doctor() {
|
|
|
305
367
|
rules ? "ANIMATION.md found — the skill follows it" : "No ANIMATION.md",
|
|
306
368
|
"Optional. Add one so the skill follows your project's rules; see reference/project-rules.md in the skill.",
|
|
307
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
|
+
}
|
|
308
390
|
}
|
|
309
391
|
|
|
310
392
|
const [command, ...args] = process.argv.slice(2);
|
|
@@ -335,8 +417,11 @@ switch (command) {
|
|
|
335
417
|
case "review":
|
|
336
418
|
runScript("review-motion.mjs", args);
|
|
337
419
|
break;
|
|
420
|
+
case "patterns":
|
|
421
|
+
runScript("patterns.mjs", args);
|
|
422
|
+
break;
|
|
338
423
|
case "doctor":
|
|
339
|
-
doctor();
|
|
424
|
+
doctor(args);
|
|
340
425
|
break;
|
|
341
426
|
case "--version":
|
|
342
427
|
case "-v":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehshekari/gsap-motion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.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.1.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
|
|
|
@@ -101,8 +93,8 @@ intent → precedent → targets → trigger → motion language → technique
|
|
|
101
93
|
2. **Precedent** — grep for the animation this codebase already has. Its
|
|
102
94
|
trigger point, easing, durations, stagger shape and attribute naming are the
|
|
103
95
|
house language: match them, and differ only for a reason you can state.
|
|
104
|
-
Nothing downstream catches a skipped precedent
|
|
105
|
-
|
|
96
|
+
Nothing downstream catches a skipped precedent: the code compiles, the audit
|
|
97
|
+
passes, and the page reads as two different sites.
|
|
106
98
|
3. **Targets** — which elements move, and can they be reached without a
|
|
107
99
|
positional selector that breaks when the markup moves?
|
|
108
100
|
4. **Trigger** — mount, in-view, scroll position, pointer, click, route, loop.
|
|
@@ -112,14 +104,15 @@ intent → precedent → targets → trigger → motion language → technique
|
|
|
112
104
|
8. **Implementation** — typed, cleaned up, responsive.
|
|
113
105
|
9. **Performance** — transform and opacity; measure before defending anything else.
|
|
114
106
|
10. **Accessibility** — the reduced-motion design, focus, keyboard.
|
|
115
|
-
11. **Validate** — [refine.md](reference/refine.md): audit, fix in order,
|
|
116
|
-
|
|
107
|
+
11. **Validate** — [refine.md](reference/refine.md): audit, fix in order, re-run,
|
|
108
|
+
then the checklist in [motion-design.md](reference/motion-design.md).
|
|
117
109
|
|
|
118
110
|
## Output format
|
|
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.
|
|
@@ -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
|
|
|
@@ -83,10 +83,95 @@ 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
|
+
### `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
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
90
175
|
## Rules worth knowing without a file
|
|
91
176
|
|
|
92
177
|
Each of these fails silently in any project that has the condition.
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -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 22+, 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));
|
|
@@ -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";
|