@mehshekari/gsap-motion 2.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/LICENSE +21 -0
- package/NOTICE.md +24 -0
- package/README.md +59 -0
- package/bin/gsap-motion.mjs +304 -0
- package/package.json +48 -0
- package/skill/LICENSE +21 -0
- package/skill/NOTICE.md +24 -0
- package/skill/SKILL.md +181 -0
- package/skill/example/cinematic-intro.md +202 -0
- package/skill/example/magnetic-button.md +101 -0
- package/skill/example/svg-loader.md +180 -0
- package/skill/preset/card-stack.md +175 -0
- package/skill/preset/cinematic.md +140 -0
- package/skill/preset/cursor.md +184 -0
- package/skill/preset/loader.md +172 -0
- package/skill/preset/magnetic.md +155 -0
- package/skill/preset/marquee.md +177 -0
- package/skill/preset/page-transition.md +151 -0
- package/skill/preset/reveal.md +146 -0
- package/skill/preset/scroll-storytelling.md +176 -0
- package/skill/reference/accessibility.md +143 -0
- package/skill/reference/core-gsap.md +233 -0
- package/skill/reference/flip.md +220 -0
- package/skill/reference/frameworks.md +153 -0
- package/skill/reference/interaction.md +183 -0
- package/skill/reference/motion-design.md +144 -0
- package/skill/reference/performance.md +216 -0
- package/skill/reference/project-rules.md +178 -0
- package/skill/reference/react-nextjs.md +234 -0
- package/skill/reference/routing.md +119 -0
- package/skill/reference/scrolltrigger.md +191 -0
- package/skill/reference/svg.md +201 -0
- package/skill/reference/text.md +150 -0
- package/skill/reference/three-r3f.md +161 -0
- package/skill/reference/timeline.md +147 -0
- package/skill/scripts/audit-gsap.mjs +157 -0
- package/skill/scripts/audit-svg.mjs +358 -0
- package/skill/scripts/lib/rules.mjs +555 -0
- package/skill/scripts/lib/source.mjs +238 -0
- package/skill/template/useScrollScene.ts +126 -0
- package/skill/template/useTimeline.ts +110 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audits an SVG for GSAP animatability, before a single line of animation is
|
|
5
|
+
* written.
|
|
6
|
+
*
|
|
7
|
+
* Every failure this reports is one that is silent at runtime: DrawSVG on a
|
|
8
|
+
* fill-only path animates nothing and throws nothing; MorphSVG between a 4-node
|
|
9
|
+
* and a 40-node path produces a lurch rather than an error; a `<circle>` handed
|
|
10
|
+
* to either simply is not a path. Those are the bugs that cost an hour of
|
|
11
|
+
* staring at a loader that "just doesn't move", so they are checked up front.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node <skill-dir>/scripts/audit-svg.mjs <file.svg> [...]
|
|
15
|
+
* node <skill-dir>/scripts/audit-svg.mjs --morph a.svg b.svg
|
|
16
|
+
* node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg> # flag hue literals too
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { basename } from "node:path";
|
|
20
|
+
|
|
21
|
+
// --- SVG parsing -------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Shape elements GSAP can touch. `<g>` is structure, not geometry. */
|
|
24
|
+
const SHAPES = new Set([
|
|
25
|
+
"path",
|
|
26
|
+
"circle",
|
|
27
|
+
"ellipse",
|
|
28
|
+
"rect",
|
|
29
|
+
"line",
|
|
30
|
+
"polyline",
|
|
31
|
+
"polygon",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Everything but `<path>` must go through `MorphSVGPlugin.convertToPath()`. */
|
|
35
|
+
const NEEDS_CONVERT = new Set([
|
|
36
|
+
"circle",
|
|
37
|
+
"ellipse",
|
|
38
|
+
"rect",
|
|
39
|
+
"line",
|
|
40
|
+
"polyline",
|
|
41
|
+
"polygon",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const ATTR = /([a-zA-Z-]+)\s*=\s*"([^"]*)"|([a-zA-Z-]+)\s*=\s*'([^']*)'/g;
|
|
45
|
+
|
|
46
|
+
function attrs(tagSource) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const m of tagSource.matchAll(ATTR)) {
|
|
49
|
+
out[m[1] ?? m[3]] = m[2] ?? m[4];
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Strips comments and CDATA so their contents never read as markup. */
|
|
55
|
+
const strip = (svg) =>
|
|
56
|
+
svg.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
|
|
57
|
+
|
|
58
|
+
function parse(svg) {
|
|
59
|
+
const clean = strip(svg);
|
|
60
|
+
const root = clean.match(/<svg\b([^>]*)>/i);
|
|
61
|
+
const elements = [];
|
|
62
|
+
|
|
63
|
+
for (const m of clean.matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*?)\/?>/g)) {
|
|
64
|
+
const tag = m[1].toLowerCase();
|
|
65
|
+
if (!SHAPES.has(tag)) continue;
|
|
66
|
+
elements.push({ tag, ...describe(tag, attrs(m[2])) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Illustrator and Figma put their paint in a `<style>` block keyed by
|
|
71
|
+
* generated class names — `.st0 { fill: #ffd400 }` — rather than on the
|
|
72
|
+
* elements. Scanning attributes alone therefore reports a clean SVG for the
|
|
73
|
+
* export format most likely to arrive, which is the one case that has to
|
|
74
|
+
* work.
|
|
75
|
+
*/
|
|
76
|
+
const stylesheets = [...clean.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)]
|
|
77
|
+
.map((m) => m[1])
|
|
78
|
+
.join("\n");
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
root: root ? attrs(root[1]) : null,
|
|
82
|
+
elements,
|
|
83
|
+
stylesheets,
|
|
84
|
+
styleHues: hues(stylesheets),
|
|
85
|
+
/** Generated class names are meaningless and survive into the JSX paste. */
|
|
86
|
+
generatedClasses: [
|
|
87
|
+
...new Set(
|
|
88
|
+
[...clean.matchAll(/class\s*=\s*["']([^"']*)["']/g)]
|
|
89
|
+
.flatMap((m) => m[1].split(/\s+/))
|
|
90
|
+
.filter((c) => /^(st|cls)\d+$/.test(c)),
|
|
91
|
+
),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Per-element facts -------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** Paint that resolves to nothing drawn. */
|
|
99
|
+
const isNone = (v) => !v || v === "none" || v === "transparent";
|
|
100
|
+
|
|
101
|
+
const PATH_COMMAND = /[MmLlHhVvCcSsQqTtAaZz]/g;
|
|
102
|
+
|
|
103
|
+
function describe(tag, a) {
|
|
104
|
+
const style = a.style ?? "";
|
|
105
|
+
const styleOf = (prop) =>
|
|
106
|
+
style.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`))?.[1]?.trim();
|
|
107
|
+
|
|
108
|
+
const stroke = a.stroke ?? styleOf("stroke");
|
|
109
|
+
const fill = a.fill ?? styleOf("fill");
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
id: a.id ?? null,
|
|
113
|
+
stroke: stroke ?? null,
|
|
114
|
+
fill: fill ?? null,
|
|
115
|
+
/**
|
|
116
|
+
* DrawSVG animates the stroke dash, so a shape with no stroke has nothing
|
|
117
|
+
* to draw. An absent `stroke` attribute is not proof of absence — it may be
|
|
118
|
+
* inherited or set in CSS — so that case is reported as unknown, not as a
|
|
119
|
+
* failure.
|
|
120
|
+
*/
|
|
121
|
+
drawable: stroke === undefined ? "unknown" : !isNone(stroke),
|
|
122
|
+
filled: fill === undefined ? "unknown" : !isNone(fill),
|
|
123
|
+
needsConvert: NEEDS_CONVERT.has(tag),
|
|
124
|
+
nodes: tag === "path" ? (a.d?.match(PATH_COMMAND)?.length ?? 0) : null,
|
|
125
|
+
/** `pathLength` rescales the dash maths and quietly breaks DrawSVG offsets. */
|
|
126
|
+
pathLength: a.pathLength ?? null,
|
|
127
|
+
hues: hues([a.fill, a.stroke, a["stop-color"], style].join(" ")),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Hue literals, for projects whose colours must be tokens -----------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Opt-in with `--hues`. Equal channels are greyscale and pass; anything else is
|
|
135
|
+
* a hue. A project that requires colour to come from tokens, so it can follow a
|
|
136
|
+
* theme, wants to hear about these before the paste — an inline SVG arrives
|
|
137
|
+
* whole, and by lint time the literal is in forty attributes, not one. A
|
|
138
|
+
* project without such a rule does not, which is why this is off by default.
|
|
139
|
+
*/
|
|
140
|
+
const HEX = /#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])/g;
|
|
141
|
+
const FN = /(?<![a-zA-Z0-9])(rgba?|hsla?)\(([^()]*)\)/g;
|
|
142
|
+
|
|
143
|
+
function hues(text) {
|
|
144
|
+
const found = new Set();
|
|
145
|
+
|
|
146
|
+
for (const [literal] of text.matchAll(HEX)) {
|
|
147
|
+
const body = literal.slice(1);
|
|
148
|
+
const pairs =
|
|
149
|
+
body.length === 3 || body.length === 4
|
|
150
|
+
? Array.from(body.slice(0, 3), (c) => c + c)
|
|
151
|
+
: body.length === 6 || body.length === 8
|
|
152
|
+
? [0, 2, 4].map((i) => body.slice(i, i + 2))
|
|
153
|
+
: null;
|
|
154
|
+
if (!pairs) continue;
|
|
155
|
+
const [r, g, b] = pairs.map((p) => Number.parseInt(p, 16));
|
|
156
|
+
if (!(r === g && g === b)) found.add(literal);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const [literal, fn, body] of text.matchAll(FN)) {
|
|
160
|
+
if (body.includes("var(")) continue;
|
|
161
|
+
const args = body
|
|
162
|
+
.split(/[,/\s]+/)
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.map((v) => Number.parseFloat(v));
|
|
165
|
+
if (args.length < 3 || args.slice(0, 3).some(Number.isNaN)) continue;
|
|
166
|
+
const hue = fn.startsWith("hsl")
|
|
167
|
+
? args[1] !== 0
|
|
168
|
+
: !(args[0] === args[1] && args[1] === args[2]);
|
|
169
|
+
if (hue) found.add(literal);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return [...found];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Report ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const notes = [];
|
|
178
|
+
const note = (level, message) => notes.push({ level, message });
|
|
179
|
+
|
|
180
|
+
const DASH = "──";
|
|
181
|
+
const EMPTY = "—";
|
|
182
|
+
|
|
183
|
+
function audit(file) {
|
|
184
|
+
const { root, elements, stylesheets, styleHues, generatedClasses } = parse(
|
|
185
|
+
readFileSync(file, "utf8"),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
console.log(`\n${DASH} ${basename(file)} ${DASH}`);
|
|
189
|
+
|
|
190
|
+
if (!root) {
|
|
191
|
+
note("error", `${basename(file)}: no <svg> root element found.`);
|
|
192
|
+
return { elements: [] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!root.viewBox) {
|
|
196
|
+
note(
|
|
197
|
+
"error",
|
|
198
|
+
`${basename(file)}: no viewBox. MotionPath aligns in the SVG's own user units, so without one the coordinate system is the pixel size and the animation breaks the moment the SVG is scaled.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (root.width && /^\d+(px)?$/.test(root.width)) {
|
|
202
|
+
note(
|
|
203
|
+
"warn",
|
|
204
|
+
`${basename(file)}: fixed width="${root.width}". Drop width/height and size it with CSS so the loader scales.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(
|
|
209
|
+
`viewBox: ${root.viewBox ?? "MISSING"} shapes: ${elements.length}`,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
213
|
+
console.log(
|
|
214
|
+
`\n ${pad("tag", 9)}${pad("id", 18)}${pad("stroke", 9)}${pad("fill", 9)}${pad("nodes", 7)}convert`,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
for (const el of elements) {
|
|
218
|
+
const mark = (v) => (v === "unknown" ? "?" : v ? "yes" : "no");
|
|
219
|
+
console.log(
|
|
220
|
+
` ${pad(el.tag, 9)}${pad(el.id ?? EMPTY, 18)}${pad(mark(el.drawable), 9)}${pad(mark(el.filled), 9)}${pad(el.nodes ?? EMPTY, 7)}${el.needsConvert ? "REQUIRED" : EMPTY}`,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const name = `<${el.tag}${el.id ? ` id="${el.id}"` : ""}>`;
|
|
224
|
+
|
|
225
|
+
if (checkHues && el.hues.length) {
|
|
226
|
+
note(
|
|
227
|
+
"error",
|
|
228
|
+
`${name} paints ${el.hues.join(", ")} — a hue literal the theme cannot reach. Use currentColor or one of the project's CSS variables.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (el.pathLength) {
|
|
232
|
+
note(
|
|
233
|
+
"warn",
|
|
234
|
+
`${name} sets pathLength="${el.pathLength}", which rescales the dash maths DrawSVG relies on. Remove it.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- What these shapes make possible ---
|
|
240
|
+
const convertible = elements.filter((e) => e.needsConvert);
|
|
241
|
+
const drawable = elements.filter((e) => e.drawable === true);
|
|
242
|
+
const unknownStroke = elements.filter((e) => e.drawable === "unknown");
|
|
243
|
+
const identified = elements.filter((e) => e.id);
|
|
244
|
+
|
|
245
|
+
console.log("");
|
|
246
|
+
|
|
247
|
+
if (convertible.length) {
|
|
248
|
+
const tags = [...new Set(convertible.map((e) => e.tag))];
|
|
249
|
+
note(
|
|
250
|
+
"action",
|
|
251
|
+
`${convertible.length} non-path shape(s): ${tags.join(", ")}. Run MorphSVGPlugin.convertToPath("${tags.join(", ")}") once before animating, or convert them in the source file.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!drawable.length && !unknownStroke.length) {
|
|
255
|
+
note(
|
|
256
|
+
"warn",
|
|
257
|
+
"Nothing here has a stroke, so DrawSVG has nothing to animate. Either add strokes, or reach for MorphSVG / MotionPath / opacity instead.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (unknownStroke.length) {
|
|
261
|
+
note(
|
|
262
|
+
"info",
|
|
263
|
+
`${unknownStroke.length} shape(s) declare no stroke attribute — they may inherit one from CSS. Confirm in the browser before assuming DrawSVG will work.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (!identified.length) {
|
|
267
|
+
note(
|
|
268
|
+
"action",
|
|
269
|
+
"No element carries an id. Add one to every shape the timeline targets — selector strings scoped to the component root are what keep the hook readable.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (checkHues && styleHues.length) {
|
|
274
|
+
note(
|
|
275
|
+
"error",
|
|
276
|
+
`The <style> block paints ${styleHues.join(", ")} — hue literals the theme cannot reach once the SVG is inlined, and an attribute-only scan of the JSX will not show them. Move the paint onto the elements as currentColor or one of the project's CSS variables.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (generatedClasses.length) {
|
|
280
|
+
note(
|
|
281
|
+
"action",
|
|
282
|
+
`Generated class names from the export tool: ${generatedClasses.join(", ")}. They carry no meaning, they collide between two inlined SVGs on one page, and in JSX they must be renamed to className. Replace them with fill/stroke attributes.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (stylesheets.includes("@") || /\bclass\s*=/.test(stylesheets)) {
|
|
286
|
+
note(
|
|
287
|
+
"info",
|
|
288
|
+
"This SVG styles itself from an internal <style> block. Tailwind does not compile it and the theme cannot reach it — convert it to attributes before inlining.",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { elements };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- Morph pair comparison ---------------------------------------------------
|
|
296
|
+
|
|
297
|
+
function compareMorph(a, b) {
|
|
298
|
+
const paths = (r) => r.elements.filter((e) => e.tag === "path");
|
|
299
|
+
const [pa, pb] = [paths(a), paths(b)];
|
|
300
|
+
|
|
301
|
+
console.log(`\n${DASH} morph pair ${DASH}`);
|
|
302
|
+
|
|
303
|
+
if (pa.length !== 1 || pb.length !== 1) {
|
|
304
|
+
note(
|
|
305
|
+
"warn",
|
|
306
|
+
`Morph compares one path to one path; got ${pa.length} and ${pb.length}. MorphSVG animates a single d attribute, so a multi-path shape needs one tween per path.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const [na, nb] = [pa[0]?.nodes ?? 0, pb[0]?.nodes ?? 0];
|
|
311
|
+
const ratio = Math.max(na, nb) / Math.max(1, Math.min(na, nb));
|
|
312
|
+
console.log(` nodes: ${na} vs ${nb} ratio: ${ratio.toFixed(1)}x`);
|
|
313
|
+
|
|
314
|
+
if (ratio > 3) {
|
|
315
|
+
note(
|
|
316
|
+
"warn",
|
|
317
|
+
`Node counts differ ${ratio.toFixed(1)}x. MorphSVG subdivides the simpler path to match, which is correct but can read as a smear. Try shapeIndex, or map: "complexity".`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
note(
|
|
321
|
+
"info",
|
|
322
|
+
'Morph rotation is decided by shapeIndex. Find the right one with MorphSVGPlugin.findShapeIndex("#from", "#to") in the browser console, then hardcode it — the default is a guess and is usually wrong for a logo.',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- Run ---------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
const argv = process.argv.slice(2);
|
|
329
|
+
const morph = argv.includes("--morph");
|
|
330
|
+
/** Read by `audit()`, which only runs below this line. */
|
|
331
|
+
const checkHues = argv.includes("--hues");
|
|
332
|
+
const files = argv.filter((a) => !a.startsWith("--"));
|
|
333
|
+
|
|
334
|
+
if (!files.length) {
|
|
335
|
+
console.error(
|
|
336
|
+
"Usage: audit-svg.mjs [--hues] <file.svg> [...]\n audit-svg.mjs [--hues] --morph <from.svg> <to.svg>",
|
|
337
|
+
);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const results = files.map(audit);
|
|
342
|
+
if (morph && results.length === 2) compareMorph(results[0], results[1]);
|
|
343
|
+
|
|
344
|
+
const ICON = { error: "✗", warn: "!", action: "→", info: "i" };
|
|
345
|
+
const ORDER = ["error", "warn", "action", "info"];
|
|
346
|
+
|
|
347
|
+
if (notes.length) {
|
|
348
|
+
console.log(`\n${DASH} findings ${DASH}`);
|
|
349
|
+
for (const level of ORDER) {
|
|
350
|
+
for (const n of notes.filter((x) => x.level === level)) {
|
|
351
|
+
console.log(` ${ICON[level]} ${n.message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
console.log("\nNo findings — this SVG is ready to animate.");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
process.exit(notes.some((n) => n.level === "error") ? 1 : 0);
|