@hegemonart/get-design-done 1.51.0 → 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 +47 -0
- package/README.md +2 -0
- package/SKILL.md +2 -0
- 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-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/context/SKILL.md +137 -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/package.json +3 -2
- package/reference/design-context-schema.md +159 -0
- package/reference/design-context-tag-vocab.md +82 -0
- package/reference/registry.json +14 -0
- package/reference/schemas/design-context.schema.json +130 -0
- package/reference/schemas/mcp-gdd-tools.schema.json +34 -1
- package/reference/skill-graph.md +3 -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/manifest/skills.json +16 -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/context/SKILL.md +137 -0
- package/skills/migrate-context/SKILL.md +123 -0
- package/skills/progress/SKILL.md +4 -0
|
@@ -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();
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/design-context/extract-tokens.mjs — Phase 52 (DesignContext graph), executor B.
|
|
3
|
+
//
|
|
4
|
+
// Deterministic, dependency-free token extractor. Regex-scans source roots for
|
|
5
|
+
// design tokens (color / spacing / typography / radius / shadow) and emits a
|
|
6
|
+
// Fragment (schema_version 52.0) to stdout. The structural pass fills
|
|
7
|
+
// id/type/name/subtype + `uses-token` edges (component -> token, where a
|
|
8
|
+
// component file references a token value); the LLM/mapper phase fills
|
|
9
|
+
// summary/tags/complexity later — so summary defaults to '' and complexity to
|
|
10
|
+
// 'moderate' (the validator soft-warns on these stubs).
|
|
11
|
+
//
|
|
12
|
+
// Idiom mirrors scripts/lib/detect/engine.cjs walk(): dep-free fs recursion
|
|
13
|
+
// with a SKIP_DIRS set, scannable-extension filter, regex content scan. No
|
|
14
|
+
// network, no optional deps, no top-level Date.now() (stamped only in main()).
|
|
15
|
+
//
|
|
16
|
+
// Public API:
|
|
17
|
+
// extract(roots, opts?) -> Fragment (pure; opts.generatedAt to stamp)
|
|
18
|
+
// main() -> prints Fragment JSON to stdout
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const MAPPER = 'token-mapper';
|
|
25
|
+
const SCHEMA_VERSION = '52.0';
|
|
26
|
+
|
|
27
|
+
const SCANNABLE_EXT = new Set([
|
|
28
|
+
'.css', '.scss', '.sass', '.less',
|
|
29
|
+
'.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs',
|
|
30
|
+
'.vue', '.svelte', '.html', '.htm',
|
|
31
|
+
]);
|
|
32
|
+
const SKIP_DIRS = new Set([
|
|
33
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage',
|
|
34
|
+
'.design', '.planning', 'out', '.cache', '.turbo', '.svelte-kit',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// A file is treated as a "component" file (a `uses-token` edge source) when it
|
|
38
|
+
// is a component-bearing extension. CSS/SCSS are token *definition* surfaces,
|
|
39
|
+
// not components, so they never originate `uses-token` edges.
|
|
40
|
+
const COMPONENT_EXT = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
|
41
|
+
|
|
42
|
+
/** Recursively collect scannable files under `root` (a file or dir). */
|
|
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 (SCANNABLE_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() && SCANNABLE_EXT.has(path.extname(e.name).toLowerCase())) out.push(full);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Slugify an arbitrary token value/name into an id-safe fragment. */
|
|
66
|
+
function slug(s) {
|
|
67
|
+
return String(s)
|
|
68
|
+
.trim()
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
71
|
+
.replace(/^-+|-+$/g, '')
|
|
72
|
+
.slice(0, 60) || 'x';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stubNode(id, type, name, extra) {
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
type,
|
|
79
|
+
name,
|
|
80
|
+
summary: '', // LLM phase fills this
|
|
81
|
+
tags: [],
|
|
82
|
+
complexity: 'moderate', // LLM phase refines this
|
|
83
|
+
...extra,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Token matchers. Each returns { subtype, name, value } objects per match.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
const HEX = /#[0-9a-fA-F]{3,8}\b/g;
|
|
92
|
+
const FUNC_COLOR = /\b(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^)]*\)/g;
|
|
93
|
+
const CSS_VAR_DEF = /(--[a-z][a-z0-9-]*)\s*:\s*([^;}\n]+)/gi;
|
|
94
|
+
const TW_COLOR = /\b(?:bg|text|border|ring|fill|stroke|from|to|via)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/g;
|
|
95
|
+
|
|
96
|
+
const SPACING_DECL = /\b(?:padding|margin|gap|inset|top|right|bottom|left|row-gap|column-gap)(?:-[a-z]+)?\s*:\s*(-?[0-9]*\.?[0-9]+(?:px|rem|em))\b/gi;
|
|
97
|
+
const TW_SPACING = /\b(?:p|m|gap|space|inset)(?:[xytrbl])?-(?:px|0|0\.5|\d{1,2}(?:\.5)?)\b/g;
|
|
98
|
+
|
|
99
|
+
const FONT_SIZE = /font-size\s*:\s*([0-9]*\.?[0-9]+(?:px|rem|em))/gi;
|
|
100
|
+
const FONT_WEIGHT = /font-weight\s*:\s*([1-9]00|normal|bold|bolder|lighter)\b/gi;
|
|
101
|
+
const FONT_FAMILY = /font-family\s*:\s*([^;}\n]+)/gi;
|
|
102
|
+
const TW_TEXT = /\btext-(?:xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)\b/g;
|
|
103
|
+
const TW_FONTWEIGHT = /\bfont-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)\b/g;
|
|
104
|
+
|
|
105
|
+
const RADIUS_DECL = /border-radius\s*:\s*([0-9]*\.?[0-9]+(?:px|rem|em|%)|9999px|50%)/gi;
|
|
106
|
+
const TW_RADIUS = /\brounded(?:-(?:none|sm|md|lg|xl|2xl|3xl|full))?\b/g;
|
|
107
|
+
|
|
108
|
+
const SHADOW_DECL = /box-shadow\s*:\s*([^;}\n]+)/gi;
|
|
109
|
+
const TW_SHADOW = /\bshadow(?:-(?:sm|md|lg|xl|2xl|inner|none))?\b/g;
|
|
110
|
+
|
|
111
|
+
function collect(re, content, mapFn) {
|
|
112
|
+
const out = [];
|
|
113
|
+
re.lastIndex = 0;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = re.exec(content)) !== null) {
|
|
116
|
+
const t = mapFn(m);
|
|
117
|
+
if (t) out.push(t);
|
|
118
|
+
if (m.index === re.lastIndex) re.lastIndex++; // guard zero-width
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Extract every token reference from one file's content (subtype-tagged). */
|
|
124
|
+
function tokensInContent(content, ext) {
|
|
125
|
+
const found = [];
|
|
126
|
+
const isStyle = ext === '.css' || ext === '.scss' || ext === '.sass' || ext === '.less';
|
|
127
|
+
|
|
128
|
+
// --- color ---
|
|
129
|
+
// CSS custom properties whose value is colorish → name them by the var.
|
|
130
|
+
for (const m of collect(CSS_VAR_DEF, content, (mm) => mm)) {
|
|
131
|
+
const [, varName, rawVal] = m;
|
|
132
|
+
const val = rawVal.trim();
|
|
133
|
+
if (/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(/.test(val)) {
|
|
134
|
+
found.push({ subtype: 'color', name: varName, value: val });
|
|
135
|
+
} else if (/(?:px|rem|em)\b/.test(val) && /^(?:[0-9.]+(?:px|rem|em)\s*)+$/.test(val)) {
|
|
136
|
+
found.push({ subtype: 'spacing', name: varName, value: val });
|
|
137
|
+
} else if (/(?:[0-9]{3}|bold|normal)/.test(val) && /font|weight|size/i.test(varName)) {
|
|
138
|
+
found.push({ subtype: 'typography', name: varName, value: val });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const v of collect(HEX, content, (mm) => mm[0])) found.push({ subtype: 'color', name: v, value: v });
|
|
142
|
+
for (const v of collect(FUNC_COLOR, content, (mm) => mm[0])) found.push({ subtype: 'color', name: v, value: v });
|
|
143
|
+
for (const v of collect(TW_COLOR, content, (mm) => mm[0])) found.push({ subtype: 'color', name: v, value: v });
|
|
144
|
+
|
|
145
|
+
// --- spacing ---
|
|
146
|
+
for (const m of collect(SPACING_DECL, content, (mm) => mm)) found.push({ subtype: 'spacing', name: m[1], value: m[1] });
|
|
147
|
+
for (const v of collect(TW_SPACING, content, (mm) => mm[0])) found.push({ subtype: 'spacing', name: v, value: v });
|
|
148
|
+
|
|
149
|
+
// --- typography ---
|
|
150
|
+
for (const m of collect(FONT_SIZE, content, (mm) => mm)) found.push({ subtype: 'typography', name: m[1], value: m[1] });
|
|
151
|
+
for (const m of collect(FONT_WEIGHT, content, (mm) => mm)) found.push({ subtype: 'typography', name: `weight-${m[1]}`, value: m[1] });
|
|
152
|
+
for (const m of collect(FONT_FAMILY, content, (mm) => mm)) found.push({ subtype: 'typography', name: m[1].trim().split(',')[0].replace(/['"]/g, ''), value: m[1].trim() });
|
|
153
|
+
for (const v of collect(TW_TEXT, content, (mm) => mm[0])) found.push({ subtype: 'typography', name: v, value: v });
|
|
154
|
+
for (const v of collect(TW_FONTWEIGHT, content, (mm) => mm[0])) found.push({ subtype: 'typography', name: v, value: v });
|
|
155
|
+
|
|
156
|
+
// --- radius ---
|
|
157
|
+
for (const m of collect(RADIUS_DECL, content, (mm) => mm)) found.push({ subtype: 'radius', name: m[1], value: m[1] });
|
|
158
|
+
for (const v of collect(TW_RADIUS, content, (mm) => mm[0])) found.push({ subtype: 'radius', name: v, value: v });
|
|
159
|
+
|
|
160
|
+
// --- shadow ---
|
|
161
|
+
for (const m of collect(SHADOW_DECL, content, (mm) => mm)) found.push({ subtype: 'shadow', name: m[1].trim().slice(0, 40), value: m[1].trim() });
|
|
162
|
+
for (const v of collect(TW_SHADOW, content, (mm) => mm[0])) found.push({ subtype: 'shadow', name: v, value: v });
|
|
163
|
+
|
|
164
|
+
// Suppress raw-utility noise in pure style files is unnecessary; dedupe by id
|
|
165
|
+
// happens at the fragment level. `isStyle` retained for future weighting.
|
|
166
|
+
void isStyle;
|
|
167
|
+
return found;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Pure extractor.
|
|
172
|
+
* @param {string[]|string} roots one or more file/dir roots
|
|
173
|
+
* @param {{generatedAt?: string}} [opts]
|
|
174
|
+
* @returns {{schema_version:string, mapper:string, generated_at:string, nodes:object[], edges:object[]}}
|
|
175
|
+
*/
|
|
176
|
+
export function extract(roots, opts = {}) {
|
|
177
|
+
const rootList = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
|
|
178
|
+
const nodeMap = new Map(); // id -> node
|
|
179
|
+
const edgeSet = new Map(); // edgeKey -> edge (dedupe)
|
|
180
|
+
|
|
181
|
+
for (const root of rootList) {
|
|
182
|
+
for (const abs of walk(root)) {
|
|
183
|
+
let content;
|
|
184
|
+
try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
185
|
+
const ext = path.extname(abs).toLowerCase();
|
|
186
|
+
const hits = tokensInContent(content, ext);
|
|
187
|
+
if (!hits.length) continue;
|
|
188
|
+
|
|
189
|
+
// Component-file id (only for `uses-token` edge origin).
|
|
190
|
+
const compId = COMPONENT_EXT.has(ext)
|
|
191
|
+
? `component:${slug(path.basename(abs, ext))}`
|
|
192
|
+
: null;
|
|
193
|
+
|
|
194
|
+
for (const h of hits) {
|
|
195
|
+
const id = `token:${h.subtype}:${slug(h.name)}`;
|
|
196
|
+
if (!nodeMap.has(id)) {
|
|
197
|
+
nodeMap.set(id, stubNode(id, 'token', h.name, { subtype: h.subtype, value: h.value }));
|
|
198
|
+
}
|
|
199
|
+
if (compId) {
|
|
200
|
+
const key = `${compId}->${id}`;
|
|
201
|
+
if (!edgeSet.has(key)) {
|
|
202
|
+
edgeSet.set(key, {
|
|
203
|
+
source: compId,
|
|
204
|
+
target: id,
|
|
205
|
+
type: 'uses-token',
|
|
206
|
+
direction: 'forward',
|
|
207
|
+
weight: 0.5,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
schema_version: SCHEMA_VERSION,
|
|
217
|
+
mapper: MAPPER,
|
|
218
|
+
generated_at: opts.generatedAt || '',
|
|
219
|
+
nodes: [...nodeMap.values()],
|
|
220
|
+
edges: [...edgeSet.values()],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** CLI entry: roots from argv (default cwd), stamp generated_at, print JSON. */
|
|
225
|
+
export function main(argv = process.argv.slice(2)) {
|
|
226
|
+
const roots = argv.length ? argv : [process.cwd()];
|
|
227
|
+
const fragment = extract(roots, { generatedAt: new Date().toISOString() });
|
|
228
|
+
process.stdout.write(JSON.stringify(fragment, null, 2) + '\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ESM "run as script" guard (Windows + POSIX safe via pathToFileURL).
|
|
232
|
+
const invokedDirectly =
|
|
233
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
234
|
+
if (invokedDirectly) main();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/design-context/extract-visual-hierarchy.mjs — Phase 52, executor B.
|
|
3
|
+
//
|
|
4
|
+
// Deterministic, dependency-free visual-hierarchy extractor. Regex-scans source
|
|
5
|
+
// for heading structure (<h1>..<h6>), type-scale steps (Tailwind text-* + CSS
|
|
6
|
+
// font-size), focal/hero weight signals, and layout patterns (flex/grid/
|
|
7
|
+
// F-/Z-/centered), and emits a Fragment (schema_version 52.0) of `layer` +
|
|
8
|
+
// `pattern` nodes with `composes` edges (heading-level layer -> next deeper
|
|
9
|
+
// level, capturing the document outline) and `referenced-by` edges
|
|
10
|
+
// (component file -> pattern it exhibits).
|
|
11
|
+
//
|
|
12
|
+
// Semantics mirror agents/visual-hierarchy-mapper.md (headings / type-scale /
|
|
13
|
+
// focal weight / layout patterns). Structural pass: summary='' and
|
|
14
|
+
// complexity='moderate' are stubs the LLM/mapper phase fills later. No network,
|
|
15
|
+
// no deps, no top-level 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 = 'visual-hierarchy-mapper';
|
|
26
|
+
const SCHEMA_VERSION = '52.0';
|
|
27
|
+
|
|
28
|
+
const SCANNABLE_EXT = new Set([
|
|
29
|
+
'.tsx', '.jsx', '.ts', '.js',
|
|
30
|
+
'.vue', '.svelte', '.html', '.htm',
|
|
31
|
+
'.css', '.scss',
|
|
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
|
+
// Visual-hierarchy matchers.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const HEADING = /<h([1-6])\b/g;
|
|
72
|
+
const TW_TEXT_SCALE = /\btext-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)\b/g;
|
|
73
|
+
const CSS_FONTSIZE = /font-size\s*:\s*([0-9.]+(?:px|rem|em))/gi;
|
|
74
|
+
const HERO = /\b(?:hero|headline|display-[a-z0-9]+|page-title|page-heading)\b/g;
|
|
75
|
+
const LAYOUT = /\b(?:justify-(?:center|between|around|evenly)|items-center|grid-template|grid-cols-\d+|flex-(?:row|col)|flex-direction)\b/g;
|
|
76
|
+
const CENTERED = /\b(?:mx-auto|justify-center|items-center|place-items-center|text-center)\b/g;
|
|
77
|
+
|
|
78
|
+
function collect(re, content, mapFn) {
|
|
79
|
+
const out = [];
|
|
80
|
+
re.lastIndex = 0;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = re.exec(content)) !== null) {
|
|
83
|
+
const v = mapFn(m);
|
|
84
|
+
if (v != null) out.push(v);
|
|
85
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pure extractor.
|
|
92
|
+
* @param {string[]|string} roots
|
|
93
|
+
* @param {{generatedAt?: string}} [opts]
|
|
94
|
+
* @returns {object} Fragment
|
|
95
|
+
*/
|
|
96
|
+
export function extract(roots, opts = {}) {
|
|
97
|
+
const rootList = (Array.isArray(roots) ? roots : [roots]).filter(Boolean);
|
|
98
|
+
const nodeMap = new Map();
|
|
99
|
+
const edgeSet = new Map();
|
|
100
|
+
const headingLevels = new Set(); // numeric levels seen (1..6) across corpus
|
|
101
|
+
const typeScaleSteps = new Set(); // text-* scale steps seen
|
|
102
|
+
|
|
103
|
+
const addNode = (id, type, name, extra) => {
|
|
104
|
+
if (!nodeMap.has(id)) nodeMap.set(id, stubNode(id, type, name, extra || {}));
|
|
105
|
+
return id;
|
|
106
|
+
};
|
|
107
|
+
const addEdge = (source, target, type, weight) => {
|
|
108
|
+
const key = `${source}--${type}-->${target}`;
|
|
109
|
+
if (!edgeSet.has(key)) edgeSet.set(key, { source, target, type, direction: 'forward', weight });
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const root of rootList) {
|
|
113
|
+
for (const abs of walk(root)) {
|
|
114
|
+
let content;
|
|
115
|
+
try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
116
|
+
const ext = path.extname(abs).toLowerCase();
|
|
117
|
+
const baseName = path.basename(abs, ext);
|
|
118
|
+
const compId = `component:${slug(baseName)}`;
|
|
119
|
+
|
|
120
|
+
// Heading levels -> layer nodes (one per level seen).
|
|
121
|
+
for (const lvl of collect(HEADING, content, (m) => Number(m[1]))) {
|
|
122
|
+
headingLevels.add(lvl);
|
|
123
|
+
addNode(`layer:heading-h${lvl}`, 'layer', `Heading level h${lvl}`, { subtype: 'Template', level: lvl });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Type-scale steps -> pattern nodes (the scale vocabulary in use).
|
|
127
|
+
for (const step of collect(TW_TEXT_SCALE, content, (m) => m[1])) {
|
|
128
|
+
typeScaleSteps.add(step);
|
|
129
|
+
addNode(`pattern:type-scale-${slug(step)}`, 'pattern', `Type scale: text-${step}`, { step });
|
|
130
|
+
}
|
|
131
|
+
for (const size of collect(CSS_FONTSIZE, content, (m) => m[1])) {
|
|
132
|
+
addNode(`pattern:type-scale-${slug(size)}`, 'pattern', `Type scale: ${size}`, { size });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Focal / hero signals -> pattern node + referenced-by from the file.
|
|
136
|
+
if ((content.match(HERO) || []).length) {
|
|
137
|
+
const pid = addNode('pattern:focal-hero', 'pattern', 'Focal/hero emphasis', {});
|
|
138
|
+
addEdge(compId, pid, 'referenced-by', 0.5);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Layout patterns.
|
|
142
|
+
if ((content.match(CENTERED) || []).length) {
|
|
143
|
+
const pid = addNode('pattern:layout-centered-column', 'pattern', 'Centered column layout', {});
|
|
144
|
+
addEdge(compId, pid, 'referenced-by', 0.4);
|
|
145
|
+
} else if ((content.match(LAYOUT) || []).length) {
|
|
146
|
+
const pid = addNode('pattern:layout-flow', 'pattern', 'Flex/grid flow layout', {});
|
|
147
|
+
addEdge(compId, pid, 'referenced-by', 0.4);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// composes edges: connect each heading level to the next-deeper one present,
|
|
153
|
+
// capturing the document-outline shape (h1 composes h2 composes h3 ...).
|
|
154
|
+
const sortedLevels = [...headingLevels].sort((a, b) => a - b);
|
|
155
|
+
for (let i = 0; i < sortedLevels.length - 1; i++) {
|
|
156
|
+
const src = `layer:heading-h${sortedLevels[i]}`;
|
|
157
|
+
const dst = `layer:heading-h${sortedLevels[i + 1]}`;
|
|
158
|
+
addEdge(src, dst, 'composes', 0.5);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
schema_version: SCHEMA_VERSION,
|
|
163
|
+
mapper: MAPPER,
|
|
164
|
+
generated_at: opts.generatedAt || '',
|
|
165
|
+
nodes: [...nodeMap.values()],
|
|
166
|
+
edges: [...edgeSet.values()],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function main(argv = process.argv.slice(2)) {
|
|
171
|
+
const roots = argv.length ? argv : [process.cwd()];
|
|
172
|
+
const fragment = extract(roots, { generatedAt: new Date().toISOString() });
|
|
173
|
+
process.stdout.write(JSON.stringify(fragment, null, 2) + '\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const invokedDirectly =
|
|
177
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
178
|
+
if (invokedDirectly) main();
|