@pedroab0/skills 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/bin/cli.js +748 -0
- package/figma-extractor/README.md +180 -0
- package/figma-extractor/SKILL.md +199 -0
- package/figma-extractor/demo/desktop_home.json +2434 -0
- package/figma-extractor/demo/dt_money_desktop_home.png +0 -0
- package/figma-extractor/demo/dt_money_preview.png +0 -0
- package/figma-extractor/demo/dt_money_selection.json +472 -0
- package/figma-extractor/demo/index.html +188 -0
- package/figma-extractor/demo/style.css +360 -0
- package/figma-extractor/figma-plugin/README.md +81 -0
- package/figma-extractor/figma-plugin/assets/cover-1920.png +0 -0
- package/figma-extractor/figma-plugin/assets/cover.jpg +0 -0
- package/figma-extractor/figma-plugin/assets/cover.png +0 -0
- package/figma-extractor/figma-plugin/assets/icon-128.png +0 -0
- package/figma-extractor/figma-plugin/assets/icon.png +0 -0
- package/figma-extractor/figma-plugin/code.js +407 -0
- package/figma-extractor/figma-plugin/manifest.json +16 -0
- package/figma-extractor/figma-plugin/ui.html +470 -0
- package/figma-extractor/scripts/fetch_figma.js +1314 -0
- package/figma-extractor/scripts/test_fetch_figma.js +601 -0
- package/figma-node-builder/README.md +107 -0
- package/figma-node-builder/SKILL.md +113 -0
- package/figma-node-builder/scripts/inspect_spec.js +514 -0
- package/figma-node-builder/scripts/test_inspect_spec.js +148 -0
- package/figma-to-code/README.md +160 -0
- package/figma-to-code/SKILL.md +211 -0
- package/figma-to-code/scripts/fetch_figma.js +1251 -0
- package/figma-to-code/scripts/test_fetch_figma.js +581 -0
- package/package.json +40 -4
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spec Inspector CLI for figma-node-builder
|
|
5
|
+
* https://github.com/pedroab0/skills
|
|
6
|
+
*
|
|
7
|
+
* Inspects extracted Figma node specifications (.specs/*.json) and generates
|
|
8
|
+
* a compact, design-system-grounded report (layout, tokens, components, gaps)
|
|
9
|
+
* without requiring the agent to load the entire JSON AST into context.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node inspect_spec.js .specs/card.json
|
|
13
|
+
* node inspect_spec.js card [--markdown] [--json]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
// ANSI colors & styling
|
|
20
|
+
const isColorSupported = !process.env.NO_COLOR && (process.stdout.isTTY || process.env.FORCE_COLOR);
|
|
21
|
+
const style = {
|
|
22
|
+
bold: (t) => (isColorSupported ? `\x1b[1m${t}\x1b[0m` : t),
|
|
23
|
+
dim: (t) => (isColorSupported ? `\x1b[2m${t}\x1b[0m` : t),
|
|
24
|
+
green: (t) => (isColorSupported ? `\x1b[32m${t}\x1b[0m` : t),
|
|
25
|
+
cyan: (t) => (isColorSupported ? `\x1b[36m${t}\x1b[0m` : t),
|
|
26
|
+
yellow: (t) => (isColorSupported ? `\x1b[33m${t}\x1b[0m` : t),
|
|
27
|
+
magenta: (t) => (isColorSupported ? `\x1b[35m${t}\x1b[0m` : t),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// -----------------------------------------------------------------------------
|
|
31
|
+
// Path Resolution
|
|
32
|
+
// -----------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function resolveSpecPath(inputPath) {
|
|
35
|
+
if (!inputPath || typeof inputPath !== 'string') {
|
|
36
|
+
throw new Error('Please specify a specification file (e.g. .specs/card.json or card)');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const raw = inputPath.trim();
|
|
40
|
+
const candidates = [
|
|
41
|
+
raw,
|
|
42
|
+
raw.endsWith('.json') ? raw : `${raw}.json`,
|
|
43
|
+
path.join('.specs', raw),
|
|
44
|
+
path.join('.specs', raw.endsWith('.json') ? raw : `${raw}.json`),
|
|
45
|
+
path.join(process.cwd(), '.specs', raw.endsWith('.json') ? raw : `${raw}.json`),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const c of candidates) {
|
|
49
|
+
const resolved = path.resolve(c);
|
|
50
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new Error(`Spec file not found for "${inputPath}". Looked in:\n • ${candidates.slice(0, 3).join('\n • ')}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// -----------------------------------------------------------------------------
|
|
59
|
+
// AST Traversal & Analysis
|
|
60
|
+
// -----------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function inspectSpec(specData, specFilePath = '') {
|
|
63
|
+
// 1. Locate root node
|
|
64
|
+
let root = specData;
|
|
65
|
+
if (specData && specData.node) {
|
|
66
|
+
root = specData.node;
|
|
67
|
+
} else if (specData && specData.document) {
|
|
68
|
+
root = specData.document;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stats = {
|
|
72
|
+
totalNodes: 0,
|
|
73
|
+
maxDepth: 0,
|
|
74
|
+
typesCount: {},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const tokens = {
|
|
78
|
+
colors: new Map(), // token or hex -> count
|
|
79
|
+
spacing: new Map(), // token or value -> count
|
|
80
|
+
radii: new Map(), // token or value -> count
|
|
81
|
+
totalTokensFound: 0,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const instances = new Map(); // componentName -> Array<props>
|
|
85
|
+
const typography = {
|
|
86
|
+
fontFamilies: new Set(),
|
|
87
|
+
fontSizes: new Set(),
|
|
88
|
+
fontWeights: new Set(),
|
|
89
|
+
textSamples: [],
|
|
90
|
+
totalTextLayers: 0,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function addToken(type, nameOrValue, detail = '') {
|
|
94
|
+
if (!nameOrValue) return;
|
|
95
|
+
tokens.totalTokensFound++;
|
|
96
|
+
const key = String(nameOrValue);
|
|
97
|
+
const existing = tokens[type].get(key);
|
|
98
|
+
if (existing) {
|
|
99
|
+
existing.count++;
|
|
100
|
+
} else {
|
|
101
|
+
tokens[type].set(key, { count: 1, detail });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Recursive AST traversal
|
|
106
|
+
function walk(node, depth = 1) {
|
|
107
|
+
if (!node || typeof node !== 'object') return;
|
|
108
|
+
|
|
109
|
+
stats.totalNodes++;
|
|
110
|
+
if (depth > stats.maxDepth) stats.maxDepth = depth;
|
|
111
|
+
|
|
112
|
+
const type = node.type || 'UNKNOWN';
|
|
113
|
+
stats.typesCount[type] = (stats.typesCount[type] || 0) + 1;
|
|
114
|
+
|
|
115
|
+
// Component Instances
|
|
116
|
+
if (type === 'INSTANCE' || (node.component && node.component.name)) {
|
|
117
|
+
const compName = (node.component && node.component.name) || node.name || 'Component';
|
|
118
|
+
const props = node.props || {};
|
|
119
|
+
if (!instances.has(compName)) {
|
|
120
|
+
instances.set(compName, []);
|
|
121
|
+
}
|
|
122
|
+
instances.get(compName).push(props);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Layout Tokens
|
|
126
|
+
if (node.itemSpacingToken) {
|
|
127
|
+
addToken('spacing', node.itemSpacingToken, `${node.itemSpacing}px`);
|
|
128
|
+
}
|
|
129
|
+
if (node.paddingToken) {
|
|
130
|
+
addToken('spacing', node.paddingToken);
|
|
131
|
+
}
|
|
132
|
+
if (node.cornerRadiusToken) {
|
|
133
|
+
addToken('radii', node.cornerRadiusToken, `${node.cornerRadius}px`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Color Fills
|
|
137
|
+
if (Array.isArray(node.fills)) {
|
|
138
|
+
for (const fill of node.fills) {
|
|
139
|
+
if (fill.token) {
|
|
140
|
+
addToken('colors', fill.token, fill.color || '');
|
|
141
|
+
} else if (fill.color) {
|
|
142
|
+
addToken('colors', fill.color, 'raw hex');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Strokes
|
|
148
|
+
if (Array.isArray(node.strokes)) {
|
|
149
|
+
for (const stroke of node.strokes) {
|
|
150
|
+
if (stroke.token) {
|
|
151
|
+
addToken('colors', stroke.token, stroke.color || '');
|
|
152
|
+
} else if (stroke.color) {
|
|
153
|
+
addToken('colors', stroke.color, 'raw hex');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Typography & Text
|
|
159
|
+
if (type === 'TEXT') {
|
|
160
|
+
typography.totalTextLayers++;
|
|
161
|
+
if (node.characters && typography.textSamples.length < 5) {
|
|
162
|
+
const cleanText = node.characters.trim().replace(/\s+/g, ' ');
|
|
163
|
+
if (cleanText && !typography.textSamples.includes(cleanText)) {
|
|
164
|
+
typography.textSamples.push(cleanText.length > 40 ? cleanText.slice(0, 37) + '...' : cleanText);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const styleObj = node.style || node.typography || {};
|
|
169
|
+
if (styleObj.fontFamily) typography.fontFamilies.add(styleObj.fontFamily);
|
|
170
|
+
if (styleObj.fontSize) typography.fontSizes.add(Math.round(styleObj.fontSize));
|
|
171
|
+
if (styleObj.fontWeight) typography.fontWeights.add(styleObj.fontWeight);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Traverse children
|
|
175
|
+
if (Array.isArray(node.children)) {
|
|
176
|
+
for (const child of node.children) {
|
|
177
|
+
walk(child, depth + 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
walk(root, 1);
|
|
183
|
+
|
|
184
|
+
// Companion Preview Image Resolution
|
|
185
|
+
let companionImage = null;
|
|
186
|
+
if (specFilePath) {
|
|
187
|
+
const dir = path.dirname(specFilePath);
|
|
188
|
+
const baseName = path.basename(specFilePath, '.json');
|
|
189
|
+
const companionPngPath = path.join(dir, `${baseName}.png`);
|
|
190
|
+
const exists = fs.existsSync(companionPngPath);
|
|
191
|
+
companionImage = {
|
|
192
|
+
path: companionPngPath,
|
|
193
|
+
exists,
|
|
194
|
+
};
|
|
195
|
+
} else if (specData && specData.image && specData.image.path) {
|
|
196
|
+
companionImage = {
|
|
197
|
+
path: specData.image.path,
|
|
198
|
+
exists: fs.existsSync(specData.image.path),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Extract Root Layout Info
|
|
203
|
+
const rootLayout = {
|
|
204
|
+
name: root.name || 'Root',
|
|
205
|
+
type: root.type || 'FRAME',
|
|
206
|
+
width: root.bounds ? root.bounds.width : null,
|
|
207
|
+
height: root.bounds ? root.bounds.height : null,
|
|
208
|
+
layoutMode: root.layoutMode || 'NONE',
|
|
209
|
+
itemSpacing: root.itemSpacing || 0,
|
|
210
|
+
itemSpacingToken: root.itemSpacingToken || null,
|
|
211
|
+
padding: root.padding || null,
|
|
212
|
+
paddingToken: root.paddingToken || null,
|
|
213
|
+
cornerRadius: root.cornerRadius || null,
|
|
214
|
+
cornerRadiusToken: root.cornerRadiusToken || null,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
specFilePath,
|
|
219
|
+
rootLayout,
|
|
220
|
+
stats,
|
|
221
|
+
tokens,
|
|
222
|
+
instances,
|
|
223
|
+
typography,
|
|
224
|
+
companionImage,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// -----------------------------------------------------------------------------
|
|
229
|
+
// Terminal Report Formatter
|
|
230
|
+
// -----------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
function formatTerminalReport(data) {
|
|
233
|
+
const lines = [];
|
|
234
|
+
const { rootLayout, tokens, instances, typography, companionImage, specFilePath } = data;
|
|
235
|
+
|
|
236
|
+
const fileSize = specFilePath && fs.existsSync(specFilePath)
|
|
237
|
+
? `(${ (fs.statSync(specFilePath).size / 1024).toFixed(1) } KB)`
|
|
238
|
+
: '';
|
|
239
|
+
|
|
240
|
+
lines.push(`\n${style.bold(style.cyan('◆'))} ${style.bold('Figma Spec Inspector')}`);
|
|
241
|
+
lines.push(`${style.dim('│')} ${style.bold('Spec:')} ${specFilePath || 'stdin'} ${style.dim(fileSize)}`);
|
|
242
|
+
|
|
243
|
+
if (companionImage) {
|
|
244
|
+
const status = companionImage.exists ? style.green('[Found]') : style.yellow('[Missing preview image]');
|
|
245
|
+
lines.push(`${style.dim('│')} ${style.bold('Preview:')} ${companionImage.path} ${status}`);
|
|
246
|
+
}
|
|
247
|
+
lines.push(style.dim('│'));
|
|
248
|
+
|
|
249
|
+
// 1. Root Dimensions & Layout
|
|
250
|
+
lines.push(`${style.cyan('◇')} ${style.bold('Root Dimensions & Layout:')}`);
|
|
251
|
+
lines.push(`${style.dim('│')} • ${style.bold('Component:')} "${rootLayout.name}" ${style.dim(`(${rootLayout.type})`)}`);
|
|
252
|
+
|
|
253
|
+
const dimStr = rootLayout.width && rootLayout.height ? `${rootLayout.width} × ${rootLayout.height} px` : 'Fluid / Unspecified';
|
|
254
|
+
lines.push(`${style.dim('│')} • ${style.bold('Dimensions:')} ${style.cyan(dimStr)}`);
|
|
255
|
+
|
|
256
|
+
let modeStr = 'Absolute / Static';
|
|
257
|
+
if (rootLayout.layoutMode === 'VERTICAL') modeStr = 'Vertical Flex (Column)';
|
|
258
|
+
else if (rootLayout.layoutMode === 'HORIZONTAL') modeStr = 'Horizontal Flex (Row)';
|
|
259
|
+
lines.push(`${style.dim('│')} • ${style.bold('Layout Mode:')} ${modeStr}`);
|
|
260
|
+
|
|
261
|
+
if (rootLayout.padding) {
|
|
262
|
+
const p = rootLayout.padding;
|
|
263
|
+
const tokenStr = rootLayout.paddingToken ? ` ${style.cyan(`(${rootLayout.paddingToken})`)}` : '';
|
|
264
|
+
lines.push(`${style.dim('│')} • ${style.bold('Padding:')} T:${p.top} R:${p.right} B:${p.bottom} L:${p.left} px${tokenStr}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (rootLayout.itemSpacing) {
|
|
268
|
+
const tokenStr = rootLayout.itemSpacingToken ? ` ${style.cyan(`(${rootLayout.itemSpacingToken})`)}` : '';
|
|
269
|
+
lines.push(`${style.dim('│')} • ${style.bold('Item Spacing (Gap):')} ${rootLayout.itemSpacing} px${tokenStr}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (rootLayout.cornerRadius) {
|
|
273
|
+
const tokenStr = rootLayout.cornerRadiusToken ? ` ${style.cyan(`(${rootLayout.cornerRadiusToken})`)}` : '';
|
|
274
|
+
lines.push(`${style.dim('│')} • ${style.bold('Corner Radius:')} ${rootLayout.cornerRadius} px${tokenStr}`);
|
|
275
|
+
}
|
|
276
|
+
lines.push(style.dim('│'));
|
|
277
|
+
|
|
278
|
+
// 2. Design Tokens Used
|
|
279
|
+
const colorCount = tokens.colors.size;
|
|
280
|
+
const spacingCount = tokens.spacing.size;
|
|
281
|
+
const radiiCount = tokens.radii.size;
|
|
282
|
+
const totalTokens = colorCount + spacingCount + radiiCount;
|
|
283
|
+
|
|
284
|
+
lines.push(`${style.cyan('◇')} ${style.bold(`Design Tokens & Colors (${totalTokens} total):`)}`);
|
|
285
|
+
if (colorCount > 0) {
|
|
286
|
+
const colorItems = Array.from(tokens.colors.entries())
|
|
287
|
+
.slice(0, 5)
|
|
288
|
+
.map(([k, v]) => `${k}${v.detail && v.detail !== 'raw hex' ? ` (${v.detail})` : ''}`)
|
|
289
|
+
.join(', ');
|
|
290
|
+
const more = colorCount > 5 ? style.dim(` +${colorCount - 5} more`) : '';
|
|
291
|
+
lines.push(`${style.dim('│')} • ${style.bold('Colors')} (${colorCount}): ${colorItems}${more}`);
|
|
292
|
+
}
|
|
293
|
+
if (spacingCount > 0) {
|
|
294
|
+
const spacingItems = Array.from(tokens.spacing.keys()).join(', ');
|
|
295
|
+
lines.push(`${style.dim('│')} • ${style.bold('Spacing')} (${spacingCount}): ${spacingItems}`);
|
|
296
|
+
}
|
|
297
|
+
if (radiiCount > 0) {
|
|
298
|
+
const radiiItems = Array.from(tokens.radii.keys()).join(', ');
|
|
299
|
+
lines.push(`${style.dim('│')} • ${style.bold('Radii')} (${radiiCount}): ${radiiItems}`);
|
|
300
|
+
}
|
|
301
|
+
if (totalTokens === 0) {
|
|
302
|
+
lines.push(`${style.dim('│')} • ${style.dim('No bound tokens found (raw inline values used)')}`);
|
|
303
|
+
}
|
|
304
|
+
lines.push(style.dim('│'));
|
|
305
|
+
|
|
306
|
+
// 3. Component Instances
|
|
307
|
+
const instanceTotal = Array.from(instances.values()).reduce((acc, arr) => acc + arr.length, 0);
|
|
308
|
+
lines.push(`${style.cyan('◇')} ${style.bold(`Component Instances (${instanceTotal} instance${instanceTotal === 1 ? '' : 's'} across ${instances.size} component${instances.size === 1 ? '' : 's'}):`)}`);
|
|
309
|
+
|
|
310
|
+
if (instances.size > 0) {
|
|
311
|
+
for (const [compName, usages] of instances.entries()) {
|
|
312
|
+
lines.push(`${style.dim('│')} • ${style.bold(style.cyan(`<${compName} />`))} ${style.dim(`(${usages.length}x)`)}`);
|
|
313
|
+
// Display sample distinct prop shapes
|
|
314
|
+
const propSamples = usages.slice(0, 2);
|
|
315
|
+
for (const p of propSamples) {
|
|
316
|
+
const propKeys = Object.keys(p);
|
|
317
|
+
if (propKeys.length > 0) {
|
|
318
|
+
const formattedProps = propKeys
|
|
319
|
+
.map((k) => `${k}=${JSON.stringify(p[k])}`)
|
|
320
|
+
.join(' ');
|
|
321
|
+
lines.push(`${style.dim('│')} ${style.dim('↳ Props:')} ${formattedProps}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
lines.push(`${style.dim('│')} • ${style.dim('None (atomic frame / layout without external component instances)')}`);
|
|
327
|
+
}
|
|
328
|
+
lines.push(style.dim('│'));
|
|
329
|
+
|
|
330
|
+
// 4. Component Gaps (Action Items for Host Codebase)
|
|
331
|
+
lines.push(`${style.cyan('◇')} ${style.bold('Component Gaps & Codebase Checklist:')}`);
|
|
332
|
+
if (instances.size > 0) {
|
|
333
|
+
lines.push(`${style.dim('│')} ${style.dim('Verify these primitives exist in your project before synthesizing:')}`);
|
|
334
|
+
for (const [compName, usages] of instances.entries()) {
|
|
335
|
+
lines.push(`${style.dim('│')} [ ] ${style.bold(`<${compName} />`)} ${style.dim(`(used ${usages.length} time${usages.length === 1 ? '' : 's'})`)}`);
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
lines.push(`${style.dim('│')} ${style.green('✓')} No external component dependencies required.`);
|
|
339
|
+
}
|
|
340
|
+
lines.push(style.dim('│'));
|
|
341
|
+
|
|
342
|
+
// 5. Typography & Text
|
|
343
|
+
if (typography.totalTextLayers > 0) {
|
|
344
|
+
lines.push(`${style.cyan('◇')} ${style.bold(`Typography & Text (${typography.totalTextLayers} layer${typography.totalTextLayers === 1 ? '' : 's'}):`)}`);
|
|
345
|
+
const fonts = Array.from(typography.fontFamilies).join(', ') || 'Default';
|
|
346
|
+
const sizes = Array.from(typography.fontSizes).sort((a, b) => a - b).map((s) => `${s}px`).join(', ') || 'N/A';
|
|
347
|
+
const weights = Array.from(typography.fontWeights).sort().join(', ') || 'Regular';
|
|
348
|
+
lines.push(`${style.dim('│')} • ${style.bold('Fonts:')} ${fonts} ${style.dim(`| Sizes: ${sizes} | Weights: ${weights}`)}`);
|
|
349
|
+
|
|
350
|
+
if (typography.textSamples.length > 0) {
|
|
351
|
+
lines.push(`${style.dim('│')} • ${style.bold('Content Samples:')} ${typography.textSamples.map((s) => `"${s}"`).join(', ')}`);
|
|
352
|
+
}
|
|
353
|
+
lines.push(style.dim('│'));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
lines.push(`${style.green('✔')} ${style.bold('Inspection complete.')} ${style.dim(`Analyzed ${data.stats.totalNodes} nodes down to depth ${data.stats.maxDepth}.`)}`);
|
|
357
|
+
lines.push(`${style.dim('└')} ${style.dim('Ready for Phase 3 (Grounded Synthesis) with figma-node-builder.')}\n`);
|
|
358
|
+
|
|
359
|
+
return lines.join('\n');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// -----------------------------------------------------------------------------
|
|
363
|
+
// Markdown Report Formatter (For agent plans & artifacts)
|
|
364
|
+
// -----------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function formatMarkdownReport(data) {
|
|
367
|
+
const { rootLayout, tokens, instances, typography, companionImage, specFilePath } = data;
|
|
368
|
+
const lines = [];
|
|
369
|
+
|
|
370
|
+
const specName = path.basename(specFilePath || 'spec.json');
|
|
371
|
+
lines.push(`### 📐 Figma Spec Summary: \`${specName}\`\n`);
|
|
372
|
+
|
|
373
|
+
// Overview Table
|
|
374
|
+
lines.push(`| Metric | Specification |`);
|
|
375
|
+
lines.push(`| :--- | :--- |`);
|
|
376
|
+
lines.push(`| **Component Name** | \`${rootLayout.name}\` (${rootLayout.type}) |`);
|
|
377
|
+
const dimStr = rootLayout.width && rootLayout.height ? `${rootLayout.width}×${rootLayout.height}px` : 'Fluid';
|
|
378
|
+
lines.push(`| **Root Dimensions** | \`${dimStr}\` |`);
|
|
379
|
+
|
|
380
|
+
let modeStr = 'Absolute / Static';
|
|
381
|
+
if (rootLayout.layoutMode === 'VERTICAL') modeStr = 'Vertical Flex (Column)';
|
|
382
|
+
else if (rootLayout.layoutMode === 'HORIZONTAL') modeStr = 'Horizontal Flex (Row)';
|
|
383
|
+
lines.push(`| **Layout Mode** | ${modeStr} |`);
|
|
384
|
+
|
|
385
|
+
if (rootLayout.itemSpacing) {
|
|
386
|
+
const token = rootLayout.itemSpacingToken ? ` (\`${rootLayout.itemSpacingToken}\`)` : '';
|
|
387
|
+
lines.push(`| **Item Spacing (Gap)** | ${rootLayout.itemSpacing}px${token} |`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (companionImage) {
|
|
391
|
+
const status = companionImage.exists ? '✅ Available' : '⚠️ Missing';
|
|
392
|
+
lines.push(`| **Companion Image** | \`${path.basename(companionImage.path)}\` (${status}) |`);
|
|
393
|
+
}
|
|
394
|
+
lines.push('');
|
|
395
|
+
|
|
396
|
+
// Design Tokens
|
|
397
|
+
lines.push(`#### 🎨 Design Tokens & Styles`);
|
|
398
|
+
if (tokens.colors.size > 0) {
|
|
399
|
+
const colors = Array.from(tokens.colors.keys()).slice(0, 6).map((c) => `\`${c}\``).join(', ');
|
|
400
|
+
lines.push(`- **Colors**: ${colors}`);
|
|
401
|
+
}
|
|
402
|
+
if (tokens.spacing.size > 0) {
|
|
403
|
+
lines.push(`- **Spacing**: ${Array.from(tokens.spacing.keys()).map((s) => `\`${s}\``).join(', ')}`);
|
|
404
|
+
}
|
|
405
|
+
if (tokens.radii.size > 0) {
|
|
406
|
+
lines.push(`- **Radii**: ${Array.from(tokens.radii.keys()).map((r) => `\`${r}\``).join(', ')}`);
|
|
407
|
+
}
|
|
408
|
+
lines.push('');
|
|
409
|
+
|
|
410
|
+
// Component Checklist
|
|
411
|
+
lines.push(`#### 🧩 Sub-Component Checklist`);
|
|
412
|
+
if (instances.size > 0) {
|
|
413
|
+
lines.push(`| Component | Occurrences | Sample Props | Status |`);
|
|
414
|
+
lines.push(`| :--- | :--- | :--- | :--- |`);
|
|
415
|
+
for (const [compName, usages] of instances.entries()) {
|
|
416
|
+
const firstProps = usages[0] ? Object.keys(usages[0]).map((k) => `${k}=${JSON.stringify(usages[0][k])}`).join(' ') : 'none';
|
|
417
|
+
lines.push(`| \`<${compName} />\` | ${usages.length} | \`${firstProps.slice(0, 45)}\` | \`[ ] Verify in codebase\` |`);
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
lines.push(`- No external component instances detected.`);
|
|
421
|
+
}
|
|
422
|
+
lines.push('');
|
|
423
|
+
|
|
424
|
+
// Typography
|
|
425
|
+
if (typography.totalTextLayers > 0) {
|
|
426
|
+
lines.push(`#### 🔤 Typography`);
|
|
427
|
+
const fonts = Array.from(typography.fontFamilies).join(', ') || 'System default';
|
|
428
|
+
const sizes = Array.from(typography.fontSizes).sort((a, b) => a - b).map((s) => `${s}px`).join(', ');
|
|
429
|
+
lines.push(`- **Families**: ${fonts}`);
|
|
430
|
+
lines.push(`- **Font Sizes**: ${sizes}`);
|
|
431
|
+
lines.push('');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return lines.join('\n');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// -----------------------------------------------------------------------------
|
|
438
|
+
// CLI Execution
|
|
439
|
+
// -----------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
function printUsage() {
|
|
442
|
+
console.log(`
|
|
443
|
+
Spec Inspector CLI - figma-node-builder
|
|
444
|
+
Summarize extracted Figma node hierarchies without loading raw JSON into agent context.
|
|
445
|
+
|
|
446
|
+
Usage:
|
|
447
|
+
node inspect_spec.js <path-or-name> [options]
|
|
448
|
+
|
|
449
|
+
Arguments:
|
|
450
|
+
<path-or-name> Path to JSON spec (e.g. .specs/card.json) or shorthand (e.g. card)
|
|
451
|
+
|
|
452
|
+
Options:
|
|
453
|
+
--markdown, -m Output clean GitHub Flavored Markdown (for plans & walkthroughs)
|
|
454
|
+
--json, -j Output structured JSON summary
|
|
455
|
+
--help, -h Show this help message
|
|
456
|
+
|
|
457
|
+
Examples:
|
|
458
|
+
node inspect_spec.js .specs/card.json
|
|
459
|
+
node inspect_spec.js card
|
|
460
|
+
node inspect_spec.js card --markdown
|
|
461
|
+
`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function main() {
|
|
465
|
+
const args = process.argv.slice(2);
|
|
466
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
467
|
+
printUsage();
|
|
468
|
+
process.exit(0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const isMarkdown = args.includes('--markdown') || args.includes('-m');
|
|
472
|
+
const isJson = args.includes('--json') || args.includes('-j');
|
|
473
|
+
const positional = args.filter((a) => !a.startsWith('-'));
|
|
474
|
+
|
|
475
|
+
if (positional.length === 0) {
|
|
476
|
+
console.error('Error: Missing spec file argument.');
|
|
477
|
+
printUsage();
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const resolvedPath = resolveSpecPath(positional[0]);
|
|
483
|
+
const rawContent = fs.readFileSync(resolvedPath, 'utf8');
|
|
484
|
+
const parsedData = JSON.parse(rawContent);
|
|
485
|
+
|
|
486
|
+
const inspection = inspectSpec(parsedData, resolvedPath);
|
|
487
|
+
|
|
488
|
+
if (isJson) {
|
|
489
|
+
console.log(JSON.stringify(inspection, (key, value) => {
|
|
490
|
+
if (value instanceof Set) return Array.from(value);
|
|
491
|
+
if (value instanceof Map) return Object.fromEntries(value);
|
|
492
|
+
return value;
|
|
493
|
+
}, 2));
|
|
494
|
+
} else if (isMarkdown) {
|
|
495
|
+
console.log(formatMarkdownReport(inspection));
|
|
496
|
+
} else {
|
|
497
|
+
console.log(formatTerminalReport(inspection));
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
console.error(`\n✖ ${err.message}\n`);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (require.main === module) {
|
|
506
|
+
main();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
module.exports = {
|
|
510
|
+
resolveSpecPath,
|
|
511
|
+
inspectSpec,
|
|
512
|
+
formatTerminalReport,
|
|
513
|
+
formatMarkdownReport,
|
|
514
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const {
|
|
8
|
+
resolveSpecPath,
|
|
9
|
+
inspectSpec,
|
|
10
|
+
formatTerminalReport,
|
|
11
|
+
formatMarkdownReport,
|
|
12
|
+
} = require('./inspect_spec.js');
|
|
13
|
+
|
|
14
|
+
console.log('--- Running Spec Inspector Unit Tests ---');
|
|
15
|
+
|
|
16
|
+
// Mock Node AST tree
|
|
17
|
+
const mockSpec = {
|
|
18
|
+
name: 'Product Card',
|
|
19
|
+
type: 'FRAME',
|
|
20
|
+
bounds: { width: 360, height: 480 },
|
|
21
|
+
layoutMode: 'VERTICAL',
|
|
22
|
+
itemSpacing: 16,
|
|
23
|
+
itemSpacingToken: 'spacing/md',
|
|
24
|
+
padding: { top: 24, right: 24, bottom: 24, left: 24 },
|
|
25
|
+
paddingToken: 'spacing/lg',
|
|
26
|
+
cornerRadius: 12,
|
|
27
|
+
cornerRadiusToken: 'radii/lg',
|
|
28
|
+
fills: [{ type: 'SOLID', color: '#FFFFFF', token: 'color/surface' }],
|
|
29
|
+
children: [
|
|
30
|
+
{
|
|
31
|
+
name: 'Badge Instance',
|
|
32
|
+
type: 'INSTANCE',
|
|
33
|
+
component: { name: 'Badge' },
|
|
34
|
+
props: { variant: 'success', label: 'In Stock' },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Title',
|
|
38
|
+
type: 'TEXT',
|
|
39
|
+
characters: 'Wireless Noise-Canceling Headphones',
|
|
40
|
+
style: {
|
|
41
|
+
fontFamily: 'Inter',
|
|
42
|
+
fontSize: 20,
|
|
43
|
+
fontWeight: 700,
|
|
44
|
+
},
|
|
45
|
+
fills: [{ type: 'SOLID', color: '#111827', token: 'color/text-primary' }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'Description',
|
|
49
|
+
type: 'TEXT',
|
|
50
|
+
characters: 'Premium audio experience with active noise cancellation.',
|
|
51
|
+
style: {
|
|
52
|
+
fontFamily: 'Inter',
|
|
53
|
+
fontSize: 14,
|
|
54
|
+
fontWeight: 400,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'Action Button',
|
|
59
|
+
type: 'INSTANCE',
|
|
60
|
+
component: { name: 'Button' },
|
|
61
|
+
props: { variant: 'primary', size: 'md', hasIcon: true },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'Secondary Button',
|
|
65
|
+
type: 'INSTANCE',
|
|
66
|
+
component: { name: 'Button' },
|
|
67
|
+
props: { variant: 'outline', size: 'md' },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// 1. inspectSpec: dimensions and layout
|
|
73
|
+
const report = inspectSpec(mockSpec, '/mock/.specs/card.json');
|
|
74
|
+
assert.strictEqual(report.rootLayout.name, 'Product Card');
|
|
75
|
+
assert.strictEqual(report.rootLayout.width, 360);
|
|
76
|
+
assert.strictEqual(report.rootLayout.height, 480);
|
|
77
|
+
assert.strictEqual(report.rootLayout.layoutMode, 'VERTICAL');
|
|
78
|
+
assert.strictEqual(report.rootLayout.itemSpacing, 16);
|
|
79
|
+
assert.strictEqual(report.rootLayout.itemSpacingToken, 'spacing/md');
|
|
80
|
+
assert.strictEqual(report.rootLayout.paddingToken, 'spacing/lg');
|
|
81
|
+
console.log(' ✓ inspectSpec: correctly extracts root dimensions and auto-layout metrics');
|
|
82
|
+
|
|
83
|
+
// 2. inspectSpec: tokens
|
|
84
|
+
assert.ok(report.tokens.colors.has('color/surface'), 'Includes surface color token');
|
|
85
|
+
assert.ok(report.tokens.colors.has('color/text-primary'), 'Includes text color token');
|
|
86
|
+
assert.ok(report.tokens.spacing.has('spacing/md'), 'Includes spacing/md token');
|
|
87
|
+
assert.ok(report.tokens.radii.has('radii/lg'), 'Includes radii/lg token');
|
|
88
|
+
console.log(' ✓ inspectSpec: aggregates design tokens across colors, spacing, and radii');
|
|
89
|
+
|
|
90
|
+
// 3. inspectSpec: component instances & gaps
|
|
91
|
+
assert.strictEqual(report.instances.size, 2, 'Detects 2 distinct components');
|
|
92
|
+
assert.strictEqual(report.instances.get('Button').length, 2, 'Detects 2 Button instances');
|
|
93
|
+
assert.strictEqual(report.instances.get('Badge').length, 1, 'Detects 1 Badge instance');
|
|
94
|
+
assert.strictEqual(report.instances.get('Button')[0].variant, 'primary');
|
|
95
|
+
assert.strictEqual(report.instances.get('Button')[1].variant, 'outline');
|
|
96
|
+
console.log(' ✓ inspectSpec: aggregates component instances and variant prop shapes');
|
|
97
|
+
|
|
98
|
+
// 4. inspectSpec: typography
|
|
99
|
+
assert.strictEqual(report.typography.totalTextLayers, 2);
|
|
100
|
+
assert.ok(report.typography.fontFamilies.has('Inter'));
|
|
101
|
+
assert.ok(report.typography.fontSizes.has(20));
|
|
102
|
+
assert.ok(report.typography.fontSizes.has(14));
|
|
103
|
+
assert.ok(report.typography.fontWeights.has(700));
|
|
104
|
+
console.log(' ✓ inspectSpec: extracts font families, sizes, weights, and text samples');
|
|
105
|
+
|
|
106
|
+
// 5. formatTerminalReport & formatMarkdownReport
|
|
107
|
+
const terminalText = formatTerminalReport(report);
|
|
108
|
+
assert.ok(terminalText.includes('Product Card'));
|
|
109
|
+
assert.ok(terminalText.includes('Vertical Flex (Column)'));
|
|
110
|
+
assert.ok(terminalText.includes('<Button />'));
|
|
111
|
+
assert.ok(terminalText.includes('<Badge />'));
|
|
112
|
+
assert.ok(terminalText.includes('Component Gaps & Codebase Checklist'));
|
|
113
|
+
console.log(' ✓ formatTerminalReport: formats clean box-drawing terminal report');
|
|
114
|
+
|
|
115
|
+
const markdownText = formatMarkdownReport(report);
|
|
116
|
+
assert.ok(markdownText.includes('### 📐 Figma Spec Summary: `card.json`'));
|
|
117
|
+
assert.ok(markdownText.includes('`<Button />`'));
|
|
118
|
+
assert.ok(markdownText.includes('`spacing/md`'));
|
|
119
|
+
console.log(' ✓ formatMarkdownReport: formats Markdown table and component checklist');
|
|
120
|
+
|
|
121
|
+
// 6. resolveSpecPath: resolution from disk
|
|
122
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-test-'));
|
|
123
|
+
try {
|
|
124
|
+
const specsSubdir = path.join(tmpDir, '.specs');
|
|
125
|
+
fs.mkdirSync(specsSubdir, { recursive: true });
|
|
126
|
+
const testFile = path.join(specsSubdir, 'product.json');
|
|
127
|
+
fs.writeFileSync(testFile, JSON.stringify(mockSpec), 'utf8');
|
|
128
|
+
|
|
129
|
+
// Direct path
|
|
130
|
+
assert.strictEqual(resolveSpecPath(testFile), testFile);
|
|
131
|
+
|
|
132
|
+
// Shorthand in cwd
|
|
133
|
+
const origCwd = process.cwd();
|
|
134
|
+
try {
|
|
135
|
+
process.chdir(tmpDir);
|
|
136
|
+
const expected = fs.realpathSync(path.resolve(specsSubdir, 'product.json'));
|
|
137
|
+
assert.strictEqual(fs.realpathSync(resolveSpecPath('product')), expected);
|
|
138
|
+
assert.strictEqual(fs.realpathSync(resolveSpecPath('.specs/product')), expected);
|
|
139
|
+
assert.strictEqual(fs.realpathSync(resolveSpecPath('.specs/product.json')), expected);
|
|
140
|
+
console.log(' ✓ resolveSpecPath: resolves direct files, shorthand names, and .specs/ subdirectories');
|
|
141
|
+
} finally {
|
|
142
|
+
process.chdir(origCwd);
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log('\nResults: 6/6 Spec Inspector tests passed.\n');
|