@hegemonart/get-design-done 1.50.1 → 1.52.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +93 -0
- package/README.md +4 -0
- package/SKILL.md +4 -1
- package/agents/a11y-mapper.md +30 -1
- package/agents/component-taxonomy-mapper.md +30 -1
- package/agents/design-debt-crawler.md +60 -60
- package/agents/design-reflector.md +33 -0
- package/agents/design-research-synthesizer.md +27 -1
- package/agents/motion-mapper.md +35 -13
- package/agents/token-mapper.md +30 -1
- package/agents/visual-hierarchy-mapper.md +30 -1
- package/dist/claude-code/.claude/skills/apply-reflections/SKILL.md +17 -0
- package/dist/claude-code/.claude/skills/context/SKILL.md +137 -0
- package/dist/claude-code/.claude/skills/extract-learnings/SKILL.md +16 -0
- package/dist/claude-code/.claude/skills/instinct/SKILL.md +111 -0
- package/dist/claude-code/.claude/skills/migrate-context/SKILL.md +123 -0
- package/dist/claude-code/.claude/skills/progress/SKILL.md +4 -0
- package/hooks/gdd-decision-injector.js +115 -6
- package/package.json +3 -2
- package/reference/design-context-schema.md +159 -0
- package/reference/design-context-tag-vocab.md +82 -0
- package/reference/instinct-format.md +120 -0
- package/reference/registry.json +21 -0
- package/reference/schemas/design-context.schema.json +130 -0
- package/reference/schemas/events.schema.json +1 -1
- package/reference/schemas/instinct.schema.json +91 -0
- package/reference/schemas/mcp-gdd-tools.schema.json +34 -1
- package/reference/skill-graph.md +4 -1
- package/scripts/lib/design-context/extract-a11y.mjs +188 -0
- package/scripts/lib/design-context/extract-components.mjs +243 -0
- package/scripts/lib/design-context/extract-motion.mjs +248 -0
- package/scripts/lib/design-context/extract-tokens.mjs +234 -0
- package/scripts/lib/design-context/extract-visual-hierarchy.mjs +178 -0
- package/scripts/lib/design-context/integration-map.mjs +251 -0
- package/scripts/lib/design-context/merge-fragments.mjs +227 -0
- package/scripts/lib/design-context-query.cjs +0 -0
- package/scripts/lib/instinct-store.cjs +677 -0
- package/scripts/lib/manifest/skills.json +24 -0
- package/scripts/lib/mcp-tools-lint/index.cjs +3 -1
- package/sdk/mcp/gdd-mcp/schemas/gdd_context_query.schema.json +60 -0
- package/sdk/mcp/gdd-mcp/server.js +474 -158
- package/sdk/mcp/gdd-mcp/server.ts +9 -5
- package/sdk/mcp/gdd-mcp/tools/gdd_context_query.ts +35 -0
- package/sdk/mcp/gdd-mcp/tools/index.ts +18 -13
- package/skills/apply-reflections/SKILL.md +17 -0
- package/skills/context/SKILL.md +137 -0
- package/skills/extract-learnings/SKILL.md +16 -0
- package/skills/instinct/SKILL.md +111 -0
- package/skills/migrate-context/SKILL.md +123 -0
- package/skills/progress/SKILL.md +4 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/design-context/extract-components.mjs — Phase 52 (DesignContext graph), executor B.
|
|
3
|
+
//
|
|
4
|
+
// Deterministic, dependency-free component extractor. Regex-scans component
|
|
5
|
+
// files (.tsx/.jsx/.vue/.svelte) for component definitions/exports, classifies
|
|
6
|
+
// each as Atomic / Molecular / Organism via import + composition heuristics, and
|
|
7
|
+
// emits a Fragment (schema_version 52.0) of `component` + `variant` + `layer`
|
|
8
|
+
// nodes with `composes` / `extends` edges.
|
|
9
|
+
//
|
|
10
|
+
// Heuristics (mirrors agents/component-taxonomy-mapper.md semantics):
|
|
11
|
+
// - Atomic : composes 0 other detected components
|
|
12
|
+
// - Molecular : composes 1-4 other detected components
|
|
13
|
+
// - Organism : composes 5+ OR is route/page-shaped (Page/Screen/Layout/View name)
|
|
14
|
+
// A `variant` node is emitted when a `variant`/`size`/`intent` prop union or a
|
|
15
|
+
// cva()/tv() variants block is detected; the variant `extends` its component.
|
|
16
|
+
// A `layer` node (subtype Atomic/Molecular/Organism) is emitted per distinct
|
|
17
|
+
// layer and the component `composes`... is captured component->component.
|
|
18
|
+
//
|
|
19
|
+
// Structural pass only: summary='' and complexity='moderate' are stubs the
|
|
20
|
+
// LLM/mapper phase fills later (validator soft-warns). No network, no deps, no
|
|
21
|
+
// top-level Date.now() (stamped in main()).
|
|
22
|
+
//
|
|
23
|
+
// Public API:
|
|
24
|
+
// extract(roots, opts?) -> Fragment (pure)
|
|
25
|
+
// main() -> prints Fragment JSON to stdout
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { pathToFileURL } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const MAPPER = 'component-taxonomy-mapper';
|
|
32
|
+
const SCHEMA_VERSION = '52.0';
|
|
33
|
+
|
|
34
|
+
const COMPONENT_EXT = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
|
35
|
+
const SKIP_DIRS = new Set([
|
|
36
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
|
|
37
|
+
'.design', '.planning', 'out', '.cache', '.turbo', '.svelte-kit',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const ORGANISM_NAME = /(Page|Screen|Layout|View|Dashboard|Shell|Provider)$/;
|
|
41
|
+
|
|
42
|
+
/** Recursively collect component files under `root`. */
|
|
43
|
+
function walk(root) {
|
|
44
|
+
const out = [];
|
|
45
|
+
let st;
|
|
46
|
+
try { st = fs.statSync(root); } catch { return out; }
|
|
47
|
+
if (st.isFile()) {
|
|
48
|
+
if (COMPONENT_EXT.has(path.extname(root).toLowerCase())) out.push(root);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const stack = [root];
|
|
52
|
+
while (stack.length) {
|
|
53
|
+
const dir = stack.pop();
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const full = path.join(dir, e.name);
|
|
58
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(full); }
|
|
59
|
+
else if (e.isFile() && COMPONENT_EXT.has(path.extname(e.name).toLowerCase())) out.push(full);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function slug(s) {
|
|
66
|
+
return String(s).trim().replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'x';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Canonical component id — lowercased so it MATCHES the file-basename-derived
|
|
70
|
+
// `component:<basename>` ids produced by the token / a11y / visual-hierarchy
|
|
71
|
+
// extractors. Component identity is case-insensitive across the graph so that a
|
|
72
|
+
// `referenced-by` edge from a11y (which only knows the filename) recovers
|
|
73
|
+
// against the `component` node defined here (which knows the export name).
|
|
74
|
+
// Cross-fragment edge recovery in merge-fragments.mjs depends on this.
|
|
75
|
+
function componentId(name) {
|
|
76
|
+
return `component:${slug(name).toLowerCase()}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stubNode(id, type, name, extra) {
|
|
80
|
+
return { id, type, name, summary: '', tags: [], complexity: 'moderate', ...extra };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Component detection.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
// React/TS component definitions: function decl, arrow const, class, default export.
|
|
88
|
+
const DEF_FN = /(?:export\s+(?:default\s+)?)?function\s+([A-Z][A-Za-z0-9]*)\s*\(/g;
|
|
89
|
+
const DEF_ARROW = /(?:export\s+)?const\s+([A-Z][A-Za-z0-9]*)\s*(?::\s*[A-Za-z0-9_.<>,\s]+)?=\s*(?:\([^)]*\)|[A-Za-z0-9_]+)\s*=>/g;
|
|
90
|
+
const DEF_CLASS = /(?:export\s+(?:default\s+)?)?class\s+([A-Z][A-Za-z0-9]*)\s+extends\s+(?:React\.)?(?:Pure)?Component\b/g;
|
|
91
|
+
const DEF_MEMO = /(?:export\s+)?const\s+([A-Z][A-Za-z0-9]*)\s*=\s*(?:React\.)?(?:memo|forwardRef)\s*\(/g;
|
|
92
|
+
|
|
93
|
+
// JSX usage of a capitalized tag → a composed child component.
|
|
94
|
+
const JSX_USE = /<([A-Z][A-Za-z0-9]*)[\s/>]/g;
|
|
95
|
+
|
|
96
|
+
// Variant signals.
|
|
97
|
+
const VARIANT_PROP = /\b(?:variant|intent|size|tone|appearance|kind)\??\s*:\s*([^;\n}]+)/g;
|
|
98
|
+
const CVA_BLOCK = /\b(?:cva|tv|cssVariants)\s*\(/g;
|
|
99
|
+
|
|
100
|
+
function collect(re, content, mapFn) {
|
|
101
|
+
const out = [];
|
|
102
|
+
re.lastIndex = 0;
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(content)) !== null) {
|
|
105
|
+
const v = mapFn(m);
|
|
106
|
+
if (v) out.push(v);
|
|
107
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Detect component definitions in one file. Returns array of names. */
|
|
113
|
+
function definedComponents(content, ext, baseName) {
|
|
114
|
+
const names = new Set();
|
|
115
|
+
if (ext === '.tsx' || ext === '.jsx') {
|
|
116
|
+
for (const n of collect(DEF_FN, content, (m) => m[1])) names.add(n);
|
|
117
|
+
for (const n of collect(DEF_ARROW, content, (m) => m[1])) names.add(n);
|
|
118
|
+
for (const n of collect(DEF_CLASS, content, (m) => m[1])) names.add(n);
|
|
119
|
+
for (const n of collect(DEF_MEMO, content, (m) => m[1])) names.add(n);
|
|
120
|
+
}
|
|
121
|
+
// Vue/Svelte (and React fallback): the file itself is a component named by
|
|
122
|
+
// its basename when nothing else was detected.
|
|
123
|
+
if (!names.size && /^[A-Z]/.test(baseName)) names.add(baseName);
|
|
124
|
+
else if (ext === '.vue' || ext === '.svelte') names.add(baseName);
|
|
125
|
+
return [...names];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Detect child components composed inside one file. */
|
|
129
|
+
function composedChildren(content) {
|
|
130
|
+
const kids = new Set();
|
|
131
|
+
for (const n of collect(JSX_USE, content, (m) => m[1])) {
|
|
132
|
+
// Skip well-known intrinsic-ish wrappers that aren't design components.
|
|
133
|
+
if (n === 'Fragment' || n === 'Suspense' || n === 'StrictMode') continue;
|
|
134
|
+
kids.add(n);
|
|
135
|
+
}
|
|
136
|
+
return kids;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function classifyLayer(name, childCount) {
|
|
140
|
+
if (ORGANISM_NAME.test(name) || childCount >= 5) return 'Organism';
|
|
141
|
+
if (childCount >= 1) return 'Molecular';
|
|
142
|
+
return 'Atomic';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pure extractor.
|
|
147
|
+
* @param {string[]|string} roots
|
|
148
|
+
* @param {{generatedAt?: string}} [opts]
|
|
149
|
+
* @returns {object} Fragment
|
|
150
|
+
*/
|
|
151
|
+
export function extract(roots, opts = {}) {
|
|
152
|
+
const rootList = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
|
|
153
|
+
const nodeMap = new Map();
|
|
154
|
+
const edgeSet = new Map();
|
|
155
|
+
const layerSeen = new Set();
|
|
156
|
+
|
|
157
|
+
// Pass 1: collect every defined component name (across files) so composes
|
|
158
|
+
// edges only point at components we actually saw defined somewhere.
|
|
159
|
+
const fileInfos = [];
|
|
160
|
+
const definedGlobal = new Set();
|
|
161
|
+
for (const root of rootList) {
|
|
162
|
+
for (const abs of walk(root)) {
|
|
163
|
+
let content;
|
|
164
|
+
try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
165
|
+
const ext = path.extname(abs).toLowerCase();
|
|
166
|
+
const baseName = path.basename(abs, ext);
|
|
167
|
+
const defs = definedComponents(content, ext, baseName);
|
|
168
|
+
const kids = composedChildren(content);
|
|
169
|
+
const hasCva = CVA_BLOCK.test(content) || VARIANT_PROP.test(content);
|
|
170
|
+
// reset lastIndex side effects of .test on global regexes
|
|
171
|
+
CVA_BLOCK.lastIndex = 0; VARIANT_PROP.lastIndex = 0;
|
|
172
|
+
defs.forEach((d) => definedGlobal.add(d));
|
|
173
|
+
fileInfos.push({ defs, kids, hasCva });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Pass 2: build nodes + edges.
|
|
178
|
+
for (const { defs, kids, hasCva } of fileInfos) {
|
|
179
|
+
for (const name of defs) {
|
|
180
|
+
const id = componentId(name);
|
|
181
|
+
// Children that are themselves defined components (drop self + unknown).
|
|
182
|
+
const realKids = [...kids].filter((k) => k !== name && definedGlobal.has(k));
|
|
183
|
+
const layer = classifyLayer(name, realKids.length);
|
|
184
|
+
|
|
185
|
+
if (!nodeMap.has(id)) {
|
|
186
|
+
nodeMap.set(id, stubNode(id, 'component', name, { layer }));
|
|
187
|
+
} else {
|
|
188
|
+
// Prefer the more-specific (higher) layer if seen twice.
|
|
189
|
+
const order = { Atomic: 0, Molecular: 1, Organism: 2 };
|
|
190
|
+
const prev = nodeMap.get(id);
|
|
191
|
+
if (order[layer] > order[prev.layer]) prev.layer = layer;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// layer node (one per distinct layer) + component depends-on-layer is
|
|
195
|
+
// implicit via the `layer` field; we emit the layer node so the graph has
|
|
196
|
+
// a navigable taxonomy anchor.
|
|
197
|
+
const layerId = `layer:${layer}`;
|
|
198
|
+
if (!layerSeen.has(layerId)) {
|
|
199
|
+
layerSeen.add(layerId);
|
|
200
|
+
nodeMap.set(layerId, stubNode(layerId, 'layer', `${layer} layer`, { subtype: layer }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// composes edges: component -> child component.
|
|
204
|
+
for (const k of realKids) {
|
|
205
|
+
const childId = componentId(k);
|
|
206
|
+
const key = `${id}--composes-->${childId}`;
|
|
207
|
+
if (!edgeSet.has(key)) {
|
|
208
|
+
edgeSet.set(key, { source: id, target: childId, type: 'composes', direction: 'forward', weight: 0.6 });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// variant node + extends edge.
|
|
213
|
+
if (hasCva) {
|
|
214
|
+
const variantId = `variant:${slug(name).toLowerCase()}`;
|
|
215
|
+
if (!nodeMap.has(variantId)) {
|
|
216
|
+
nodeMap.set(variantId, stubNode(variantId, 'variant', `${name} (variants)`, {}));
|
|
217
|
+
}
|
|
218
|
+
const vkey = `${variantId}--extends-->${id}`;
|
|
219
|
+
if (!edgeSet.has(vkey)) {
|
|
220
|
+
edgeSet.set(vkey, { source: variantId, target: id, type: 'extends', direction: 'forward', weight: 0.7 });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
schema_version: SCHEMA_VERSION,
|
|
228
|
+
mapper: MAPPER,
|
|
229
|
+
generated_at: opts.generatedAt || '',
|
|
230
|
+
nodes: [...nodeMap.values()],
|
|
231
|
+
edges: [...edgeSet.values()],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function main(argv = process.argv.slice(2)) {
|
|
236
|
+
const roots = argv.length ? argv : [process.cwd()];
|
|
237
|
+
const fragment = extract(roots, { generatedAt: new Date().toISOString() });
|
|
238
|
+
process.stdout.write(JSON.stringify(fragment, null, 2) + '\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const invokedDirectly =
|
|
242
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
243
|
+
if (invokedDirectly) main();
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/design-context/extract-motion.mjs — Phase 52 (DesignContext graph), executor B.
|
|
3
|
+
//
|
|
4
|
+
// Deterministic, dependency-free motion extractor. Regex-scans source for CSS
|
|
5
|
+
// transitions / @keyframes / animations, Tailwind motion utilities, and JS
|
|
6
|
+
// libs (framer-motion, GSAP) plus easing tokens, and emits a Fragment
|
|
7
|
+
// (schema_version 52.0) of `motion-fragment` + `state` nodes with
|
|
8
|
+
// `transitions-to` edges (state -> state for keyframe-style enter/exit pairs and
|
|
9
|
+
// for AnimatePresence enter/exit; otherwise the motion-fragment links the two
|
|
10
|
+
// canonical states it animates between).
|
|
11
|
+
//
|
|
12
|
+
// Semantics mirror agents/motion-mapper.md (easing / duration / trigger /
|
|
13
|
+
// library). Structural pass only: summary='' and complexity='moderate' are
|
|
14
|
+
// stubs the LLM/mapper phase fills later. No network, no deps, no top-level
|
|
15
|
+
// Date.now() (stamped in main()).
|
|
16
|
+
//
|
|
17
|
+
// Public API:
|
|
18
|
+
// extract(roots, opts?) -> Fragment (pure)
|
|
19
|
+
// main() -> prints Fragment JSON to stdout
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const MAPPER = 'motion-mapper';
|
|
26
|
+
const SCHEMA_VERSION = '52.0';
|
|
27
|
+
|
|
28
|
+
const SCANNABLE_EXT = new Set([
|
|
29
|
+
'.css', '.scss', '.sass', '.less',
|
|
30
|
+
'.tsx', '.jsx', '.ts', '.js', '.mjs',
|
|
31
|
+
'.vue', '.svelte', '.html', '.htm',
|
|
32
|
+
]);
|
|
33
|
+
const SKIP_DIRS = new Set([
|
|
34
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
|
|
35
|
+
'.design', '.planning', 'out', '.cache', '.turbo', '.svelte-kit',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function walk(root) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let st;
|
|
41
|
+
try { st = fs.statSync(root); } catch { return out; }
|
|
42
|
+
if (st.isFile()) {
|
|
43
|
+
if (SCANNABLE_EXT.has(path.extname(root).toLowerCase())) out.push(root);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
const stack = [root];
|
|
47
|
+
while (stack.length) {
|
|
48
|
+
const dir = stack.pop();
|
|
49
|
+
let entries;
|
|
50
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
const full = path.join(dir, e.name);
|
|
53
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(full); }
|
|
54
|
+
else if (e.isFile() && SCANNABLE_EXT.has(path.extname(e.name).toLowerCase())) out.push(full);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function slug(s) {
|
|
61
|
+
return String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'x';
|
|
62
|
+
}
|
|
63
|
+
function stubNode(id, type, name, extra) {
|
|
64
|
+
return { id, type, name, summary: '', tags: [], complexity: 'moderate', ...extra };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Motion matchers.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const KEYFRAMES = /@keyframes\s+([A-Za-z0-9_-]+)/g;
|
|
72
|
+
const CSS_TRANSITION = /transition\s*:\s*([^;}\n]+)/gi;
|
|
73
|
+
const CSS_ANIMATION = /\banimation\s*:\s*([^;}\n]+)/gi;
|
|
74
|
+
const TW_MOTION = /\b(?:animate-[a-z-]+|duration-\d+|ease-(?:linear|in|out|in-out)|transition(?:-[a-z]+)?)\b/g;
|
|
75
|
+
const FRAMER = /\b(?:framer-motion|motion\.(?:div|span|button|ul|li|a|section|nav|header|footer)|useAnimation|useSpring|useTransform|whileHover|whileTap|AnimatePresence|layoutId)\b/g;
|
|
76
|
+
const GSAP = /\b(?:gsap|TweenMax|TimelineMax)\.(?:to|from|fromTo|timeline)\b/g;
|
|
77
|
+
const EASING = /cubic-bezier\(([^)]+)\)/g;
|
|
78
|
+
const REDUCED_MOTION = /prefers-reduced-motion/g;
|
|
79
|
+
|
|
80
|
+
// Trigger heuristics.
|
|
81
|
+
function triggerFor(content, snippet) {
|
|
82
|
+
if (/whileHover|:hover/.test(snippet)) return 'hover';
|
|
83
|
+
if (/whileTap|:active|onClick|onPress/.test(snippet)) return 'press';
|
|
84
|
+
if (/useScroll|animation-timeline|ScrollTimeline|whileInView/.test(content)) return 'scroll';
|
|
85
|
+
if (/AnimatePresence|exit=|mount|unmount/.test(content)) return 'mount-unmount';
|
|
86
|
+
return 'state-change';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collect(re, content, mapFn) {
|
|
90
|
+
const out = [];
|
|
91
|
+
re.lastIndex = 0;
|
|
92
|
+
let m;
|
|
93
|
+
while ((m = re.exec(content)) !== null) {
|
|
94
|
+
const v = mapFn(m);
|
|
95
|
+
if (v) out.push(v);
|
|
96
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure extractor.
|
|
103
|
+
* @param {string[]|string} roots
|
|
104
|
+
* @param {{generatedAt?: string}} [opts]
|
|
105
|
+
* @returns {object} Fragment
|
|
106
|
+
*/
|
|
107
|
+
export function extract(roots, opts = {}) {
|
|
108
|
+
const rootList = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
|
|
109
|
+
const nodeMap = new Map();
|
|
110
|
+
const edgeSet = new Map();
|
|
111
|
+
|
|
112
|
+
const addState = (name) => {
|
|
113
|
+
const id = `state:${slug(name)}`;
|
|
114
|
+
if (!nodeMap.has(id)) nodeMap.set(id, stubNode(id, 'state', name, {}));
|
|
115
|
+
return id;
|
|
116
|
+
};
|
|
117
|
+
const addEdge = (source, target, type, weight) => {
|
|
118
|
+
const key = `${source}--${type}-->${target}`;
|
|
119
|
+
if (!edgeSet.has(key)) edgeSet.set(key, { source, target, type, direction: 'forward', weight });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const root of rootList) {
|
|
123
|
+
for (const abs of walk(root)) {
|
|
124
|
+
let content;
|
|
125
|
+
try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
126
|
+
const ext = path.extname(abs).toLowerCase();
|
|
127
|
+
const baseName = path.basename(abs, ext);
|
|
128
|
+
const reducedMotion = (content.match(REDUCED_MOTION) || []).length > 0;
|
|
129
|
+
|
|
130
|
+
// @keyframes -> motion-fragment + an idle->active transition pair.
|
|
131
|
+
for (const kf of collect(KEYFRAMES, content, (m) => m[1])) {
|
|
132
|
+
const id = `motion-fragment:${slug(kf)}`;
|
|
133
|
+
if (!nodeMap.has(id)) {
|
|
134
|
+
nodeMap.set(id, stubNode(id, 'motion-fragment', kf, {
|
|
135
|
+
library: 'css-keyframes',
|
|
136
|
+
trigger: triggerFor(content, kf),
|
|
137
|
+
reduced_motion_handled: reducedMotion,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
const idle = addState(`${kf}-idle`);
|
|
141
|
+
const active = addState(`${kf}-active`);
|
|
142
|
+
addEdge(idle, active, 'transitions-to', 0.6);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// CSS transition decls -> a motion-fragment named by file+property.
|
|
146
|
+
const transitions = collect(CSS_TRANSITION, content, (m) => m[1].trim());
|
|
147
|
+
transitions.forEach((decl, i) => {
|
|
148
|
+
const prop = decl.split(/\s+/)[0] || 'all';
|
|
149
|
+
const id = `motion-fragment:${slug(baseName)}-transition-${slug(prop)}-${i}`;
|
|
150
|
+
if (!nodeMap.has(id)) {
|
|
151
|
+
nodeMap.set(id, stubNode(id, 'motion-fragment', `${baseName} transition (${prop})`, {
|
|
152
|
+
library: 'css',
|
|
153
|
+
trigger: triggerFor(content, decl),
|
|
154
|
+
reduced_motion_handled: reducedMotion,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// CSS animation shorthand -> motion-fragment.
|
|
160
|
+
collect(CSS_ANIMATION, content, (m) => m[1].trim()).forEach((decl, i) => {
|
|
161
|
+
const id = `motion-fragment:${slug(baseName)}-animation-${i}`;
|
|
162
|
+
if (!nodeMap.has(id)) {
|
|
163
|
+
nodeMap.set(id, stubNode(id, 'motion-fragment', `${baseName} animation`, {
|
|
164
|
+
library: 'css',
|
|
165
|
+
trigger: triggerFor(content, decl),
|
|
166
|
+
reduced_motion_handled: reducedMotion,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Framer-motion usage -> motion-fragment + (if AnimatePresence) enter/exit states.
|
|
172
|
+
const framerHits = (content.match(FRAMER) || []);
|
|
173
|
+
if (framerHits.length) {
|
|
174
|
+
const id = `motion-fragment:${slug(baseName)}-framer`;
|
|
175
|
+
if (!nodeMap.has(id)) {
|
|
176
|
+
nodeMap.set(id, stubNode(id, 'motion-fragment', `${baseName} (framer-motion)`, {
|
|
177
|
+
library: 'framer-motion',
|
|
178
|
+
trigger: triggerFor(content, content),
|
|
179
|
+
reduced_motion_handled: reducedMotion,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
if (/AnimatePresence/.test(content)) {
|
|
183
|
+
const enter = addState(`${baseName}-enter`);
|
|
184
|
+
const exit = addState(`${baseName}-exit`);
|
|
185
|
+
addEdge(enter, exit, 'transitions-to', 0.7);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// GSAP usage -> motion-fragment.
|
|
190
|
+
if ((content.match(GSAP) || []).length) {
|
|
191
|
+
const id = `motion-fragment:${slug(baseName)}-gsap`;
|
|
192
|
+
if (!nodeMap.has(id)) {
|
|
193
|
+
nodeMap.set(id, stubNode(id, 'motion-fragment', `${baseName} (gsap)`, {
|
|
194
|
+
library: 'gsap',
|
|
195
|
+
trigger: triggerFor(content, content),
|
|
196
|
+
reduced_motion_handled: reducedMotion,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Tailwind motion utilities -> a single motion-fragment per file (low
|
|
202
|
+
// weight). Skip pure stylesheets: bare `transition` there is plain CSS,
|
|
203
|
+
// already captured by CSS_TRANSITION above (avoids a duplicate node).
|
|
204
|
+
const isStyle = ext === '.css' || ext === '.scss' || ext === '.sass' || ext === '.less';
|
|
205
|
+
if (!isStyle && (content.match(TW_MOTION) || []).length) {
|
|
206
|
+
const id = `motion-fragment:${slug(baseName)}-tw`;
|
|
207
|
+
if (!nodeMap.has(id)) {
|
|
208
|
+
nodeMap.set(id, stubNode(id, 'motion-fragment', `${baseName} (tailwind motion)`, {
|
|
209
|
+
library: 'tailwind',
|
|
210
|
+
trigger: triggerFor(content, content),
|
|
211
|
+
reduced_motion_handled: reducedMotion,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Easing tokens -> tag onto a per-file easing motion-fragment (informational).
|
|
217
|
+
const easings = collect(EASING, content, (m) => m[1].trim());
|
|
218
|
+
if (easings.length) {
|
|
219
|
+
const id = `motion-fragment:${slug(baseName)}-easing`;
|
|
220
|
+
if (!nodeMap.has(id)) {
|
|
221
|
+
nodeMap.set(id, stubNode(id, 'motion-fragment', `${baseName} easing`, {
|
|
222
|
+
library: 'easing',
|
|
223
|
+
easings: easings.slice(0, 8),
|
|
224
|
+
reduced_motion_handled: reducedMotion,
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
schema_version: SCHEMA_VERSION,
|
|
233
|
+
mapper: MAPPER,
|
|
234
|
+
generated_at: opts.generatedAt || '',
|
|
235
|
+
nodes: [...nodeMap.values()],
|
|
236
|
+
edges: [...edgeSet.values()],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function main(argv = process.argv.slice(2)) {
|
|
241
|
+
const roots = argv.length ? argv : [process.cwd()];
|
|
242
|
+
const fragment = extract(roots, { generatedAt: new Date().toISOString() });
|
|
243
|
+
process.stdout.write(JSON.stringify(fragment, null, 2) + '\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const invokedDirectly =
|
|
247
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
248
|
+
if (invokedDirectly) main();
|