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 +1 -1
- package/skills/figma-component-audit/SKILL.md +141 -0
- package/src/component-audit.js +520 -0
- package/src/index.js +121 -0
package/package.json
CHANGED
|
@@ -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
|
+
|