figma-local 2.0.0 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-local",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Control Figma Desktop with Claude Code. Smart read, write, and AI-prompt export. No API key required.",
5
5
  "author": "elvke",
6
6
  "license": "MIT",
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: figma-component-audit
3
+ description: |
4
+ Use this skill when the user wants to audit, review, or check the quality of Figma components. Triggers on: "audit", "audit components", "audit all components", "check component quality", "what's wrong with my components", "component issues", "find problems in components", "review my design system components", "component health", "component score", "check for hardcoded colors", "check spacing tokens", "check font styles", "check typography", "missing text styles", "hardcoded spacing", "missing variable bindings", "detached instances", "incomplete variants", "check border radius tokens". Requires a Figma file to be open and the daemon connected.
5
+ allowed-tools:
6
+ - Bash(fig component-audit *)
7
+ - Bash(fig component-audit)
8
+ - Bash(fig daemon status)
9
+ ---
10
+
11
+ # Figma Component Audit
12
+
13
+ Comprehensive quality audit for Figma components. Checks token compliance (colors, spacing, typography, effects, border radius), component structure, layout correctness, and layer hygiene. Returns a score (0–100) with issues grouped by category.
14
+
15
+ ## Prerequisites
16
+
17
+ The `fig` CLI must be connected. Check with `fig daemon status`. If not connected: `fig connect --safe`.
18
+
19
+ ## Usage
20
+
21
+ ### Audit current selection
22
+
23
+ Select a component or frame in Figma, then:
24
+
25
+ ```bash
26
+ fig component-audit
27
+ ```
28
+
29
+ ### Audit a specific component by name
30
+
31
+ ```bash
32
+ fig component-audit "Button"
33
+ fig component-audit "Card"
34
+ fig component-audit "Navigation Bar"
35
+ ```
36
+
37
+ ### Audit ALL components on the current page
38
+
39
+ ```bash
40
+ fig component-audit --all
41
+ ```
42
+
43
+ Scans every `COMPONENT` and `COMPONENT_SET` on the page, ranks them by score (worst first), and prints a summary with issues grouped into four categories.
44
+
45
+ ### Audit by node ID
46
+
47
+ ```bash
48
+ fig component-audit --node "123:456"
49
+ ```
50
+
51
+ ### JSON output (for piping or saving)
52
+
53
+ ```bash
54
+ fig component-audit --all --json
55
+ fig component-audit --all --json > audit-report.json
56
+ ```
57
+
58
+ ### Include info-level issues
59
+
60
+ Errors and warnings are shown by default. Add `--verbose` to also see info-level suggestions:
61
+
62
+ ```bash
63
+ fig component-audit --all --verbose
64
+ fig component-audit "Button" --verbose
65
+ ```
66
+
67
+ ## Rules by Category
68
+
69
+ ### Token Compliance
70
+ These ensure all design values come from Figma variables rather than hardcoded numbers.
71
+
72
+ | Rule | Severity | What it flags |
73
+ |------|----------|---------------|
74
+ | `hardcoded-fill-color` | warning | Fill color not bound to a color variable |
75
+ | `hardcoded-stroke-color` | warning | Stroke color not bound to a variable |
76
+ | `hardcoded-effect-color` | warning | Shadow / glow color not bound to a variable |
77
+ | `hardcoded-spacing` | warning | Padding or gap value not bound to a spacing variable |
78
+ | `hardcoded-border-radius` | warning | Corner radius not bound to a variable |
79
+ | `missing-text-style` | warning | Text node not using a shared text style |
80
+ | `hardcoded-font-size` | warning | Font size not bound to a variable (and no text style) |
81
+ | `hardcoded-opacity` | info | Non-100% opacity not bound to a variable |
82
+ | `off-type-scale` | info | Font size not on a standard type scale |
83
+
84
+ ### Component Structure
85
+
86
+ | Rule | Severity | What it flags |
87
+ |------|----------|---------------|
88
+ | `missing-description` | warning | Component has no description |
89
+ | `missing-component-props` | info | Component exposes no properties at all |
90
+ | `incomplete-variants` | warning | Component set missing expected variant combinations |
91
+ | `detached-instance` | error | Instance whose main component is missing |
92
+
93
+ ### Layout & Organisation
94
+
95
+ | Rule | Severity | What it flags |
96
+ |------|----------|---------------|
97
+ | `no-auto-layout` | info | Frame with 2+ children but no auto layout |
98
+ | `absolute-in-autolayout` | warning | Child pinned absolute inside an auto-layout frame |
99
+ | `non-standard-spacing` | info | Padding / gap value not on the 4px grid |
100
+ | `deep-nesting` | info | Node nested 7+ levels deep |
101
+
102
+ ### Layer Hygiene
103
+
104
+ | Rule | Severity | What it flags |
105
+ |------|----------|---------------|
106
+ | `hidden-layer` | info | Invisible layer still in the tree |
107
+ | `generic-layer-name` | info | Default name like "Frame 2" or "Rectangle" |
108
+ | `empty-text` | warning | Text node with no content |
109
+
110
+ ## Scoring
111
+
112
+ `score = 100 − (errors × 15) − (warnings × 5) − (info × 2)`
113
+
114
+ - **≥ 80** — Good
115
+ - **60–79** — Fair
116
+ - **< 60** — Needs work
117
+
118
+ ## Workflow: Full Design-System Audit
119
+
120
+ 1. Open the Figma file with your component library
121
+ 2. Confirm connection: `fig daemon status`
122
+ 3. Run the full audit:
123
+ ```bash
124
+ fig component-audit --all
125
+ ```
126
+ 4. Components are sorted worst-first — start with the lowest scores
127
+ 5. Dig into a specific component with verbose output:
128
+ ```bash
129
+ fig component-audit "ComponentName" --verbose
130
+ ```
131
+ 6. Fix issues in Figma, then re-run to verify improvement
132
+
133
+ ## Tips
134
+
135
+ - `detached-instance` (−15pts each) is always the highest priority to fix
136
+ - `missing-text-style` and `hardcoded-fill-color` are the most common warnings — fixing them usually means binding nodes to existing variables or creating new ones
137
+ - Use `fig var list` to see available variable collections before fixing hardcoded colors or spacing
138
+ - Use `fig styles "FrameName"` to see which text/color styles a frame currently uses
139
+ - Run `--all --json` to save a baseline report: `fig component-audit --all --json > baseline.json`
140
+ - `absolute-in-autolayout` is a warning, not an error — sometimes intentional (overlays, badges), but worth reviewing
141
+ - `non-standard-spacing` flags values not on the 4px grid (e.g., 5px, 7px, 13px) which may cause inconsistency at scale
@@ -0,0 +1,520 @@
1
+ /**
2
+ * component-audit.js — Figma component audit logic
3
+ *
4
+ * Comprehensive quality audit across five categories:
5
+ *
6
+ * ── Token compliance ──────────────────────────────────────────────────────────
7
+ * hardcoded-fill-color Fill color not bound to a color variable
8
+ * hardcoded-stroke-color Stroke color not bound to a variable
9
+ * hardcoded-effect-color Shadow/glow color not bound to a variable
10
+ * hardcoded-spacing Padding or gap value not bound to a spacing variable
11
+ * hardcoded-font-size Font size not bound to a variable
12
+ * hardcoded-border-radius Corner radius not bound to a variable
13
+ * missing-text-style Text node not using a shared text style
14
+ * hardcoded-opacity Non-100% opacity not bound to a variable
15
+ *
16
+ * ── Component structure ───────────────────────────────────────────────────────
17
+ * missing-description No description on the component / set
18
+ * missing-component-props Component exposes no properties (variants, text, boolean, swap)
19
+ * incomplete-variants Component set missing expected variant combinations
20
+ * detached-instance Instance whose main component is gone
21
+ *
22
+ * ── Layout & organisation ─────────────────────────────────────────────────────
23
+ * no-auto-layout Frame with 2+ children but no auto layout
24
+ * absolute-in-autolayout Child pinned absolute inside an auto-layout frame
25
+ * non-standard-spacing Spacing value not on the 4px grid
26
+ * deep-nesting Node nested 7+ levels deep
27
+ *
28
+ * ── Layer hygiene ─────────────────────────────────────────────────────────────
29
+ * hidden-layer Invisible layer still present in the tree
30
+ * generic-layer-name Default name like "Frame 2" or "Rectangle"
31
+ * empty-text Text node with no content
32
+ */
33
+
34
+ // ─── Figma eval code builders ──────────────────────────────────────────────
35
+
36
+ export function buildSingleAuditCode(nodeId) {
37
+ return `
38
+ (function() {
39
+ var node = figma.getNodeById(${JSON.stringify(nodeId)});
40
+ if (!node) return { error: 'Node not found: ${nodeId}' };
41
+ if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET' && node.type !== 'FRAME') {
42
+ return { error: 'Node is not a COMPONENT, COMPONENT_SET, or FRAME. Got: ' + node.type };
43
+ }
44
+ return auditComponent(node);
45
+ ${AUDIT_HELPERS}
46
+ })()
47
+ `;
48
+ }
49
+
50
+ export function buildAllAuditCode() {
51
+ return `
52
+ (function() {
53
+ var page = figma.currentPage;
54
+ var results = [];
55
+
56
+ function collectComponents(node) {
57
+ if (node.type === 'COMPONENT_SET') {
58
+ results.push(auditComponent(node));
59
+ return;
60
+ }
61
+ if (node.type === 'COMPONENT') {
62
+ if (!node.parent || node.parent.type !== 'COMPONENT_SET') {
63
+ results.push(auditComponent(node));
64
+ }
65
+ return;
66
+ }
67
+ if (node.children) {
68
+ for (var i = 0; i < node.children.length; i++) collectComponents(node.children[i]);
69
+ }
70
+ }
71
+
72
+ collectComponents(page);
73
+ return { page: page.name, total: results.length, components: results };
74
+ ${AUDIT_HELPERS}
75
+ })()
76
+ `;
77
+ }
78
+
79
+ export function buildSelectionAuditCode() {
80
+ return `
81
+ (function() {
82
+ var sel = figma.currentPage.selection;
83
+ if (!sel || sel.length === 0) return { error: 'Nothing selected. Select a component or frame in Figma first.' };
84
+ var node = sel[0];
85
+ if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET' && node.type !== 'FRAME') {
86
+ return { error: 'Selection is not a COMPONENT, COMPONENT_SET, or FRAME. Got: ' + node.type };
87
+ }
88
+ return auditComponent(node);
89
+ ${AUDIT_HELPERS}
90
+ })()
91
+ `;
92
+ }
93
+
94
+ // ─── Shared Figma helpers (injected as a string into every eval IIFE) ──────
95
+
96
+ const AUDIT_HELPERS = `
97
+
98
+ // ── Utility helpers ────────────────────────────────────────────────────────
99
+
100
+ function isBound(node, prop) {
101
+ return !!(node.boundVariables && node.boundVariables[prop]);
102
+ }
103
+
104
+ function toHex(c) {
105
+ var r = Math.round((c.r || 0) * 255);
106
+ var g = Math.round((c.g || 0) * 255);
107
+ var b = Math.round((c.b || 0) * 255);
108
+ return '#' + r.toString(16).padStart(2,'0') + g.toString(16).padStart(2,'0') + b.toString(16).padStart(2,'0');
109
+ }
110
+
111
+ // Returns true if the value sits on the standard 4-px grid
112
+ function onGrid(v) { return v === 0 || Math.round(v) % 4 === 0; }
113
+
114
+ // ── Main audit function ────────────────────────────────────────────────────
115
+
116
+ function auditComponent(node) {
117
+ var issues = [];
118
+ var stats = {
119
+ textNodes: 0, hiddenNodes: 0, instances: 0, detachedInstances: 0, maxDepth: 0,
120
+ hardcodedColors: 0, hardcodedSpacing: 0, hardcodedFontSizes: 0, missingTextStyles: 0
121
+ };
122
+
123
+ // ════════════════════════════════════════════════════════════════════════
124
+ // CATEGORY 1 — Component structure (top-level checks)
125
+ // ════════════════════════════════════════════════════════════════════════
126
+
127
+ if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
128
+
129
+ // 1a. Missing description
130
+ if (!node.description || node.description.trim() === '') {
131
+ issues.push({ category: 'structure', rule: 'missing-description', severity: 'warning',
132
+ message: 'Component has no description' });
133
+ }
134
+
135
+ // 1b. No exposed properties
136
+ var propDefs = node.componentPropertyDefinitions || {};
137
+ var propKeys = Object.keys(propDefs);
138
+ if (propKeys.length === 0 && node.type === 'COMPONENT') {
139
+ issues.push({ category: 'structure', rule: 'missing-component-props', severity: 'info',
140
+ message: 'Component exposes no properties (no variants, text, boolean, or instance-swap)' });
141
+ }
142
+
143
+ // 1c. Incomplete variant combinations
144
+ if (node.type === 'COMPONENT_SET') {
145
+ var variantProps = propKeys.filter(function(k) { return propDefs[k].type === 'VARIANT'; });
146
+ if (variantProps.length > 0) {
147
+ var expected = variantProps.reduce(function(acc, k) {
148
+ return acc * ((propDefs[k].variantOptions || []).length || 1);
149
+ }, 1);
150
+ var actual = (node.children || []).length;
151
+ if (actual < expected) {
152
+ issues.push({ category: 'structure', rule: 'incomplete-variants', severity: 'warning',
153
+ message: 'Variant set has ' + actual + ' of ' + expected + ' expected combinations',
154
+ details: { expected: expected, actual: actual, properties: variantProps } });
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ // ════════════════════════════════════════════════════════════════════════
161
+ // CATEGORIES 2-4 — Deep tree walk
162
+ // ════════════════════════════════════════════════════════════════════════
163
+
164
+ var GENERIC_NAMES = /^(Frame|Rectangle|Ellipse|Group|Vector|Polygon|Star|Line|Image|Component)\\s*\\d*$/i;
165
+
166
+ // Deduplicate: avoid flagging the same node+rule twice
167
+ var seen = {};
168
+ function flag(rule, nodeId) {
169
+ var key = rule + '::' + nodeId;
170
+ if (seen[key]) return false;
171
+ seen[key] = true;
172
+ return true;
173
+ }
174
+
175
+ function walk(n, depth) {
176
+ if (depth > stats.maxDepth) stats.maxDepth = depth;
177
+
178
+ // ── Layer hygiene ────────────────────────────────────────────────────
179
+
180
+ if (depth > 0 && n.visible === false) {
181
+ stats.hiddenNodes++;
182
+ if (flag('hidden-layer', n.id))
183
+ issues.push({ category: 'hygiene', rule: 'hidden-layer', severity: 'info',
184
+ message: 'Hidden layer: "' + n.name + '"', nodeId: n.id });
185
+ }
186
+
187
+ if (depth > 0 && GENERIC_NAMES.test(n.name)) {
188
+ if (flag('generic-layer-name', n.id))
189
+ issues.push({ category: 'hygiene', rule: 'generic-layer-name', severity: 'info',
190
+ message: 'Generic layer name: "' + n.name + '"', nodeId: n.id });
191
+ }
192
+
193
+ if (depth === 7) {
194
+ if (flag('deep-nesting', n.id))
195
+ issues.push({ category: 'layout', rule: 'deep-nesting', severity: 'info',
196
+ message: 'Node "' + n.name + '" is nested 7+ levels deep', nodeId: n.id });
197
+ }
198
+
199
+ // ── Detached instance ────────────────────────────────────────────────
200
+
201
+ if (n.type === 'INSTANCE') {
202
+ stats.instances++;
203
+ if (!n.mainComponent) {
204
+ stats.detachedInstances++;
205
+ if (flag('detached-instance', n.id))
206
+ issues.push({ category: 'structure', rule: 'detached-instance', severity: 'error',
207
+ message: 'Detached instance: "' + n.name + '" — main component is missing', nodeId: n.id });
208
+ }
209
+ }
210
+
211
+ // ── Layout ───────────────────────────────────────────────────────────
212
+
213
+ if (n.type === 'FRAME' && depth > 0) {
214
+ var childCount = (n.children || []).length;
215
+ if (childCount >= 2 && n.layoutMode === 'NONE') {
216
+ if (flag('no-auto-layout', n.id))
217
+ issues.push({ category: 'layout', rule: 'no-auto-layout', severity: 'info',
218
+ message: 'Frame "' + n.name + '" has ' + childCount + ' children but no auto layout', nodeId: n.id });
219
+ }
220
+ }
221
+
222
+ // Absolute-positioned child inside an auto-layout parent
223
+ if (n.layoutPositioning === 'ABSOLUTE' && n.parent && n.parent.layoutMode && n.parent.layoutMode !== 'NONE') {
224
+ if (flag('absolute-in-autolayout', n.id))
225
+ issues.push({ category: 'layout', rule: 'absolute-in-autolayout', severity: 'warning',
226
+ message: '"' + n.name + '" is absolutely positioned inside auto-layout frame "' + n.parent.name + '"', nodeId: n.id });
227
+ }
228
+
229
+ // ── TOKEN COMPLIANCE — Colors ─────────────────────────────────────────
230
+
231
+ // Fill colors
232
+ if (n.fills && Array.isArray(n.fills) && n.fills.length > 0) {
233
+ var hasFillBinding = isBound(n, 'fills');
234
+ if (!hasFillBinding) {
235
+ for (var fi = 0; fi < n.fills.length; fi++) {
236
+ var fill = n.fills[fi];
237
+ if (fill.type === 'SOLID' && fill.visible !== false) {
238
+ var fillHex = toHex(fill.color);
239
+ stats.hardcodedColors++;
240
+ if (flag('hardcoded-fill-color', n.id))
241
+ issues.push({ category: 'tokens', rule: 'hardcoded-fill-color', severity: 'warning',
242
+ message: 'Fill color ' + fillHex + ' on "' + n.name + '" is not bound to a color variable', nodeId: n.id });
243
+ break; // one flag per node is enough
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ // Stroke colors
250
+ if (n.strokes && Array.isArray(n.strokes) && n.strokes.length > 0) {
251
+ var hasStrokeBinding = isBound(n, 'strokes');
252
+ if (!hasStrokeBinding) {
253
+ for (var si = 0; si < n.strokes.length; si++) {
254
+ var stroke = n.strokes[si];
255
+ if (stroke.type === 'SOLID' && stroke.visible !== false) {
256
+ var strokeHex = toHex(stroke.color);
257
+ stats.hardcodedColors++;
258
+ if (flag('hardcoded-stroke-color', n.id))
259
+ issues.push({ category: 'tokens', rule: 'hardcoded-stroke-color', severity: 'warning',
260
+ message: 'Stroke color ' + strokeHex + ' on "' + n.name + '" is not bound to a color variable', nodeId: n.id });
261
+ break;
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ // Effect (shadow / glow) colors
268
+ if (n.effects && Array.isArray(n.effects) && n.effects.length > 0) {
269
+ var hasEffectBinding = isBound(n, 'effects');
270
+ if (!hasEffectBinding) {
271
+ for (var ei = 0; ei < n.effects.length; ei++) {
272
+ var effect = n.effects[ei];
273
+ if ((effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') &&
274
+ effect.visible !== false && effect.color) {
275
+ var effectHex = toHex(effect.color);
276
+ if (flag('hardcoded-effect-color', n.id))
277
+ issues.push({ category: 'tokens', rule: 'hardcoded-effect-color', severity: 'warning',
278
+ message: 'Shadow color ' + effectHex + ' on "' + n.name + '" is not bound to a color variable', nodeId: n.id });
279
+ break;
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // Opacity
286
+ if (typeof n.opacity === 'number' && n.opacity < 1 && !isBound(n, 'opacity')) {
287
+ if (flag('hardcoded-opacity', n.id))
288
+ issues.push({ category: 'tokens', rule: 'hardcoded-opacity', severity: 'info',
289
+ message: 'Opacity ' + Math.round(n.opacity * 100) + '% on "' + n.name + '" is not bound to a variable', nodeId: n.id });
290
+ }
291
+
292
+ // ── TOKEN COMPLIANCE — Spacing ────────────────────────────────────────
293
+
294
+ var spacingProps = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'itemSpacing', 'counterAxisSpacing'];
295
+ spacingProps.forEach(function(prop) {
296
+ var val = n[prop];
297
+ if (typeof val === 'number' && val > 0 && !isBound(n, prop)) {
298
+ stats.hardcodedSpacing++;
299
+ if (flag('hardcoded-spacing::' + prop, n.id))
300
+ issues.push({ category: 'tokens', rule: 'hardcoded-spacing', severity: 'warning',
301
+ message: prop + ' = ' + val + 'px on "' + n.name + '" is not bound to a spacing variable',
302
+ nodeId: n.id, details: { property: prop, value: val } });
303
+
304
+ // Non-standard spacing (off the 4px grid)
305
+ if (!onGrid(val)) {
306
+ if (flag('non-standard-spacing::' + prop, n.id))
307
+ issues.push({ category: 'layout', rule: 'non-standard-spacing', severity: 'info',
308
+ message: prop + ' = ' + val + 'px on "' + n.name + '" is not on the 4px grid',
309
+ nodeId: n.id, details: { property: prop, value: val } });
310
+ }
311
+ }
312
+ });
313
+
314
+ // ── TOKEN COMPLIANCE — Border radius ──────────────────────────────────
315
+
316
+ var radiusProps = ['cornerRadius', 'topLeftRadius', 'topRightRadius', 'bottomLeftRadius', 'bottomRightRadius'];
317
+ radiusProps.forEach(function(prop) {
318
+ var val = n[prop];
319
+ if (typeof val === 'number' && val > 0 && !isBound(n, prop)) {
320
+ if (flag('hardcoded-border-radius', n.id))
321
+ issues.push({ category: 'tokens', rule: 'hardcoded-border-radius', severity: 'warning',
322
+ message: 'Corner radius ' + val + 'px on "' + n.name + '" is not bound to a variable',
323
+ nodeId: n.id, details: { property: prop, value: val } });
324
+ }
325
+ });
326
+
327
+ // ── TOKEN COMPLIANCE — Typography ─────────────────────────────────────
328
+
329
+ if (n.type === 'TEXT') {
330
+ stats.textNodes++;
331
+
332
+ // Empty text
333
+ if (!n.characters || n.characters.trim() === '') {
334
+ if (flag('empty-text', n.id))
335
+ issues.push({ category: 'hygiene', rule: 'empty-text', severity: 'warning',
336
+ message: 'Empty text node: "' + n.name + '"', nodeId: n.id });
337
+ }
338
+
339
+ // Missing text style (no shared style applied)
340
+ var hasTextStyle = n.textStyleId && typeof n.textStyleId === 'string' && n.textStyleId.trim() !== '';
341
+ if (!hasTextStyle) {
342
+ stats.missingTextStyles++;
343
+ if (flag('missing-text-style', n.id))
344
+ issues.push({ category: 'tokens', rule: 'missing-text-style', severity: 'warning',
345
+ message: 'Text node "' + n.name + '" does not use a shared text style',
346
+ nodeId: n.id,
347
+ details: {
348
+ fontSize: typeof n.fontSize === 'number' ? n.fontSize : null,
349
+ fontFamily: n.fontName ? n.fontName.family : null,
350
+ fontWeight: n.fontWeight || null
351
+ }
352
+ });
353
+ }
354
+
355
+ // Hardcoded font size (not bound to a variable, even if a text style is applied)
356
+ if (typeof n.fontSize === 'number' && !isBound(n, 'fontSize') && !hasTextStyle) {
357
+ stats.hardcodedFontSizes++;
358
+ if (flag('hardcoded-font-size', n.id))
359
+ issues.push({ category: 'tokens', rule: 'hardcoded-font-size', severity: 'warning',
360
+ message: 'Font size ' + n.fontSize + 'px on "' + n.name + '" is not bound to a variable',
361
+ nodeId: n.id, details: { fontSize: n.fontSize } });
362
+ }
363
+
364
+ // Font size off the standard type scale (if no style and size is unusual)
365
+ if (typeof n.fontSize === 'number' && !hasTextStyle) {
366
+ var typeScale = [10, 11, 12, 13, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96];
367
+ if (typeScale.indexOf(n.fontSize) === -1) {
368
+ if (flag('off-type-scale', n.id))
369
+ issues.push({ category: 'tokens', rule: 'off-type-scale', severity: 'info',
370
+ message: 'Font size ' + n.fontSize + 'px on "' + n.name + '" is not on a standard type scale',
371
+ nodeId: n.id });
372
+ }
373
+ }
374
+ }
375
+
376
+ if (n.children) {
377
+ for (var ci = 0; ci < n.children.length; ci++) {
378
+ walk(n.children[ci], depth + 1);
379
+ }
380
+ }
381
+ }
382
+
383
+ walk(node, 0);
384
+
385
+ // ── Scoring ──────────────────────────────────────────────────────────────
386
+ var errors = issues.filter(function(i) { return i.severity === 'error'; }).length;
387
+ var warnings = issues.filter(function(i) { return i.severity === 'warning'; }).length;
388
+ var infos = issues.filter(function(i) { return i.severity === 'info'; }).length;
389
+ var score = Math.max(0, 100 - errors * 15 - warnings * 5 - infos * 2);
390
+
391
+ // ── Group issues by category for the report ───────────────────────────────
392
+ var byCategory = {};
393
+ issues.forEach(function(issue) {
394
+ var cat = issue.category || 'other';
395
+ if (!byCategory[cat]) byCategory[cat] = [];
396
+ byCategory[cat].push(issue);
397
+ });
398
+
399
+ return {
400
+ id: node.id,
401
+ name: node.name,
402
+ type: node.type,
403
+ score: score,
404
+ summary: { errors: errors, warnings: warnings, info: infos, total: issues.length },
405
+ stats: stats,
406
+ byCategory: byCategory,
407
+ issues: issues
408
+ };
409
+ }
410
+ `;
411
+
412
+ // ─── CLI formatters ────────────────────────────────────────────────────────
413
+
414
+ const CATEGORY_LABELS = {
415
+ tokens: 'Token Compliance',
416
+ structure: 'Component Structure',
417
+ layout: 'Layout & Organisation',
418
+ hygiene: 'Layer Hygiene',
419
+ };
420
+
421
+ /**
422
+ * Format a single component audit result for terminal output.
423
+ */
424
+ export function formatAuditResult(result, chalk, verbose = true) {
425
+ if (result.error) return chalk.red('✗ ' + result.error);
426
+
427
+ const lines = [];
428
+ const scoreColor = result.score >= 80 ? chalk.green : result.score >= 60 ? chalk.yellow : chalk.red;
429
+ const scoreLabel = result.score >= 80 ? 'Good' : result.score >= 60 ? 'Fair' : 'Needs work';
430
+
431
+ lines.push(`\n${chalk.bold(result.name)} ${chalk.gray('(' + result.type + ')')}`);
432
+ lines.push(
433
+ ` Score: ${scoreColor(result.score + '/100')} ${chalk.gray('(' + scoreLabel + ')')}` +
434
+ ` ${chalk.red(result.summary.errors + ' errors')}` +
435
+ ` ${chalk.yellow(result.summary.warnings + ' warnings')}` +
436
+ ` ${chalk.gray(result.summary.info + ' info')}`
437
+ );
438
+
439
+ const s = result.stats;
440
+ lines.push(chalk.gray(
441
+ ` Stats: ${s.textNodes} text nodes` +
442
+ (s.missingTextStyles ? chalk.yellow(` (${s.missingTextStyles} missing style)`) : '') +
443
+ `, ${s.instances} instances` +
444
+ (s.detachedInstances ? chalk.red(` (${s.detachedInstances} detached)`) : '') +
445
+ `, ${s.hiddenNodes} hidden` +
446
+ (s.hardcodedColors ? chalk.yellow(`, ${s.hardcodedColors} hardcoded colors`) : '') +
447
+ (s.hardcodedSpacing ? chalk.yellow(`, ${s.hardcodedSpacing} hardcoded spacing`) : '') +
448
+ `, max depth ${s.maxDepth}`
449
+ ));
450
+
451
+ // Group by category
452
+ const byCategory = result.byCategory || {};
453
+ const catOrder = ['structure', 'tokens', 'layout', 'hygiene'];
454
+ let hasOutput = false;
455
+
456
+ for (const cat of catOrder) {
457
+ const catIssues = (byCategory[cat] || []).filter(i => verbose || i.severity !== 'info');
458
+ if (catIssues.length === 0) continue;
459
+ hasOutput = true;
460
+ lines.push('');
461
+ lines.push(chalk.dim(' ' + (CATEGORY_LABELS[cat] || cat)));
462
+ for (const issue of catIssues) {
463
+ const icon = issue.severity === 'error' ? chalk.red(' ✗') :
464
+ issue.severity === 'warning' ? chalk.yellow(' ⚠') : chalk.gray(' ℹ');
465
+ lines.push(`${icon} ${issue.message} ${chalk.gray('[' + issue.rule + ']')}`);
466
+ }
467
+ }
468
+
469
+ if (!hasOutput) {
470
+ lines.push(chalk.green(' ✓ No issues found'));
471
+ }
472
+
473
+ return lines.join('\n');
474
+ }
475
+
476
+ /**
477
+ * Format the all-components audit result for terminal output.
478
+ */
479
+ export function formatAllAuditResult(result, chalk, verbose = false) {
480
+ if (result.error) return chalk.red('✗ ' + result.error);
481
+
482
+ const lines = [];
483
+ const comps = result.components || [];
484
+ const totalErrors = comps.reduce((s, c) => s + c.summary.errors, 0);
485
+ const totalWarnings = comps.reduce((s, c) => s + c.summary.warnings, 0);
486
+ const totalIssues = comps.reduce((s, c) => s + c.summary.total, 0);
487
+ const avgScore = comps.length
488
+ ? Math.round(comps.reduce((s, c) => s + c.score, 0) / comps.length)
489
+ : 0;
490
+
491
+ lines.push('');
492
+ lines.push(chalk.bold(`Component Audit — ${result.page}`));
493
+ lines.push(
494
+ ` ${comps.length} component${comps.length !== 1 ? 's' : ''} scanned` +
495
+ ` ${totalIssues} total issues` +
496
+ ` ${chalk.red(totalErrors + ' errors')}` +
497
+ ` ${chalk.yellow(totalWarnings + ' warnings')}` +
498
+ ` Avg score: ${avgScore}/100`
499
+ );
500
+ lines.push('');
501
+
502
+ // Sort worst-first
503
+ const sorted = [...comps].sort((a, b) => a.score - b.score);
504
+ for (const comp of sorted) {
505
+ lines.push(formatAuditResult(comp, chalk, verbose));
506
+ }
507
+
508
+ lines.push('');
509
+ lines.push(chalk.gray('─'.repeat(60)));
510
+ const good = comps.filter(c => c.score >= 80).length;
511
+ const fair = comps.filter(c => c.score >= 60 && c.score < 80).length;
512
+ const bad = comps.filter(c => c.score < 60).length;
513
+ lines.push(
514
+ chalk.green(good + ' good') + ` ` +
515
+ chalk.yellow(fair + ' fair') + ` ` +
516
+ chalk.red(bad + ' need work')
517
+ );
518
+
519
+ return lines.join('\n');
520
+ }
package/src/index.js CHANGED
@@ -20,6 +20,10 @@ import {
20
20
  STAGE1_METADATA, buildFrameStructureCode, buildUsedTokensCode, formatLeanContext
21
21
  } from './read.js';
22
22
  import { generatePrompt } from './prompt-templates.js';
23
+ import {
24
+ buildSingleAuditCode, buildAllAuditCode, buildSelectionAuditCode,
25
+ formatAuditResult, formatAllAuditResult
26
+ } from './component-audit.js';
23
27
  import {
24
28
  nullDevice, killPort, getPortPid, sleepAfterStop,
25
29
  startFigmaApp, killFigmaApp,
@@ -11199,4 +11203,121 @@ Examples:
11199
11203
  }
11200
11204
  });
11201
11205
 
11206
+ // ─── Component Audit ────────────────────────────────────────────────────────
11207
+
11208
+ program
11209
+ .command('component-audit [name]')
11210
+ .description('Audit components for quality issues (naming, tokens, layout, variants, detached instances)')
11211
+ .option('--all', 'Audit ALL components on the current page')
11212
+ .option('--node <nodeId>', 'Audit a specific component by node ID')
11213
+ .option('--json', 'Output raw JSON')
11214
+ .option('--verbose', 'Show info-level issues in addition to errors/warnings')
11215
+ .addHelpText('after', `
11216
+ Checks run on every component:
11217
+ missing-description No description set on the component
11218
+ incomplete-variants Component set is missing expected variant combinations
11219
+ hidden-layer Child layer is hidden (dead weight)
11220
+ generic-layer-name Layer has a default name like "Frame 2" or "Rectangle"
11221
+ empty-text Text node with no content
11222
+ hardcoded-color Solid fill with no variable binding
11223
+ no-auto-layout Frame with 2+ children but no auto layout
11224
+ detached-instance Instance whose main component is missing
11225
+ deep-nesting Node nested 7+ levels deep
11226
+
11227
+ Severity:
11228
+ error Must fix — structural or correctness problem
11229
+ warning Should fix — design system or quality concern
11230
+ info Nice to fix — best practice recommendation (shown with --verbose)
11231
+
11232
+ Examples:
11233
+ fig component-audit Audit current selection
11234
+ fig component-audit "Button" Audit the component named "Button"
11235
+ fig component-audit --all Audit every component on this page
11236
+ fig component-audit --node "123:456" Audit by node ID
11237
+ fig component-audit --all --json Machine-readable output
11238
+ fig component-audit --all --verbose Include info-level issues
11239
+ `)
11240
+ .action(async (name, options) => {
11241
+ await checkConnection();
11242
+ const spinner = ora('Running component audit...').start();
11243
+
11244
+ try {
11245
+ // ── Determine which audit code to run ──────────────────────────────────
11246
+ let code;
11247
+ let isAll = false;
11248
+
11249
+ if (options.all) {
11250
+ isAll = true;
11251
+ code = buildAllAuditCode();
11252
+ spinner.text = 'Auditing all components on page...';
11253
+ } else if (options.node) {
11254
+ code = buildSingleAuditCode(options.node);
11255
+ spinner.text = `Auditing node ${options.node}...`;
11256
+ } else if (name) {
11257
+ // Find component by name via metadata, then audit it
11258
+ spinner.text = `Looking up component "${name}"...`;
11259
+ const findCode = `
11260
+ (function() {
11261
+ function findByName(node, n) {
11262
+ if ((node.type === 'COMPONENT' || node.type === 'COMPONENT_SET' || node.type === 'FRAME') &&
11263
+ node.name.toLowerCase() === n.toLowerCase()) return node.id;
11264
+ if (node.children) {
11265
+ for (var i = 0; i < node.children.length; i++) {
11266
+ var found = findByName(node.children[i], n);
11267
+ if (found) return found;
11268
+ }
11269
+ }
11270
+ return null;
11271
+ }
11272
+ return findByName(figma.currentPage, ${JSON.stringify(name)});
11273
+ })()
11274
+ `;
11275
+ const nodeId = await daemonExec('eval', { code: findCode });
11276
+ if (!nodeId) {
11277
+ spinner.fail(`No component named "${name}" found on the current page.`);
11278
+ process.exit(1);
11279
+ }
11280
+ code = buildSingleAuditCode(nodeId);
11281
+ spinner.text = `Auditing "${name}"...`;
11282
+ } else {
11283
+ // Default: audit current selection
11284
+ code = buildSelectionAuditCode();
11285
+ spinner.text = 'Auditing selection...';
11286
+ }
11287
+
11288
+ const result = await daemonExec('eval', { code });
11289
+
11290
+ if (result && result.error) {
11291
+ spinner.fail(result.error);
11292
+ process.exit(1);
11293
+ }
11294
+
11295
+ // ── Output ─────────────────────────────────────────────────────────────
11296
+ if (isAll) {
11297
+ const comps = result.components || [];
11298
+ spinner.succeed(
11299
+ `Audited ${comps.length} component${comps.length !== 1 ? 's' : ''} on page "${result.page}"`
11300
+ );
11301
+ if (options.json) {
11302
+ console.log(JSON.stringify(result, null, 2));
11303
+ } else {
11304
+ console.log(formatAllAuditResult(result, chalk, options.verbose || false));
11305
+ }
11306
+ } else {
11307
+ const score = result.score;
11308
+ const label = score >= 80 ? 'Good' : score >= 60 ? 'Fair' : 'Needs work';
11309
+ spinner.succeed(`Audited "${result.name}" — score ${score}/100 (${label})`);
11310
+ if (options.json) {
11311
+ console.log(JSON.stringify(result, null, 2));
11312
+ } else {
11313
+ console.log(formatAuditResult(result, chalk, options.verbose !== false));
11314
+ }
11315
+ }
11316
+ } catch (e) {
11317
+ spinner.fail(`Audit failed: ${e.message}`);
11318
+ process.exit(1);
11319
+ }
11320
+ });
11321
+
11202
11322
  program.parse();
11323
+