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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-local",
3
- "version": "2.1.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",
@@ -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 descriptions", "detached instances", "incomplete variants". Requires a Figma file to be open and the daemon connected.
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
- Audit Figma components for design-system quality issues. Returns a score (0–100) and a categorized list of issues per component.
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
- This is the most useful command for a full design-system review. It scans every `COMPONENT` and `COMPONENT_SET` on the page, ranks them by score (worst first), and prints a summary.
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
- By default, only errors and warnings are shown. Add `--verbose` to also see info-level suggestions:
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
- ## What Gets Checked
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
- | `missing-description` | warning | Component has no description set |
72
- | `incomplete-variants` | warning | Component set is missing expected variant combinations |
73
- | `hidden-layer` | info | A child layer is hidden (dead weight in the file) |
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 containing your component library
92
- 2. Make sure `fig` is connected: `fig daemon status`
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. Review the outputcomponents sorted worst-first
98
- 5. For a detailed look at the worst component:
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 the issues in Figma, then re-run to verify improvement
131
+ 6. Fix issues in Figma, then re-run to verify improvement
103
132
 
104
133
  ## Tips
105
134
 
106
- - Run `--all --json` to save a baseline report and compare over time
107
- - `detached-instance` errors (score −15 each) are the highest priority to fix
108
- - `hardcoded-color` warnings usually mean a token should be created in your variable collection
109
- - Use `fig var list` to see available variable collections before fixing hardcoded colors
110
- - `incomplete-variants` means your component set has a property with N options but fewer than the expected NxM combinations
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
@@ -1,22 +1,38 @@
1
1
  /**
2
2
  * component-audit.js — Figma component audit logic
3
3
  *
4
- * Generates Figma plugin JS code that inspects components for:
5
- * - Naming issues (unnamed layers, generic names)
6
- * - Missing descriptions
7
- * - Hardcoded colors (no variable bindings)
8
- * - Missing auto layout
9
- * - Hidden layers (dead weight)
10
- * - Empty text nodes
11
- * - Excessive nesting depth (>6 levels)
12
- * - Variant completeness for component sets
13
- * - Detached instances within a component
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
- * Build the Figma JS code to audit a single component node by ID.
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; // children are COMPONENT variants — covered by set audit
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
- // Shared helper code injected into every eval string.
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 = { textNodes: 0, hiddenNodes: 0, instances: 0, detachedInstances: 0, maxDepth: 0 };
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', message: 'Component has no description' });
131
+ issues.push({ category: 'structure', rule: 'missing-description', severity: 'warning',
132
+ message: 'Component has no description' });
104
133
  }
105
- }
106
134
 
107
- // ── 2. Variant completeness (COMPONENT_SET only) ─────────────────────────
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
- var variantProps = propKeys.filter(function(k) { return propDefs[k].type === 'VARIANT'; });
112
- if (variantProps.length > 0) {
113
- var expected = 1;
114
- variantProps.forEach(function(k) {
115
- expected *= (propDefs[k].variantOptions || []).length;
116
- });
117
- var actual = (node.children || []).length;
118
- if (actual < expected) {
119
- issues.push({
120
- rule: 'incomplete-variants',
121
- severity: 'warning',
122
- message: 'Variant set has ' + actual + ' of ' + expected + ' expected combinations',
123
- details: { expected: expected, actual: actual, properties: variantProps }
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
- // ── 3. Deep tree walk ────────────────────────────────────────────────────
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
- // Hidden layers
178
+ // ── Layer hygiene ────────────────────────────────────────────────────
179
+
136
180
  if (depth > 0 && n.visible === false) {
137
181
  stats.hiddenNodes++;
138
- issues.push({ rule: 'hidden-layer', severity: 'info', message: 'Hidden layer: "' + n.name + '"', nodeId: n.id });
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
- issues.push({ rule: 'generic-layer-name', severity: 'info', message: 'Generic layer name: "' + n.name + '"', nodeId: n.id });
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
- // Text nodes
147
- if (n.type === 'TEXT') {
148
- stats.textNodes++;
149
- if (!n.characters || n.characters.trim() === '') {
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
- // Hardcoded colors fills with no variable binding
155
- if (n.fills && Array.isArray(n.fills)) {
156
- for (var i = 0; i < n.fills.length; i++) {
157
- var fill = n.fills[i];
158
- if (fill.type === 'SOLID' && fill.visible !== false) {
159
- var hasBinding = n.boundVariables && n.boundVariables.fills;
160
- if (!hasBinding) {
161
- var r = Math.round((fill.color.r || 0) * 255);
162
- var g = Math.round((fill.color.g || 0) * 255);
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
- // Missing auto layout on FRAME nodes that contain multiple children
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
- issues.push({ rule: 'no-auto-layout', severity: 'info', message: 'Frame "' + n.name + '" has ' + childCount + ' children but no auto layout', nodeId: n.id });
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
- // Instances (check for detached)
183
- if (n.type === 'INSTANCE') {
184
- stats.instances++;
185
- if (!n.mainComponent) {
186
- stats.detachedInstances++;
187
- issues.push({ rule: 'detached-instance', severity: 'error', message: 'Detached instance: "' + n.name + '" — main component missing', nodeId: n.id });
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
- // Excessive nesting
192
- if (depth === 7) {
193
- issues.push({ rule: 'deep-nesting', severity: 'info', message: 'Node "' + n.name + '" is nested 7+ levels deep', nodeId: n.id });
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
- // ── 4. Score ─────────────────────────────────────────────────────────────
206
- var errors = issues.filter(function(i) { return i.severity === 'error'; }).length;
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'; }).length;
209
- var score = Math.max(0, 100 - errors * 15 - warnings * 5 - infos * 2);
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
- // Formatter — turns raw audit result into human-readable CLI output
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 shown = result.issues.filter(i => verbose || i.severity !== 'info');
256
- if (shown.length === 0) {
257
- lines.push(chalk.green(' No issues found'));
258
- } else {
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
- for (const issue of shown) {
261
- const icon = issue.severity === 'error' ? chalk.red('✗') :
262
- issue.severity === 'warning' ? chalk.yellow('') : chalk.gray('ℹ');
263
- const ruleTag = chalk.gray(`[${issue.rule}]`);
264
- lines.push(` ${icon} ${issue.message} ${ruleTag}`);
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 avgScore = comps.length ? Math.round(comps.reduce((s, c) => s + c.score, 0) / comps.length) : 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;
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
- chalk.red(totalErrors + ' errors') + ' ' +
291
- chalk.yellow(totalWarnings + ' warnings') + ' ' +
292
- `Avg score: ${avgScore}/100`
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: worst score first
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
- `${comps.filter(c => c.score >= 80).length} good ` +
307
- `${comps.filter(c => c.score >= 60 && c.score < 80).length} fair ` +
308
- `${comps.filter(c => c.score < 60).length} need work`
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');