@hegemonart/get-design-done 1.51.0 → 1.53.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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +2 -0
  6. package/agents/a11y-mapper.md +30 -1
  7. package/agents/component-taxonomy-mapper.md +30 -1
  8. package/agents/design-context-reviewer-gate.md +102 -0
  9. package/agents/design-context-reviewer.md +186 -0
  10. package/agents/design-debt-crawler.md +60 -60
  11. package/agents/design-research-synthesizer.md +27 -1
  12. package/agents/motion-mapper.md +35 -13
  13. package/agents/token-mapper.md +30 -1
  14. package/agents/visual-hierarchy-mapper.md +30 -1
  15. package/dist/claude-code/.claude/skills/context/SKILL.md +137 -0
  16. package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
  17. package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
  18. package/dist/claude-code/.claude/skills/migrate-context/SKILL.md +123 -0
  19. package/dist/claude-code/.claude/skills/progress/SKILL.md +4 -0
  20. package/package.json +3 -2
  21. package/reference/design-context-schema.md +159 -0
  22. package/reference/design-context-tag-vocab.md +82 -0
  23. package/reference/registry.json +14 -0
  24. package/reference/schemas/design-context.schema.json +130 -0
  25. package/reference/schemas/mcp-gdd-tools.schema.json +34 -1
  26. package/reference/skill-graph.md +3 -1
  27. package/scripts/lib/design-context/extract-a11y.mjs +188 -0
  28. package/scripts/lib/design-context/extract-components.mjs +243 -0
  29. package/scripts/lib/design-context/extract-motion.mjs +248 -0
  30. package/scripts/lib/design-context/extract-tokens.mjs +234 -0
  31. package/scripts/lib/design-context/extract-visual-hierarchy.mjs +178 -0
  32. package/scripts/lib/design-context/integration-map.mjs +251 -0
  33. package/scripts/lib/design-context/merge-fragments.mjs +227 -0
  34. package/scripts/lib/design-context-query.cjs +0 -0
  35. package/scripts/lib/explore-parallel-runner/index.ts +58 -0
  36. package/scripts/lib/explore-parallel-runner/types.ts +58 -0
  37. package/scripts/lib/manifest/skills.json +18 -2
  38. package/scripts/lib/mappers/compute-batches.mjs +625 -0
  39. package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
  40. package/scripts/lib/mappers/incremental-discover.cjs +617 -0
  41. package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
  42. package/scripts/lib/mappers/neighbor-map.mjs +0 -0
  43. package/scripts/lib/mcp-tools-lint/index.cjs +3 -1
  44. package/sdk/cli/index.js +369 -2
  45. package/sdk/fingerprint/classify.cjs +406 -0
  46. package/sdk/fingerprint/index.ts +405 -0
  47. package/sdk/fingerprint/store.cjs +523 -0
  48. package/sdk/index.ts +1 -0
  49. package/sdk/mcp/gdd-mcp/schemas/gdd_context_query.schema.json +60 -0
  50. package/sdk/mcp/gdd-mcp/server.js +474 -158
  51. package/sdk/mcp/gdd-mcp/server.ts +9 -5
  52. package/sdk/mcp/gdd-mcp/tools/gdd_context_query.ts +35 -0
  53. package/sdk/mcp/gdd-mcp/tools/index.ts +18 -13
  54. package/skills/context/SKILL.md +137 -0
  55. package/skills/discover/SKILL.md +7 -1
  56. package/skills/explore/SKILL.md +3 -1
  57. package/skills/migrate-context/SKILL.md +123 -0
  58. package/skills/progress/SKILL.md +4 -0
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lib/design-context/extract-a11y.mjs — Phase 52 (DesignContext graph), executor B.
3
+ //
4
+ // Deterministic, dependency-free accessibility extractor. Regex-scans source
5
+ // for ARIA attributes, role=, keyboard handlers, focus states, semantic
6
+ // landmarks, skip links, and image alt coverage, and emits a Fragment
7
+ // (schema_version 52.0) of `a11y-pattern` nodes with `documented-by` /
8
+ // `referenced-by` edges:
9
+ // - component file --referenced-by--> a11y-pattern (the file exhibits the pattern)
10
+ // - a11y-pattern --documented-by--> wcag:<criterion> node (when a known pattern
11
+ // maps to a WCAG criterion we recognize)
12
+ //
13
+ // Semantics mirror agents/a11y-mapper.md (ARIA / keyboard / focus / landmarks /
14
+ // alt). Static-only — no live browser. Structural pass: summary='' and
15
+ // complexity='moderate' are stubs the LLM/mapper phase fills later. No network,
16
+ // no deps, no top-level Date.now() (stamped in main()).
17
+ //
18
+ // Public API:
19
+ // extract(roots, opts?) -> Fragment (pure)
20
+ // main() -> prints Fragment JSON to stdout
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ const MAPPER = 'a11y-mapper';
27
+ const SCHEMA_VERSION = '52.0';
28
+
29
+ const SCANNABLE_EXT = new Set([
30
+ '.tsx', '.jsx', '.ts', '.js',
31
+ '.vue', '.svelte', '.html', '.htm',
32
+ '.css', '.scss',
33
+ ]);
34
+ const SKIP_DIRS = new Set([
35
+ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
36
+ '.design', '.planning', 'out', '.cache', '.turbo', '.svelte-kit',
37
+ ]);
38
+
39
+ function walk(root) {
40
+ const out = [];
41
+ let st;
42
+ try { st = fs.statSync(root); } catch { return out; }
43
+ if (st.isFile()) {
44
+ if (SCANNABLE_EXT.has(path.extname(root).toLowerCase())) out.push(root);
45
+ return out;
46
+ }
47
+ const stack = [root];
48
+ while (stack.length) {
49
+ const dir = stack.pop();
50
+ let entries;
51
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
52
+ for (const e of entries) {
53
+ const full = path.join(dir, e.name);
54
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(full); }
55
+ else if (e.isFile() && SCANNABLE_EXT.has(path.extname(e.name).toLowerCase())) out.push(full);
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function slug(s) {
62
+ return String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'x';
63
+ }
64
+ function stubNode(id, type, name, extra) {
65
+ return { id, type, name, summary: '', tags: [], complexity: 'moderate', ...extra };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // A11y matchers + WCAG mapping.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const ARIA_ATTR = /\baria-([a-z]+)\s*=/g;
73
+ const ROLE_ATTR = /\brole\s*=\s*["']([a-z]+)["']/g;
74
+ const KEYBOARD = /\b(?:tabIndex|tabindex|onKeyDown|onKeyPress|onKeyUp|on:keydown)\b/g;
75
+ const FOCUS_STATE = /:focus(?:-visible)?|focus-visible:|focus:/g;
76
+ const LANDMARK = /<(header|nav|main|section|article|aside|footer)\b/g;
77
+ const SKIP_LINK = /(?:skip-nav|skip-to-content|skip-link|#main-content)/g;
78
+ const IMG_TAG = /<img\b[^>]*>/gi;
79
+
80
+ // pattern-id -> WCAG criterion it satisfies/relates to.
81
+ const WCAG_FOR_PATTERN = {
82
+ 'aria-attributes': '4.1.2',
83
+ 'role-semantics': '4.1.2',
84
+ 'keyboard-support': '2.1.1',
85
+ 'focus-visible': '2.4.7',
86
+ 'semantic-landmarks': '1.3.1',
87
+ 'skip-link': '2.4.1',
88
+ 'image-alt': '1.1.1',
89
+ };
90
+
91
+ function collect(re, content, mapFn) {
92
+ const out = [];
93
+ re.lastIndex = 0;
94
+ let m;
95
+ while ((m = re.exec(content)) !== null) {
96
+ const v = mapFn(m);
97
+ if (v != null) out.push(v);
98
+ if (m.index === re.lastIndex) re.lastIndex++;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Pure extractor.
105
+ * @param {string[]|string} roots
106
+ * @param {{generatedAt?: string}} [opts]
107
+ * @returns {object} Fragment
108
+ */
109
+ export function extract(roots, opts = {}) {
110
+ const rootList = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
111
+ const nodeMap = new Map();
112
+ const edgeSet = new Map();
113
+
114
+ const addPattern = (patternId, name, extra) => {
115
+ const id = `a11y-pattern:${patternId}`;
116
+ if (!nodeMap.has(id)) nodeMap.set(id, stubNode(id, 'a11y-pattern', name, extra || {}));
117
+ return id;
118
+ };
119
+ const addEdge = (source, target, type, weight) => {
120
+ const key = `${source}--${type}-->${target}`;
121
+ if (!edgeSet.has(key)) edgeSet.set(key, { source, target, type, direction: 'forward', weight });
122
+ };
123
+ // a11y-pattern --documented-by--> wcag criterion node.
124
+ const linkWcag = (patternKey, patternNodeId) => {
125
+ const crit = WCAG_FOR_PATTERN[patternKey];
126
+ if (!crit) return;
127
+ const wid = `pattern:wcag-${slug(crit)}`;
128
+ if (!nodeMap.has(wid)) nodeMap.set(wid, stubNode(wid, 'pattern', `WCAG ${crit}`, { criterion: crit }));
129
+ addEdge(patternNodeId, wid, 'documented-by', 0.8);
130
+ };
131
+
132
+ for (const root of rootList) {
133
+ for (const abs of walk(root)) {
134
+ let content;
135
+ try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
136
+ const ext = path.extname(abs).toLowerCase();
137
+ const baseName = path.basename(abs, ext);
138
+ const compId = `component:${slug(baseName)}`;
139
+
140
+ // Detect each pattern family present in this file.
141
+ const aria = collect(ARIA_ATTR, content, (m) => m[1]);
142
+ const roles = collect(ROLE_ATTR, content, (m) => m[1]);
143
+ const kbd = (content.match(KEYBOARD) || []).length;
144
+ const focus = (content.match(FOCUS_STATE) || []).length;
145
+ const landmarks = collect(LANDMARK, content, (m) => m[1]);
146
+ const skip = (content.match(SKIP_LINK) || []).length;
147
+ const imgs = collect(IMG_TAG, content, (m) => m[0]);
148
+
149
+ const families = [];
150
+ if (aria.length) families.push(['aria-attributes', 'ARIA attributes', { attributes: [...new Set(aria)].slice(0, 12) }]);
151
+ if (roles.length) families.push(['role-semantics', 'ARIA role semantics', { roles: [...new Set(roles)].slice(0, 12) }]);
152
+ if (kbd) families.push(['keyboard-support', 'Keyboard navigation', {}]);
153
+ if (focus) families.push(['focus-visible', 'Focus-visible states', {}]);
154
+ if (landmarks.length) families.push(['semantic-landmarks', 'Semantic landmarks', { landmarks: [...new Set(landmarks)] }]);
155
+ if (skip) families.push(['skip-link', 'Skip link', {}]);
156
+ if (imgs.length) {
157
+ const withAlt = imgs.filter((t) => /\balt\s*=/.test(t)).length;
158
+ families.push(['image-alt', 'Image alt coverage', { images: imgs.length, with_alt: withAlt }]);
159
+ }
160
+
161
+ for (const [key, name, extra] of families) {
162
+ const pid = addPattern(key, name, extra);
163
+ // The file *references* the a11y pattern.
164
+ addEdge(compId, pid, 'referenced-by', 0.5);
165
+ // The pattern is *documented-by* the WCAG criterion.
166
+ linkWcag(key, pid);
167
+ }
168
+ }
169
+ }
170
+
171
+ return {
172
+ schema_version: SCHEMA_VERSION,
173
+ mapper: MAPPER,
174
+ generated_at: opts.generatedAt || '',
175
+ nodes: [...nodeMap.values()],
176
+ edges: [...edgeSet.values()],
177
+ };
178
+ }
179
+
180
+ export function main(argv = process.argv.slice(2)) {
181
+ const roots = argv.length ? argv : [process.cwd()];
182
+ const fragment = extract(roots, { generatedAt: new Date().toISOString() });
183
+ process.stdout.write(JSON.stringify(fragment, null, 2) + '\n');
184
+ }
185
+
186
+ const invokedDirectly =
187
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
188
+ if (invokedDirectly) main();
@@ -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();