figma-local 2.1.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 +55 -24
- package/src/component-audit.js +345 -137
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: figma-component-audit
|
|
3
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", "missing
|
|
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
5
|
allowed-tools:
|
|
6
6
|
- Bash(fig component-audit *)
|
|
7
7
|
- Bash(fig component-audit)
|
|
@@ -10,7 +10,7 @@ allowed-tools:
|
|
|
10
10
|
|
|
11
11
|
# Figma Component Audit
|
|
12
12
|
|
|
13
|
-
|
|
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
14
|
|
|
15
15
|
## Prerequisites
|
|
16
16
|
|
|
@@ -40,7 +40,7 @@ fig component-audit "Navigation Bar"
|
|
|
40
40
|
fig component-audit --all
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
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
44
|
|
|
45
45
|
### Audit by node ID
|
|
46
46
|
|
|
@@ -57,26 +57,55 @@ fig component-audit --all --json > audit-report.json
|
|
|
57
57
|
|
|
58
58
|
### Include info-level issues
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Errors and warnings are shown by default. Add `--verbose` to also see info-level suggestions:
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
63
|
fig component-audit --all --verbose
|
|
64
64
|
fig component-audit "Button" --verbose
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
##
|
|
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
|
|
68
103
|
|
|
69
104
|
| Rule | Severity | What it flags |
|
|
70
105
|
|------|----------|---------------|
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `generic-layer-name` | info | Layer has a default name like "Frame 2" or "Rectangle" |
|
|
75
|
-
| `empty-text` | warning | A text node exists but has no content |
|
|
76
|
-
| `hardcoded-color` | warning | A solid fill color with no variable binding |
|
|
77
|
-
| `no-auto-layout` | info | A frame with 2+ children but no auto layout enabled |
|
|
78
|
-
| `detached-instance` | error | An instance whose main component is missing |
|
|
79
|
-
| `deep-nesting` | info | A node nested 7+ levels deep |
|
|
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 |
|
|
80
109
|
|
|
81
110
|
## Scoring
|
|
82
111
|
|
|
@@ -88,23 +117,25 @@ fig component-audit "Button" --verbose
|
|
|
88
117
|
|
|
89
118
|
## Workflow: Full Design-System Audit
|
|
90
119
|
|
|
91
|
-
1. Open the Figma file
|
|
92
|
-
2.
|
|
120
|
+
1. Open the Figma file with your component library
|
|
121
|
+
2. Confirm connection: `fig daemon status`
|
|
93
122
|
3. Run the full audit:
|
|
94
123
|
```bash
|
|
95
124
|
fig component-audit --all
|
|
96
125
|
```
|
|
97
|
-
4.
|
|
98
|
-
5.
|
|
126
|
+
4. Components are sorted worst-first — start with the lowest scores
|
|
127
|
+
5. Dig into a specific component with verbose output:
|
|
99
128
|
```bash
|
|
100
129
|
fig component-audit "ComponentName" --verbose
|
|
101
130
|
```
|
|
102
|
-
6. Fix
|
|
131
|
+
6. Fix issues in Figma, then re-run to verify improvement
|
|
103
132
|
|
|
104
133
|
## Tips
|
|
105
134
|
|
|
106
|
-
-
|
|
107
|
-
- `
|
|
108
|
-
- `
|
|
109
|
-
- Use `fig
|
|
110
|
-
-
|
|
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
|
package/src/component-audit.js
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* component-audit.js — Figma component audit logic
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
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
|
|
14
32
|
*/
|
|
15
33
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* @param {string} nodeId
|
|
19
|
-
*/
|
|
34
|
+
// ─── Figma eval code builders ──────────────────────────────────────────────
|
|
35
|
+
|
|
20
36
|
export function buildSingleAuditCode(nodeId) {
|
|
21
37
|
return `
|
|
22
38
|
(function() {
|
|
@@ -31,9 +47,6 @@ export function buildSingleAuditCode(nodeId) {
|
|
|
31
47
|
`;
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
/**
|
|
35
|
-
* Build the Figma JS code to audit ALL components on the current page.
|
|
36
|
-
*/
|
|
37
50
|
export function buildAllAuditCode() {
|
|
38
51
|
return `
|
|
39
52
|
(function() {
|
|
@@ -43,36 +56,26 @@ export function buildAllAuditCode() {
|
|
|
43
56
|
function collectComponents(node) {
|
|
44
57
|
if (node.type === 'COMPONENT_SET') {
|
|
45
58
|
results.push(auditComponent(node));
|
|
46
|
-
return;
|
|
59
|
+
return;
|
|
47
60
|
}
|
|
48
61
|
if (node.type === 'COMPONENT') {
|
|
49
|
-
// Skip components that are children of a COMPONENT_SET (audited via the set)
|
|
50
62
|
if (!node.parent || node.parent.type !== 'COMPONENT_SET') {
|
|
51
63
|
results.push(auditComponent(node));
|
|
52
64
|
}
|
|
53
65
|
return;
|
|
54
66
|
}
|
|
55
67
|
if (node.children) {
|
|
56
|
-
for (var i = 0; i < node.children.length; i++)
|
|
57
|
-
collectComponents(node.children[i]);
|
|
58
|
-
}
|
|
68
|
+
for (var i = 0; i < node.children.length; i++) collectComponents(node.children[i]);
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
collectComponents(page);
|
|
63
|
-
return {
|
|
64
|
-
page: page.name,
|
|
65
|
-
total: results.length,
|
|
66
|
-
components: results
|
|
67
|
-
};
|
|
73
|
+
return { page: page.name, total: results.length, components: results };
|
|
68
74
|
${AUDIT_HELPERS}
|
|
69
75
|
})()
|
|
70
76
|
`;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
/**
|
|
74
|
-
* Build the Figma JS code to audit the current selection.
|
|
75
|
-
*/
|
|
76
79
|
export function buildSelectionAuditCode() {
|
|
77
80
|
return `
|
|
78
81
|
(function() {
|
|
@@ -88,109 +91,286 @@ export function buildSelectionAuditCode() {
|
|
|
88
91
|
`;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
// Written as a plain string so it can be appended inside the IIFE.
|
|
94
|
-
// ---------------------------------------------------------------------------
|
|
94
|
+
// ─── Shared Figma helpers (injected as a string into every eval IIFE) ──────
|
|
95
|
+
|
|
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
|
+
|
|
96
116
|
function auditComponent(node) {
|
|
97
117
|
var issues = [];
|
|
98
|
-
var stats = {
|
|
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
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
99
126
|
|
|
100
|
-
// ── 1. Description check ─────────────────────────────────────────────────
|
|
101
127
|
if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
|
|
128
|
+
|
|
129
|
+
// 1a. Missing description
|
|
102
130
|
if (!node.description || node.description.trim() === '') {
|
|
103
|
-
issues.push({ rule: 'missing-description', severity: 'warning',
|
|
131
|
+
issues.push({ category: 'structure', rule: 'missing-description', severity: 'warning',
|
|
132
|
+
message: 'Component has no description' });
|
|
104
133
|
}
|
|
105
|
-
}
|
|
106
134
|
|
|
107
|
-
|
|
108
|
-
if (node.type === 'COMPONENT_SET') {
|
|
135
|
+
// 1b. No exposed properties
|
|
109
136
|
var propDefs = node.componentPropertyDefinitions || {};
|
|
110
137
|
var propKeys = Object.keys(propDefs);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
}
|
|
125
156
|
}
|
|
126
157
|
}
|
|
127
158
|
}
|
|
128
159
|
|
|
129
|
-
//
|
|
160
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
161
|
+
// CATEGORIES 2-4 — Deep tree walk
|
|
162
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
163
|
+
|
|
130
164
|
var GENERIC_NAMES = /^(Frame|Rectangle|Ellipse|Group|Vector|Polygon|Star|Line|Image|Component)\\s*\\d*$/i;
|
|
131
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
|
+
|
|
132
175
|
function walk(n, depth) {
|
|
133
176
|
if (depth > stats.maxDepth) stats.maxDepth = depth;
|
|
134
177
|
|
|
135
|
-
//
|
|
178
|
+
// ── Layer hygiene ────────────────────────────────────────────────────
|
|
179
|
+
|
|
136
180
|
if (depth > 0 && n.visible === false) {
|
|
137
181
|
stats.hiddenNodes++;
|
|
138
|
-
|
|
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 });
|
|
139
185
|
}
|
|
140
186
|
|
|
141
|
-
// Generic / unnamed layer
|
|
142
187
|
if (depth > 0 && GENERIC_NAMES.test(n.name)) {
|
|
143
|
-
|
|
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 });
|
|
144
191
|
}
|
|
145
192
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
issues.push({ rule: 'empty-text', severity: 'warning', message: 'Empty text node: "' + n.name + '"', nodeId: n.id });
|
|
151
|
-
}
|
|
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 });
|
|
152
197
|
}
|
|
153
198
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
var b = Math.round((fill.color.b || 0) * 255);
|
|
164
|
-
var hex = '#' + r.toString(16).padStart(2,'0') + g.toString(16).padStart(2,'0') + b.toString(16).padStart(2,'0');
|
|
165
|
-
// Only flag non-transparent, non-white fills that look like intentional colors
|
|
166
|
-
if (hex !== '#ffffff' && hex !== '#000000' && !(r === g && g === b)) {
|
|
167
|
-
issues.push({ rule: 'hardcoded-color', severity: 'warning', message: 'Hardcoded fill color ' + hex + ' on "' + n.name + '" — consider using a variable', nodeId: n.id });
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
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 });
|
|
171
208
|
}
|
|
172
209
|
}
|
|
173
210
|
|
|
174
|
-
//
|
|
211
|
+
// ── Layout ───────────────────────────────────────────────────────────
|
|
212
|
+
|
|
175
213
|
if (n.type === 'FRAME' && depth > 0) {
|
|
176
214
|
var childCount = (n.children || []).length;
|
|
177
215
|
if (childCount >= 2 && n.layoutMode === 'NONE') {
|
|
178
|
-
|
|
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 });
|
|
179
219
|
}
|
|
180
220
|
}
|
|
181
221
|
|
|
182
|
-
//
|
|
183
|
-
if (n.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|
|
188
246
|
}
|
|
189
247
|
}
|
|
190
248
|
|
|
191
|
-
//
|
|
192
|
-
if (
|
|
193
|
-
|
|
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
|
+
}
|
|
194
374
|
}
|
|
195
375
|
|
|
196
376
|
if (n.children) {
|
|
@@ -202,33 +382,44 @@ const AUDIT_HELPERS = `
|
|
|
202
382
|
|
|
203
383
|
walk(node, 0);
|
|
204
384
|
|
|
205
|
-
// ──
|
|
206
|
-
var errors = issues.filter(function(i) { return i.severity === 'error';
|
|
385
|
+
// ── Scoring ──────────────────────────────────────────────────────────────
|
|
386
|
+
var errors = issues.filter(function(i) { return i.severity === 'error'; }).length;
|
|
207
387
|
var warnings = issues.filter(function(i) { return i.severity === 'warning'; }).length;
|
|
208
|
-
var infos = issues.filter(function(i) { return i.severity === 'info';
|
|
209
|
-
var score
|
|
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
|
+
});
|
|
210
398
|
|
|
211
399
|
return {
|
|
212
400
|
id: node.id,
|
|
213
401
|
name: node.name,
|
|
214
402
|
type: node.type,
|
|
215
403
|
score: score,
|
|
216
|
-
summary: { errors: errors, warnings: warnings, info: infos },
|
|
404
|
+
summary: { errors: errors, warnings: warnings, info: infos, total: issues.length },
|
|
217
405
|
stats: stats,
|
|
406
|
+
byCategory: byCategory,
|
|
218
407
|
issues: issues
|
|
219
408
|
};
|
|
220
409
|
}
|
|
221
410
|
`;
|
|
222
411
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
};
|
|
226
420
|
|
|
227
421
|
/**
|
|
228
422
|
* Format a single component audit result for terminal output.
|
|
229
|
-
* @param {object} result - from auditComponent()
|
|
230
|
-
* @param {object} chalk - chalk instance
|
|
231
|
-
* @param {boolean} verbose - show info-level issues
|
|
232
423
|
*/
|
|
233
424
|
export function formatAuditResult(result, chalk, verbose = true) {
|
|
234
425
|
if (result.error) return chalk.red('✗ ' + result.error);
|
|
@@ -239,40 +430,51 @@ export function formatAuditResult(result, chalk, verbose = true) {
|
|
|
239
430
|
|
|
240
431
|
lines.push(`\n${chalk.bold(result.name)} ${chalk.gray('(' + result.type + ')')}`);
|
|
241
432
|
lines.push(
|
|
242
|
-
` Score: ${scoreColor(result.score + '/100')} ${chalk.gray('(' + scoreLabel + ')')}
|
|
243
|
-
chalk.red(result.summary.errors + ' errors') +
|
|
244
|
-
chalk.yellow(result.summary.warnings + ' warnings') +
|
|
245
|
-
chalk.gray(result.summary.info + ' info')
|
|
246
|
-
);
|
|
247
|
-
lines.push(
|
|
248
|
-
chalk.gray(
|
|
249
|
-
` Stats: ${result.stats.textNodes} text nodes, ${result.stats.instances} instances` +
|
|
250
|
-
(result.stats.detachedInstances ? chalk.red(` (${result.stats.detachedInstances} detached)`) : '') +
|
|
251
|
-
`, ${result.stats.hiddenNodes} hidden, max depth ${result.stats.maxDepth}`
|
|
252
|
-
)
|
|
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')}`
|
|
253
437
|
);
|
|
254
438
|
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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;
|
|
259
460
|
lines.push('');
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
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 + ']')}`);
|
|
265
466
|
}
|
|
266
467
|
}
|
|
267
468
|
|
|
469
|
+
if (!hasOutput) {
|
|
470
|
+
lines.push(chalk.green(' ✓ No issues found'));
|
|
471
|
+
}
|
|
472
|
+
|
|
268
473
|
return lines.join('\n');
|
|
269
474
|
}
|
|
270
475
|
|
|
271
476
|
/**
|
|
272
477
|
* Format the all-components audit result for terminal output.
|
|
273
|
-
* @param {object} result - { page, total, components[] }
|
|
274
|
-
* @param {object} chalk
|
|
275
|
-
* @param {boolean} verbose
|
|
276
478
|
*/
|
|
277
479
|
export function formatAllAuditResult(result, chalk, verbose = false) {
|
|
278
480
|
if (result.error) return chalk.red('✗ ' + result.error);
|
|
@@ -281,31 +483,37 @@ export function formatAllAuditResult(result, chalk, verbose = false) {
|
|
|
281
483
|
const comps = result.components || [];
|
|
282
484
|
const totalErrors = comps.reduce((s, c) => s + c.summary.errors, 0);
|
|
283
485
|
const totalWarnings = comps.reduce((s, c) => s + c.summary.warnings, 0);
|
|
284
|
-
const
|
|
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;
|
|
285
490
|
|
|
286
491
|
lines.push('');
|
|
287
492
|
lines.push(chalk.bold(`Component Audit — ${result.page}`));
|
|
288
493
|
lines.push(
|
|
289
|
-
` ${comps.length} component${comps.length !== 1 ? 's' : ''} scanned
|
|
290
|
-
|
|
291
|
-
chalk.
|
|
292
|
-
`
|
|
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`
|
|
293
499
|
);
|
|
294
500
|
lines.push('');
|
|
295
501
|
|
|
296
|
-
// Sort
|
|
502
|
+
// Sort worst-first
|
|
297
503
|
const sorted = [...comps].sort((a, b) => a.score - b.score);
|
|
298
|
-
|
|
299
504
|
for (const comp of sorted) {
|
|
300
505
|
lines.push(formatAuditResult(comp, chalk, verbose));
|
|
301
506
|
}
|
|
302
507
|
|
|
303
508
|
lines.push('');
|
|
304
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;
|
|
305
513
|
lines.push(
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
514
|
+
chalk.green(good + ' good') + ` ` +
|
|
515
|
+
chalk.yellow(fair + ' fair') + ` ` +
|
|
516
|
+
chalk.red(bad + ' need work')
|
|
309
517
|
);
|
|
310
518
|
|
|
311
519
|
return lines.join('\n');
|