@mp3wizard/figma-console-mcp 1.14.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/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
- package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
- package/dist/apps/design-system-dashboard/server.d.ts +24 -0
- package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/server.js +160 -0
- package/dist/apps/design-system-dashboard/server.js.map +1 -0
- package/dist/apps/token-browser/server.d.ts +26 -0
- package/dist/apps/token-browser/server.d.ts.map +1 -0
- package/dist/apps/token-browser/server.js +137 -0
- package/dist/apps/token-browser/server.js.map +1 -0
- package/dist/browser/base.d.ts +58 -0
- package/dist/browser/base.d.ts.map +1 -0
- package/dist/browser/base.js +6 -0
- package/dist/browser/base.js.map +1 -0
- package/dist/browser/local.d.ts +87 -0
- package/dist/browser/local.d.ts.map +1 -0
- package/dist/browser/local.js +318 -0
- package/dist/browser/local.js.map +1 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
- package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
- package/dist/cloudflare/apps/token-browser/server.js +136 -0
- package/dist/cloudflare/browser/base.js +5 -0
- package/dist/cloudflare/browser/cloudflare.js +156 -0
- package/dist/cloudflare/browser-manager.js +157 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +267 -0
- package/dist/cloudflare/core/cloud-websocket-relay.js +199 -0
- package/dist/cloudflare/core/comment-tools.js +292 -0
- package/dist/cloudflare/core/config.js +161 -0
- package/dist/cloudflare/core/console-monitor.js +427 -0
- package/dist/cloudflare/core/design-code-tools.js +2504 -0
- package/dist/cloudflare/core/design-system-manifest.js +260 -0
- package/dist/cloudflare/core/design-system-tools.js +863 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
- package/dist/cloudflare/core/enrichment/index.js +7 -0
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
- package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
- package/dist/cloudflare/core/figma-api.js +409 -0
- package/dist/cloudflare/core/figma-connector.js +7 -0
- package/dist/cloudflare/core/figma-desktop-connector.js +1184 -0
- package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
- package/dist/cloudflare/core/figma-style-extractor.js +311 -0
- package/dist/cloudflare/core/figma-tools.js +2947 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/port-discovery.js +282 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/types/design-code.js +4 -0
- package/dist/cloudflare/core/types/enriched.js +5 -0
- package/dist/cloudflare/core/types/index.js +4 -0
- package/dist/cloudflare/core/websocket-connector.js +256 -0
- package/dist/cloudflare/core/websocket-server.js +646 -0
- package/dist/cloudflare/core/write-tools.js +2091 -0
- package/dist/cloudflare/index.js +2899 -0
- package/dist/cloudflare/test-browser.js +88 -0
- package/dist/core/comment-tools.d.ts +11 -0
- package/dist/core/comment-tools.d.ts.map +1 -0
- package/dist/core/comment-tools.js +293 -0
- package/dist/core/comment-tools.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +162 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/console-monitor.d.ts +82 -0
- package/dist/core/console-monitor.d.ts.map +1 -0
- package/dist/core/console-monitor.js +428 -0
- package/dist/core/console-monitor.js.map +1 -0
- package/dist/core/design-code-tools.d.ts +127 -0
- package/dist/core/design-code-tools.d.ts.map +1 -0
- package/dist/core/design-code-tools.js +2505 -0
- package/dist/core/design-code-tools.js.map +1 -0
- package/dist/core/design-system-manifest.d.ts +272 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -0
- package/dist/core/design-system-manifest.js +261 -0
- package/dist/core/design-system-manifest.js.map +1 -0
- package/dist/core/design-system-tools.d.ts +17 -0
- package/dist/core/design-system-tools.d.ts.map +1 -0
- package/dist/core/design-system-tools.js +864 -0
- package/dist/core/design-system-tools.js.map +1 -0
- package/dist/core/enrichment/enrichment-service.d.ts +52 -0
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
- package/dist/core/enrichment/enrichment-service.js +273 -0
- package/dist/core/enrichment/enrichment-service.js.map +1 -0
- package/dist/core/enrichment/index.d.ts +8 -0
- package/dist/core/enrichment/index.d.ts.map +1 -0
- package/dist/core/enrichment/index.js +8 -0
- package/dist/core/enrichment/index.js.map +1 -0
- package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
- package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
- package/dist/core/enrichment/relationship-mapper.js +352 -0
- package/dist/core/enrichment/relationship-mapper.js.map +1 -0
- package/dist/core/enrichment/style-resolver.d.ts +80 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +327 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figma-api.d.ts +201 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +410 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-connector.d.ts +48 -0
- package/dist/core/figma-connector.d.ts.map +1 -0
- package/dist/core/figma-connector.js +8 -0
- package/dist/core/figma-connector.js.map +1 -0
- package/dist/core/figma-desktop-connector.d.ts +265 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +1184 -0
- package/dist/core/figma-desktop-connector.js.map +1 -0
- package/dist/core/figma-reconstruction-spec.d.ts +166 -0
- package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
- package/dist/core/figma-reconstruction-spec.js +403 -0
- package/dist/core/figma-reconstruction-spec.js.map +1 -0
- package/dist/core/figma-style-extractor.d.ts +76 -0
- package/dist/core/figma-style-extractor.d.ts.map +1 -0
- package/dist/core/figma-style-extractor.js +312 -0
- package/dist/core/figma-style-extractor.js.map +1 -0
- package/dist/core/figma-tools.d.ts +23 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +2948 -0
- package/dist/core/figma-tools.js.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/port-discovery.d.ts +110 -0
- package/dist/core/port-discovery.d.ts.map +1 -0
- package/dist/core/port-discovery.js +283 -0
- package/dist/core/port-discovery.js.map +1 -0
- package/dist/core/snippet-injector.d.ts +24 -0
- package/dist/core/snippet-injector.d.ts.map +1 -0
- package/dist/core/snippet-injector.js +97 -0
- package/dist/core/snippet-injector.js.map +1 -0
- package/dist/core/types/design-code.d.ts +262 -0
- package/dist/core/types/design-code.d.ts.map +1 -0
- package/dist/core/types/design-code.js +5 -0
- package/dist/core/types/design-code.js.map +1 -0
- package/dist/core/types/enriched.d.ts +213 -0
- package/dist/core/types/enriched.d.ts.map +1 -0
- package/dist/core/types/enriched.js +6 -0
- package/dist/core/types/enriched.js.map +1 -0
- package/dist/core/types/index.d.ts +112 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +5 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/core/websocket-connector.d.ts +55 -0
- package/dist/core/websocket-connector.d.ts.map +1 -0
- package/dist/core/websocket-connector.js +257 -0
- package/dist/core/websocket-connector.js.map +1 -0
- package/dist/core/websocket-server.d.ts +191 -0
- package/dist/core/websocket-server.d.ts.map +1 -0
- package/dist/core/websocket-server.js +647 -0
- package/dist/core/websocket-server.js.map +1 -0
- package/dist/core/write-tools.d.ts +7 -0
- package/dist/core/write-tools.d.ts.map +1 -0
- package/dist/core/write-tools.js +2092 -0
- package/dist/core/write-tools.js.map +1 -0
- package/dist/local.d.ts +84 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +5039 -0
- package/dist/local.js.map +1 -0
- package/figma-desktop-bridge/README.md +313 -0
- package/figma-desktop-bridge/code.js +2818 -0
- package/figma-desktop-bridge/manifest.json +67 -0
- package/figma-desktop-bridge/ui.html +1236 -0
- package/package.json +87 -0
|
@@ -0,0 +1,2505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design-Code Parity Checker & Documentation Generator
|
|
3
|
+
* MCP tools for comparing Figma design specs with code-side data
|
|
4
|
+
* and generating platform-agnostic component documentation.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { extractFileKey } from "./figma-api.js";
|
|
8
|
+
import { createChildLogger } from "./logger.js";
|
|
9
|
+
import { EnrichmentService } from "./enrichment/index.js";
|
|
10
|
+
const logger = createChildLogger({ component: "design-code-tools" });
|
|
11
|
+
const enrichmentService = new EnrichmentService(logger);
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Shared Helpers
|
|
14
|
+
// ============================================================================
|
|
15
|
+
/** Convert Figma RGBA (0-1 floats) to hex string */
|
|
16
|
+
export function figmaRGBAToHex(color) {
|
|
17
|
+
const r = Math.round(color.r * 255);
|
|
18
|
+
const g = Math.round(color.g * 255);
|
|
19
|
+
const b = Math.round(color.b * 255);
|
|
20
|
+
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
|
|
21
|
+
if (color.a !== undefined && color.a < 1) {
|
|
22
|
+
const a = Math.round(color.a * 255);
|
|
23
|
+
return `${hex}${a.toString(16).padStart(2, "0")}`;
|
|
24
|
+
}
|
|
25
|
+
return hex;
|
|
26
|
+
}
|
|
27
|
+
/** Normalize a color string for comparison (uppercase hex without alpha if fully opaque) */
|
|
28
|
+
export function normalizeColor(color) {
|
|
29
|
+
let c = color.trim().toUpperCase();
|
|
30
|
+
// Strip alpha if fully opaque (FF)
|
|
31
|
+
if (c.length === 9 && c.endsWith("FF")) {
|
|
32
|
+
c = c.slice(0, 7);
|
|
33
|
+
}
|
|
34
|
+
// Expand shorthand (#RGB -> #RRGGBB)
|
|
35
|
+
if (/^#[0-9A-F]{3}$/.test(c)) {
|
|
36
|
+
c = `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
|
|
37
|
+
}
|
|
38
|
+
return c;
|
|
39
|
+
}
|
|
40
|
+
/** Compare numeric values with a tolerance */
|
|
41
|
+
export function numericClose(a, b, tolerance = 1) {
|
|
42
|
+
return Math.abs(a - b) <= tolerance;
|
|
43
|
+
}
|
|
44
|
+
/** Calculate parity score from discrepancy counts */
|
|
45
|
+
export function calculateParityScore(critical, major, minor, info) {
|
|
46
|
+
return Math.max(0, 100 - (critical * 15 + major * 8 + minor * 3 + info * 1));
|
|
47
|
+
}
|
|
48
|
+
/** Extract first solid fill color from Figma node */
|
|
49
|
+
function extractFirstFillColor(fills) {
|
|
50
|
+
if (!fills || !Array.isArray(fills))
|
|
51
|
+
return null;
|
|
52
|
+
const solid = fills.find((f) => f.type === "SOLID" && f.visible !== false);
|
|
53
|
+
if (!solid?.color)
|
|
54
|
+
return null;
|
|
55
|
+
return figmaRGBAToHex({ ...solid.color, a: solid.opacity ?? solid.color.a ?? 1 });
|
|
56
|
+
}
|
|
57
|
+
/** Extract first stroke color from Figma node */
|
|
58
|
+
function extractFirstStrokeColor(strokes) {
|
|
59
|
+
if (!strokes || !Array.isArray(strokes))
|
|
60
|
+
return null;
|
|
61
|
+
const solid = strokes.find((s) => s.type === "SOLID" && s.visible !== false);
|
|
62
|
+
if (!solid?.color)
|
|
63
|
+
return null;
|
|
64
|
+
return figmaRGBAToHex({ ...solid.color, a: solid.opacity ?? solid.color.a ?? 1 });
|
|
65
|
+
}
|
|
66
|
+
/** Extract text style properties from a Figma text node */
|
|
67
|
+
function extractTextProperties(node) {
|
|
68
|
+
const style = node.style || {};
|
|
69
|
+
const result = {};
|
|
70
|
+
if (style.fontFamily)
|
|
71
|
+
result.fontFamily = style.fontFamily;
|
|
72
|
+
if (style.fontSize)
|
|
73
|
+
result.fontSize = style.fontSize;
|
|
74
|
+
if (style.fontWeight)
|
|
75
|
+
result.fontWeight = style.fontWeight;
|
|
76
|
+
if (style.lineHeightPx)
|
|
77
|
+
result.lineHeight = style.lineHeightPx;
|
|
78
|
+
if (style.letterSpacing)
|
|
79
|
+
result.letterSpacing = style.letterSpacing;
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
/** Extract spacing/layout properties from a Figma node */
|
|
83
|
+
function extractSpacingProperties(node) {
|
|
84
|
+
const result = {};
|
|
85
|
+
if (node.paddingTop !== undefined)
|
|
86
|
+
result.paddingTop = node.paddingTop;
|
|
87
|
+
if (node.paddingRight !== undefined)
|
|
88
|
+
result.paddingRight = node.paddingRight;
|
|
89
|
+
if (node.paddingBottom !== undefined)
|
|
90
|
+
result.paddingBottom = node.paddingBottom;
|
|
91
|
+
if (node.paddingLeft !== undefined)
|
|
92
|
+
result.paddingLeft = node.paddingLeft;
|
|
93
|
+
if (node.itemSpacing !== undefined)
|
|
94
|
+
result.gap = node.itemSpacing;
|
|
95
|
+
if (node.absoluteBoundingBox) {
|
|
96
|
+
result.width = node.absoluteBoundingBox.width;
|
|
97
|
+
result.height = node.absoluteBoundingBox.height;
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
/** Map Figma font weight number to CSS font weight */
|
|
102
|
+
function figmaFontWeight(weight) {
|
|
103
|
+
// Figma already uses numeric weights
|
|
104
|
+
return weight;
|
|
105
|
+
}
|
|
106
|
+
/** Split markdown by H2 headers for platforms that need chunking */
|
|
107
|
+
export function chunkMarkdownByHeaders(markdown) {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
const lines = markdown.split("\n");
|
|
110
|
+
let currentHeading = "";
|
|
111
|
+
let currentContent = [];
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.startsWith("## ")) {
|
|
114
|
+
if (currentHeading || currentContent.length > 0) {
|
|
115
|
+
chunks.push({ heading: currentHeading, content: currentContent.join("\n").trim() });
|
|
116
|
+
}
|
|
117
|
+
currentHeading = line.replace("## ", "").trim();
|
|
118
|
+
currentContent = [];
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
currentContent.push(line);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (currentHeading || currentContent.length > 0) {
|
|
125
|
+
chunks.push({ heading: currentHeading, content: currentContent.join("\n").trim() });
|
|
126
|
+
}
|
|
127
|
+
return chunks;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Clean a raw Figma variant name like "Type=Image, Size=12" into "Image / 12".
|
|
131
|
+
* Extracts just the values from "Key=Value" pairs, joined by " / ".
|
|
132
|
+
*/
|
|
133
|
+
export function cleanVariantName(rawName) {
|
|
134
|
+
// Match Key=Value pairs separated by comma/space
|
|
135
|
+
const pairs = rawName.match(/(\w[\w\s]*)=([^,]+)/g);
|
|
136
|
+
if (!pairs || pairs.length === 0)
|
|
137
|
+
return rawName;
|
|
138
|
+
const values = pairs.map((p) => {
|
|
139
|
+
const eqIdx = p.indexOf("=");
|
|
140
|
+
return p.slice(eqIdx + 1).trim();
|
|
141
|
+
});
|
|
142
|
+
return values.join(" / ");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse a Figma component description into structured sections.
|
|
146
|
+
* Handles markdown-formatted descriptions with headers, bullet points, etc.
|
|
147
|
+
*/
|
|
148
|
+
export function parseComponentDescription(description) {
|
|
149
|
+
const result = {
|
|
150
|
+
overview: "",
|
|
151
|
+
whenToUse: [],
|
|
152
|
+
whenNotToUse: [],
|
|
153
|
+
contentGuidelines: [],
|
|
154
|
+
accessibilityNotes: [],
|
|
155
|
+
additionalNotes: [],
|
|
156
|
+
};
|
|
157
|
+
if (!description)
|
|
158
|
+
return result;
|
|
159
|
+
// Pre-process: split inline section headers onto their own lines.
|
|
160
|
+
// Figma sometimes concatenates headers without newlines: "...sentence.When to Use\n- bullet..."
|
|
161
|
+
// We detect known section header patterns appearing after sentence-ending chars (word, period, paren)
|
|
162
|
+
// but NOT after markdown formatting like ** or ## to avoid breaking formatted headers.
|
|
163
|
+
const inlineHeaderPatterns = [
|
|
164
|
+
/(?<=[\w.)!?])(When\s+to\s+Use)/gi,
|
|
165
|
+
/(?<=[\w.)!?])(When\s+NOT\s+to\s+Use)/gi,
|
|
166
|
+
/(?<=[\w.)!?])(When\s+to\s+Not\s+Use)/gi,
|
|
167
|
+
/(?<=[\w.)!?])(Don'?t\s+Use)/gi,
|
|
168
|
+
/(?<=[\w.)!?])(Accessibility)/gi,
|
|
169
|
+
/(?<=[\w.)!?])(Content\s+Requirements?)/gi,
|
|
170
|
+
/(?<=[\w.)!?])(Content\s+Guidelines?)/gi,
|
|
171
|
+
/(?<=[\w.)!?])(Writing\s+Guidelines?)/gi,
|
|
172
|
+
/(?<=[\w.)!?])(Variants\b)/gi,
|
|
173
|
+
];
|
|
174
|
+
let normalized = description.replace(/\r\n/g, "\n");
|
|
175
|
+
for (const pattern of inlineHeaderPatterns) {
|
|
176
|
+
normalized = normalized.replace(pattern, "\n$1");
|
|
177
|
+
}
|
|
178
|
+
// Normalize line endings and split
|
|
179
|
+
const lines = normalized.split("\n");
|
|
180
|
+
let currentSection = "overview";
|
|
181
|
+
let currentContentHeading = "";
|
|
182
|
+
const overviewLines = [];
|
|
183
|
+
// Known plain-text section header patterns (for descriptions without markdown formatting)
|
|
184
|
+
const plainTextHeaders = [
|
|
185
|
+
{ pattern: /^when\s+to\s+use$/i, section: "when_to_use" },
|
|
186
|
+
{ pattern: /^when\s+not\s+to\s+use$/i, section: "when_not_to_use" },
|
|
187
|
+
{ pattern: /^when\s+to\s+not\s+use$/i, section: "when_not_to_use" },
|
|
188
|
+
{ pattern: /^don'?t\s+use$/i, section: "when_not_to_use" },
|
|
189
|
+
{ pattern: /^do\s+not\s+use$/i, section: "when_not_to_use" },
|
|
190
|
+
{ pattern: /^accessibility$/i, section: "accessibility" },
|
|
191
|
+
{ pattern: /^a11y$/i, section: "accessibility" },
|
|
192
|
+
{ pattern: /^content\s*(requirements|guidelines)?$/i, section: "content", getHeading: true },
|
|
193
|
+
{ pattern: /^writing\s*(guidelines)?$/i, section: "content", getHeading: true },
|
|
194
|
+
{ pattern: /^copy\s*(guidelines)?$/i, section: "content", getHeading: true },
|
|
195
|
+
{ pattern: /^variants$/i, section: "other" },
|
|
196
|
+
];
|
|
197
|
+
// Detect Figma per-property documentation headers like:
|
|
198
|
+
// "Show Left Icon: True – Purpose", "Badge Text – Purpose", "Nested Instance: Checkbox – Purpose"
|
|
199
|
+
const propertyDocPattern = /[–-]\s*Purpose\s*$/i;
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const trimmed = line.trim();
|
|
202
|
+
// Detect section headers: bold text (**Header**), markdown headers (## Header), or plain text exact matches
|
|
203
|
+
const markdownHeaderMatch = trimmed.match(/^(?:\*\*|###?\s*)(.+?)(?:\*\*)?$/);
|
|
204
|
+
const headerText = markdownHeaderMatch ? markdownHeaderMatch[1].trim().replace(/\*\*/g, "") : null;
|
|
205
|
+
// Check if this is a Figma per-property documentation block (e.g., "Show Left Icon: True – Purpose")
|
|
206
|
+
// These should be routed to "other" to avoid polluting content guidelines and accessibility sections
|
|
207
|
+
const rawTextForPropertyCheck = headerText || trimmed;
|
|
208
|
+
if (propertyDocPattern.test(rawTextForPropertyCheck)) {
|
|
209
|
+
currentSection = "other";
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// Check plain-text headers first (exact line matches for known patterns)
|
|
213
|
+
let plainMatch = null;
|
|
214
|
+
if (!headerText) {
|
|
215
|
+
for (const ph of plainTextHeaders) {
|
|
216
|
+
if (ph.pattern.test(trimmed)) {
|
|
217
|
+
plainMatch = { section: ph.section, heading: ph.getHeading ? trimmed : "" };
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Resolve the effective header
|
|
223
|
+
const effectiveHeader = headerText || plainMatch?.heading || null;
|
|
224
|
+
const isHeader = headerText !== null || plainMatch !== null;
|
|
225
|
+
if (isHeader) {
|
|
226
|
+
// If we matched a plain-text header with a known section, use it directly
|
|
227
|
+
if (plainMatch) {
|
|
228
|
+
if (plainMatch.section === "content" && plainMatch.heading) {
|
|
229
|
+
currentContentHeading = plainMatch.heading;
|
|
230
|
+
result.contentGuidelines.push({ heading: plainMatch.heading, items: [] });
|
|
231
|
+
}
|
|
232
|
+
currentSection = plainMatch.section;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
// Otherwise process the markdown header text
|
|
236
|
+
const lower = (effectiveHeader || "").toLowerCase();
|
|
237
|
+
if (lower.includes("when to use") && !lower.includes("not")) {
|
|
238
|
+
currentSection = "when_to_use";
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
else if (lower.includes("when not to use") || lower.includes("when to not use") || lower.includes("don't use") || lower.includes("do not use")) {
|
|
242
|
+
currentSection = "when_not_to_use";
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
else if (lower.includes("accessibility") || lower.includes("a11y") || lower.includes("aria")) {
|
|
246
|
+
currentSection = "accessibility";
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
else if (lower.includes("content") || lower.includes("title text") || lower.includes("description text") || lower.includes("button label") || lower.includes("writing") || lower.includes("copy")) {
|
|
250
|
+
currentSection = "content";
|
|
251
|
+
currentContentHeading = effectiveHeader || "";
|
|
252
|
+
result.contentGuidelines.push({ heading: effectiveHeader || "", items: [] });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
else if (currentSection === "overview") {
|
|
256
|
+
// A header after overview text means we're moving to a new section
|
|
257
|
+
currentSection = "other";
|
|
258
|
+
// Check if this might be a content guideline sub-section
|
|
259
|
+
if (lower.includes("title") || lower.includes("description") || lower.includes("label") || lower.includes("variant")) {
|
|
260
|
+
currentSection = "content";
|
|
261
|
+
currentContentHeading = effectiveHeader || "";
|
|
262
|
+
result.contentGuidelines.push({ heading: effectiveHeader || "", items: [] });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (currentSection === "content") {
|
|
267
|
+
// New sub-heading within content guidelines
|
|
268
|
+
currentContentHeading = effectiveHeader || "";
|
|
269
|
+
result.contentGuidelines.push({ heading: effectiveHeader || "", items: [] });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Skip empty lines (but don't change section)
|
|
274
|
+
if (!trimmed)
|
|
275
|
+
continue;
|
|
276
|
+
// Skip horizontal rules
|
|
277
|
+
if (/^---+$/.test(trimmed))
|
|
278
|
+
continue;
|
|
279
|
+
// Extract bullet content
|
|
280
|
+
const bulletMatch = trimmed.match(/^[-*•]\s*(.+)/);
|
|
281
|
+
const content = bulletMatch ? bulletMatch[1] : trimmed;
|
|
282
|
+
switch (currentSection) {
|
|
283
|
+
case "overview":
|
|
284
|
+
overviewLines.push(content);
|
|
285
|
+
break;
|
|
286
|
+
case "when_to_use":
|
|
287
|
+
result.whenToUse.push(content);
|
|
288
|
+
break;
|
|
289
|
+
case "when_not_to_use":
|
|
290
|
+
result.whenNotToUse.push(content);
|
|
291
|
+
break;
|
|
292
|
+
case "content": {
|
|
293
|
+
const last = result.contentGuidelines[result.contentGuidelines.length - 1];
|
|
294
|
+
if (last)
|
|
295
|
+
last.items.push(content);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "accessibility":
|
|
299
|
+
result.accessibilityNotes.push(content);
|
|
300
|
+
break;
|
|
301
|
+
case "other":
|
|
302
|
+
result.additionalNotes.push(content);
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
result.overview = overviewLines.join(" ").trim();
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Collect color data from all variants in a COMPONENT_SET.
|
|
311
|
+
* For single COMPONENTs, returns data for just that component.
|
|
312
|
+
*/
|
|
313
|
+
export function collectAllVariantData(node, varNameMap) {
|
|
314
|
+
const variants = [];
|
|
315
|
+
const nodesToWalk = node.type === "COMPONENT_SET" && node.children?.length > 0
|
|
316
|
+
? node.children
|
|
317
|
+
: [node];
|
|
318
|
+
for (const variant of nodesToWalk) {
|
|
319
|
+
const data = {
|
|
320
|
+
variantName: variant.name || "Default",
|
|
321
|
+
fills: [],
|
|
322
|
+
strokes: [],
|
|
323
|
+
textColors: [],
|
|
324
|
+
icons: [],
|
|
325
|
+
};
|
|
326
|
+
walkVariantNode(variant, data, varNameMap, 0, 5);
|
|
327
|
+
variants.push(data);
|
|
328
|
+
}
|
|
329
|
+
return variants;
|
|
330
|
+
}
|
|
331
|
+
/** Walk a single variant node tree to collect colors and icons */
|
|
332
|
+
function walkVariantNode(node, data, varNameMap, depth, maxDepth) {
|
|
333
|
+
if (depth > maxDepth)
|
|
334
|
+
return;
|
|
335
|
+
const isText = node.type === "TEXT";
|
|
336
|
+
// Check if this is an icon instance
|
|
337
|
+
if (node.type === "INSTANCE" && (node.name?.toLowerCase().includes("icon") ||
|
|
338
|
+
node.name?.toLowerCase().startsWith("icon"))) {
|
|
339
|
+
const iconName = node.name.replace(/^icon\s*\/?\s*/i, "").trim();
|
|
340
|
+
data.icons.push({ name: iconName || node.name, type: "instance" });
|
|
341
|
+
}
|
|
342
|
+
// Collect fills
|
|
343
|
+
if (node.fills && Array.isArray(node.fills)) {
|
|
344
|
+
for (const fill of node.fills) {
|
|
345
|
+
if (fill.type === "SOLID" && fill.color && fill.visible !== false) {
|
|
346
|
+
const hex = figmaRGBAToHex({ ...fill.color, a: fill.opacity ?? fill.color.a ?? 1 });
|
|
347
|
+
const varId = fill.boundVariables?.color?.id;
|
|
348
|
+
const entry = {
|
|
349
|
+
hex,
|
|
350
|
+
nodeName: node.name || "",
|
|
351
|
+
variableId: varId,
|
|
352
|
+
variableName: varId ? varNameMap.get(varId) : undefined,
|
|
353
|
+
};
|
|
354
|
+
if (isText) {
|
|
355
|
+
data.textColors.push(entry);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
data.fills.push(entry);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Collect strokes
|
|
364
|
+
if (node.strokes && Array.isArray(node.strokes)) {
|
|
365
|
+
for (const stroke of node.strokes) {
|
|
366
|
+
if (stroke.type === "SOLID" && stroke.color && stroke.visible !== false) {
|
|
367
|
+
const hex = figmaRGBAToHex({ ...stroke.color, a: stroke.opacity ?? stroke.color.a ?? 1 });
|
|
368
|
+
const varId = stroke.boundVariables?.color?.id;
|
|
369
|
+
data.strokes.push({
|
|
370
|
+
hex,
|
|
371
|
+
nodeName: node.name || "",
|
|
372
|
+
variableId: varId,
|
|
373
|
+
variableName: varId ? varNameMap.get(varId) : undefined,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Recurse into children
|
|
379
|
+
if (node.children && Array.isArray(node.children)) {
|
|
380
|
+
for (const child of node.children) {
|
|
381
|
+
walkVariantNode(child, data, varNameMap, depth + 1, maxDepth);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Collect typography data from all text nodes in a component tree.
|
|
387
|
+
*/
|
|
388
|
+
export function collectTypographyData(node, depth = 0, maxDepth = 5) {
|
|
389
|
+
const results = [];
|
|
390
|
+
if (depth > maxDepth)
|
|
391
|
+
return results;
|
|
392
|
+
// For COMPONENT_SET, walk the default (first) variant
|
|
393
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 0 && depth === 0) {
|
|
394
|
+
return collectTypographyData(node.children[0], 0, maxDepth);
|
|
395
|
+
}
|
|
396
|
+
if (node.type === "TEXT" && node.style) {
|
|
397
|
+
const s = node.style;
|
|
398
|
+
const weightNames = {
|
|
399
|
+
100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular",
|
|
400
|
+
500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black",
|
|
401
|
+
};
|
|
402
|
+
results.push({
|
|
403
|
+
nodeName: node.name || "Text",
|
|
404
|
+
fontFamily: s.fontFamily || "Unknown",
|
|
405
|
+
fontWeight: s.fontWeight || 400,
|
|
406
|
+
fontWeightName: weightNames[s.fontWeight] || String(s.fontWeight),
|
|
407
|
+
fontSize: s.fontSize || 14,
|
|
408
|
+
lineHeight: s.lineHeightPx || s.fontSize || 14,
|
|
409
|
+
letterSpacing: s.letterSpacing || 0,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (node.children && Array.isArray(node.children)) {
|
|
413
|
+
for (const child of node.children) {
|
|
414
|
+
results.push(...collectTypographyData(child, depth + 1, maxDepth));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return results;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Build an anatomy tree representation from a Figma node structure.
|
|
421
|
+
* Returns a formatted string showing the component's nested structure.
|
|
422
|
+
*/
|
|
423
|
+
export function buildAnatomyTree(node, depth = 0, maxDepth = 5) {
|
|
424
|
+
if (depth > maxDepth)
|
|
425
|
+
return "";
|
|
426
|
+
// For COMPONENT_SET, pick the variant with the deepest children tree for the richest anatomy
|
|
427
|
+
let targetNode = node;
|
|
428
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 0 && depth === 0) {
|
|
429
|
+
let bestChild = node.children[0];
|
|
430
|
+
let bestDepth = countChildDepth(bestChild);
|
|
431
|
+
for (let i = 1; i < node.children.length; i++) {
|
|
432
|
+
const d = countChildDepth(node.children[i]);
|
|
433
|
+
if (d > bestDepth) {
|
|
434
|
+
bestDepth = d;
|
|
435
|
+
bestChild = node.children[i];
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
targetNode = bestChild;
|
|
439
|
+
}
|
|
440
|
+
const lines = [];
|
|
441
|
+
buildAnatomyLines(targetNode, lines, "", true, 0, maxDepth);
|
|
442
|
+
return lines.join("\n");
|
|
443
|
+
}
|
|
444
|
+
/** Count the maximum depth of a node's children tree */
|
|
445
|
+
function countChildDepth(node) {
|
|
446
|
+
if (!node.children || !Array.isArray(node.children) || node.children.length === 0)
|
|
447
|
+
return 0;
|
|
448
|
+
let max = 0;
|
|
449
|
+
for (const child of node.children) {
|
|
450
|
+
const d = countChildDepth(child);
|
|
451
|
+
if (d > max)
|
|
452
|
+
max = d;
|
|
453
|
+
}
|
|
454
|
+
return 1 + max;
|
|
455
|
+
}
|
|
456
|
+
function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
457
|
+
if (depth > maxDepth)
|
|
458
|
+
return;
|
|
459
|
+
const connector = depth === 0 ? "" : (isLast ? "└── " : "├── ");
|
|
460
|
+
const childPrefix = depth === 0 ? "" : (isLast ? " " : "│ ");
|
|
461
|
+
// Build node label
|
|
462
|
+
let label = node.name || node.type;
|
|
463
|
+
const typeHint = node.type === "TEXT" ? " (TEXT)"
|
|
464
|
+
: node.type === "INSTANCE" ? " (INSTANCE)"
|
|
465
|
+
: node.type === "COMPONENT" ? " (COMPONENT)"
|
|
466
|
+
: node.type === "FRAME" ? ""
|
|
467
|
+
: node.type === "VECTOR" ? " (VECTOR)"
|
|
468
|
+
: node.type === "RECTANGLE" ? " (RECTANGLE)"
|
|
469
|
+
: "";
|
|
470
|
+
// Add layout info for frames
|
|
471
|
+
let layoutInfo = "";
|
|
472
|
+
if (node.layoutMode) {
|
|
473
|
+
const dir = node.layoutMode === "HORIZONTAL" ? "horizontal" : "vertical";
|
|
474
|
+
layoutInfo = ` — ${dir} auto-layout`;
|
|
475
|
+
if (node.itemSpacing !== undefined)
|
|
476
|
+
layoutInfo += `, gap: ${node.itemSpacing}px`;
|
|
477
|
+
}
|
|
478
|
+
// Add sizing info
|
|
479
|
+
let sizingInfo = "";
|
|
480
|
+
if (node.primaryAxisSizingMode || node.counterAxisSizingMode) {
|
|
481
|
+
const parts = [];
|
|
482
|
+
if (node.primaryAxisSizingMode === "FIXED")
|
|
483
|
+
parts.push("fixed-width");
|
|
484
|
+
if (node.primaryAxisSizingMode === "AUTO")
|
|
485
|
+
parts.push("hug-content");
|
|
486
|
+
if (node.counterAxisSizingMode === "FIXED")
|
|
487
|
+
parts.push("fixed-height");
|
|
488
|
+
if (node.layoutGrow === 1)
|
|
489
|
+
parts.push("fill");
|
|
490
|
+
if (parts.length > 0)
|
|
491
|
+
sizingInfo = ` [${parts.join(", ")}]`;
|
|
492
|
+
}
|
|
493
|
+
lines.push(`${prefix}${connector}${label}${typeHint}${layoutInfo}${sizingInfo}`);
|
|
494
|
+
// Recurse into children
|
|
495
|
+
if (node.children && Array.isArray(node.children)) {
|
|
496
|
+
const visibleChildren = node.children.filter((c) => c.visible !== false);
|
|
497
|
+
for (let i = 0; i < visibleChildren.length; i++) {
|
|
498
|
+
const isChildLast = i === visibleChildren.length - 1;
|
|
499
|
+
buildAnatomyLines(visibleChildren[i], lines, prefix + childPrefix, isChildLast, depth + 1, maxDepth);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Collect spacing tokens with their bound variable names.
|
|
505
|
+
*/
|
|
506
|
+
function collectSpacingTokens(node) {
|
|
507
|
+
const tokens = [];
|
|
508
|
+
const boundVars = node.boundVariables || {};
|
|
509
|
+
const spacingProps = [
|
|
510
|
+
{ key: "paddingTop", label: "Padding top" },
|
|
511
|
+
{ key: "paddingRight", label: "Padding right" },
|
|
512
|
+
{ key: "paddingBottom", label: "Padding bottom" },
|
|
513
|
+
{ key: "paddingLeft", label: "Padding left" },
|
|
514
|
+
{ key: "itemSpacing", label: "Gap" },
|
|
515
|
+
{ key: "cornerRadius", label: "Border radius" },
|
|
516
|
+
{ key: "strokeWeight", label: "Border width" },
|
|
517
|
+
];
|
|
518
|
+
for (const { key, label } of spacingProps) {
|
|
519
|
+
const value = node[key];
|
|
520
|
+
if (value !== undefined && value !== null) {
|
|
521
|
+
const varBinding = boundVars[key];
|
|
522
|
+
const varName = varBinding?.id || varBinding?.name;
|
|
523
|
+
tokens.push({
|
|
524
|
+
property: label,
|
|
525
|
+
value,
|
|
526
|
+
variableName: typeof varName === "string" ? varName : undefined,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return tokens;
|
|
531
|
+
}
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// Component Set Resolution Helpers
|
|
534
|
+
// ============================================================================
|
|
535
|
+
/**
|
|
536
|
+
* Resolve the node to use for visual/spacing/typography comparisons.
|
|
537
|
+
* COMPONENT_SET frames have container-level styling (Figma's purple dashed stroke,
|
|
538
|
+
* default cornerRadius: 5, organizational padding) that are NOT actual design specs.
|
|
539
|
+
* The real design properties live on the child COMPONENT variants.
|
|
540
|
+
* Returns the default variant (first child) for COMPONENT_SET, or the node itself otherwise.
|
|
541
|
+
*/
|
|
542
|
+
export function resolveVisualNode(node) {
|
|
543
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
|
|
544
|
+
return node.children[0];
|
|
545
|
+
}
|
|
546
|
+
return node;
|
|
547
|
+
}
|
|
548
|
+
/** Detect if a node name is a Figma variant pattern like "Variant=Default, State=Hover, Size=lg" */
|
|
549
|
+
export function isVariantName(name) {
|
|
550
|
+
return /^[A-Za-z]+=.+,.+[A-Za-z]+=/.test(name);
|
|
551
|
+
}
|
|
552
|
+
/** Sanitize a component name for use as a file path */
|
|
553
|
+
export function sanitizeComponentName(name) {
|
|
554
|
+
return name.replace(/[^a-zA-Z0-9-_ ]/g, "").replace(/\s+/g, "-").trim();
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Resolve the parent COMPONENT_SET info for a variant COMPONENT node.
|
|
558
|
+
* Returns the set's name, nodeId, and componentPropertyDefinitions.
|
|
559
|
+
*/
|
|
560
|
+
async function resolveComponentSetInfo(api, fileKey, nodeId, componentMeta, allComponentsMeta) {
|
|
561
|
+
const empty = { setName: null, setNodeId: null, propertyDefinitions: {} };
|
|
562
|
+
// Strategy 1: Check componentMeta for component_set_id (from /files/:key/components)
|
|
563
|
+
const meta = componentMeta || allComponentsMeta?.find((c) => c.node_id === nodeId);
|
|
564
|
+
const setId = meta?.component_set_id;
|
|
565
|
+
const setName = meta?.component_set_name || null;
|
|
566
|
+
if (!setId) {
|
|
567
|
+
// Strategy 2: Use containing_frame from component metadata
|
|
568
|
+
const containingFrame = meta?.containing_frame;
|
|
569
|
+
if (containingFrame?.containingComponentSet && containingFrame?.nodeId) {
|
|
570
|
+
const frameNodeId = containingFrame.nodeId;
|
|
571
|
+
try {
|
|
572
|
+
const setResponse = await api.getNodes(fileKey, [frameNodeId], { depth: 1 });
|
|
573
|
+
const setNode = setResponse?.nodes?.[frameNodeId]?.document;
|
|
574
|
+
if (setNode?.componentPropertyDefinitions) {
|
|
575
|
+
return {
|
|
576
|
+
setName: setNode.name || containingFrame.name || setName,
|
|
577
|
+
setNodeId: frameNodeId,
|
|
578
|
+
propertyDefinitions: setNode.componentPropertyDefinitions,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
logger.warn("Could not fetch component set via containing_frame");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Strategy 3: Search getComponentSets for a set matching this component's containing frame
|
|
587
|
+
try {
|
|
588
|
+
const setsResponse = await api.getComponentSets(fileKey);
|
|
589
|
+
const sets = setsResponse?.meta?.component_sets;
|
|
590
|
+
if (sets && Array.isArray(sets)) {
|
|
591
|
+
// Match by containing_frame name or by node name prefix
|
|
592
|
+
const nodeName = allComponentsMeta?.find((c) => c.node_id === nodeId)?.name || "";
|
|
593
|
+
const matchingSet = containingFrame?.name
|
|
594
|
+
? sets.find((s) => s.name === containingFrame.name)
|
|
595
|
+
: null;
|
|
596
|
+
if (matchingSet) {
|
|
597
|
+
const setNodeId = matchingSet.node_id;
|
|
598
|
+
const setNodeResponse = await api.getNodes(fileKey, [setNodeId], { depth: 1 });
|
|
599
|
+
const setNode = setNodeResponse?.nodes?.[setNodeId]?.document;
|
|
600
|
+
if (setNode?.componentPropertyDefinitions) {
|
|
601
|
+
return {
|
|
602
|
+
setName: setNode.name || matchingSet.name,
|
|
603
|
+
setNodeId,
|
|
604
|
+
propertyDefinitions: setNode.componentPropertyDefinitions,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
logger.warn("Could not resolve component set via getComponentSets");
|
|
612
|
+
}
|
|
613
|
+
return { ...empty, setName };
|
|
614
|
+
}
|
|
615
|
+
// Fetch the COMPONENT_SET node to get componentPropertyDefinitions
|
|
616
|
+
try {
|
|
617
|
+
const setResponse = await api.getNodes(fileKey, [setId], { depth: 1 });
|
|
618
|
+
const setNode = setResponse?.nodes?.[setId]?.document;
|
|
619
|
+
if (setNode) {
|
|
620
|
+
return {
|
|
621
|
+
setName: setNode.name || setName,
|
|
622
|
+
setNodeId: setId,
|
|
623
|
+
propertyDefinitions: setNode.componentPropertyDefinitions || {},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
logger.warn({ setId }, "Could not fetch component set node");
|
|
629
|
+
}
|
|
630
|
+
return { ...empty, setName };
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Extract a clean component name from a node, resolving variant patterns.
|
|
634
|
+
* "Variant=Default, State=Default, Size=default" -> uses parent set name or codeSpec fallback.
|
|
635
|
+
* Preserves the casing of whatever source provides the name (no assumptions about convention).
|
|
636
|
+
*/
|
|
637
|
+
function resolveComponentName(node, setName, fallbackName) {
|
|
638
|
+
// If we have a parent set name, prefer it (authoritative from Figma)
|
|
639
|
+
if (setName)
|
|
640
|
+
return setName;
|
|
641
|
+
// If the node name is a variant pattern, try to extract something useful
|
|
642
|
+
if (isVariantName(node.name)) {
|
|
643
|
+
// Use fallback (from codeSpec.metadata.name or file path) preserving original casing
|
|
644
|
+
if (fallbackName)
|
|
645
|
+
return fallbackName;
|
|
646
|
+
// Last resort: extract first variant value as name hint
|
|
647
|
+
return node.name.split(",")[0].split("=")[1]?.trim() || node.name;
|
|
648
|
+
}
|
|
649
|
+
return node.name || fallbackName || "Component";
|
|
650
|
+
}
|
|
651
|
+
// ============================================================================
|
|
652
|
+
// Parity Comparators
|
|
653
|
+
// ============================================================================
|
|
654
|
+
function compareVisual(node, codeSpec, discrepancies) {
|
|
655
|
+
const cv = codeSpec.visual;
|
|
656
|
+
if (!cv)
|
|
657
|
+
return;
|
|
658
|
+
// Background / fill color
|
|
659
|
+
const figmaFill = extractFirstFillColor(node.fills);
|
|
660
|
+
if (figmaFill && cv.backgroundColor) {
|
|
661
|
+
const normalizedDesign = normalizeColor(figmaFill);
|
|
662
|
+
const normalizedCode = normalizeColor(cv.backgroundColor);
|
|
663
|
+
if (normalizedDesign !== normalizedCode) {
|
|
664
|
+
discrepancies.push({
|
|
665
|
+
category: "visual",
|
|
666
|
+
property: "backgroundColor",
|
|
667
|
+
severity: "major",
|
|
668
|
+
designValue: figmaFill,
|
|
669
|
+
codeValue: cv.backgroundColor,
|
|
670
|
+
message: `Background color mismatch: design=${figmaFill}, code=${cv.backgroundColor}`,
|
|
671
|
+
suggestion: `Update to match ${figmaFill}`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// Border / stroke color
|
|
676
|
+
const figmaStroke = extractFirstStrokeColor(node.strokes);
|
|
677
|
+
if (figmaStroke && cv.borderColor) {
|
|
678
|
+
const normalizedDesign = normalizeColor(figmaStroke);
|
|
679
|
+
const normalizedCode = normalizeColor(cv.borderColor);
|
|
680
|
+
if (normalizedDesign !== normalizedCode) {
|
|
681
|
+
discrepancies.push({
|
|
682
|
+
category: "visual",
|
|
683
|
+
property: "borderColor",
|
|
684
|
+
severity: "major",
|
|
685
|
+
designValue: figmaStroke,
|
|
686
|
+
codeValue: cv.borderColor,
|
|
687
|
+
message: `Border color mismatch: design=${figmaStroke}, code=${cv.borderColor}`,
|
|
688
|
+
suggestion: `Update to match ${figmaStroke}`,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// Border width / stroke weight
|
|
693
|
+
if (node.strokeWeight !== undefined && cv.borderWidth !== undefined) {
|
|
694
|
+
if (!numericClose(node.strokeWeight, cv.borderWidth)) {
|
|
695
|
+
discrepancies.push({
|
|
696
|
+
category: "visual",
|
|
697
|
+
property: "borderWidth",
|
|
698
|
+
severity: "minor",
|
|
699
|
+
designValue: node.strokeWeight,
|
|
700
|
+
codeValue: cv.borderWidth,
|
|
701
|
+
message: `Border width mismatch: design=${node.strokeWeight}px, code=${cv.borderWidth}px`,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Corner radius
|
|
706
|
+
const figmaRadius = node.cornerRadius;
|
|
707
|
+
if (figmaRadius !== undefined && cv.borderRadius !== undefined) {
|
|
708
|
+
const codeRadius = typeof cv.borderRadius === "string" ? parseFloat(cv.borderRadius) : cv.borderRadius;
|
|
709
|
+
if (!isNaN(codeRadius) && !numericClose(figmaRadius, codeRadius)) {
|
|
710
|
+
discrepancies.push({
|
|
711
|
+
category: "visual",
|
|
712
|
+
property: "borderRadius",
|
|
713
|
+
severity: "minor",
|
|
714
|
+
designValue: figmaRadius,
|
|
715
|
+
codeValue: cv.borderRadius,
|
|
716
|
+
message: `Border radius mismatch: design=${figmaRadius}px, code=${cv.borderRadius}`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Opacity
|
|
721
|
+
if (node.opacity !== undefined && cv.opacity !== undefined) {
|
|
722
|
+
if (!numericClose(node.opacity, cv.opacity, 0.01)) {
|
|
723
|
+
discrepancies.push({
|
|
724
|
+
category: "visual",
|
|
725
|
+
property: "opacity",
|
|
726
|
+
severity: "minor",
|
|
727
|
+
designValue: node.opacity,
|
|
728
|
+
codeValue: cv.opacity,
|
|
729
|
+
message: `Opacity mismatch: design=${node.opacity}, code=${cv.opacity}`,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function compareSpacing(node, codeSpec, discrepancies) {
|
|
735
|
+
const cs = codeSpec.spacing;
|
|
736
|
+
if (!cs)
|
|
737
|
+
return;
|
|
738
|
+
const designSpacing = extractSpacingProperties(node);
|
|
739
|
+
const spacingProps = [
|
|
740
|
+
{ key: "paddingTop", designKey: "paddingTop" },
|
|
741
|
+
{ key: "paddingRight", designKey: "paddingRight" },
|
|
742
|
+
{ key: "paddingBottom", designKey: "paddingBottom" },
|
|
743
|
+
{ key: "paddingLeft", designKey: "paddingLeft" },
|
|
744
|
+
{ key: "gap", designKey: "gap" },
|
|
745
|
+
];
|
|
746
|
+
for (const { key, designKey } of spacingProps) {
|
|
747
|
+
const dVal = designSpacing[designKey];
|
|
748
|
+
const cVal = cs[key];
|
|
749
|
+
if (dVal !== undefined && cVal !== undefined) {
|
|
750
|
+
const cNum = typeof cVal === "string" ? parseFloat(cVal) : cVal;
|
|
751
|
+
if (!isNaN(cNum) && !numericClose(dVal, cNum)) {
|
|
752
|
+
discrepancies.push({
|
|
753
|
+
category: "spacing",
|
|
754
|
+
property: key,
|
|
755
|
+
severity: "major",
|
|
756
|
+
designValue: dVal,
|
|
757
|
+
codeValue: cVal,
|
|
758
|
+
message: `Spacing mismatch on ${key}: design=${dVal}px, code=${cVal}`,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// Width/height (only compare if both are numeric)
|
|
764
|
+
if (designSpacing.width !== undefined && cs.width !== undefined) {
|
|
765
|
+
const cNum = typeof cs.width === "string" ? parseFloat(cs.width) : cs.width;
|
|
766
|
+
if (!isNaN(cNum) && !numericClose(designSpacing.width, cNum, 2)) {
|
|
767
|
+
discrepancies.push({
|
|
768
|
+
category: "spacing",
|
|
769
|
+
property: "width",
|
|
770
|
+
severity: "minor",
|
|
771
|
+
designValue: designSpacing.width,
|
|
772
|
+
codeValue: cs.width,
|
|
773
|
+
message: `Width mismatch: design=${designSpacing.width}px, code=${cs.width}`,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (designSpacing.height !== undefined && cs.height !== undefined) {
|
|
778
|
+
const cNum = typeof cs.height === "string" ? parseFloat(cs.height) : cs.height;
|
|
779
|
+
if (!isNaN(cNum) && !numericClose(designSpacing.height, cNum, 2)) {
|
|
780
|
+
discrepancies.push({
|
|
781
|
+
category: "spacing",
|
|
782
|
+
property: "height",
|
|
783
|
+
severity: "minor",
|
|
784
|
+
designValue: designSpacing.height,
|
|
785
|
+
codeValue: cs.height,
|
|
786
|
+
message: `Height mismatch: design=${designSpacing.height}px, code=${cs.height}`,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function compareTypography(node, codeSpec, discrepancies) {
|
|
792
|
+
const ct = codeSpec.typography;
|
|
793
|
+
if (!ct)
|
|
794
|
+
return;
|
|
795
|
+
// Find text nodes in children or check the node itself
|
|
796
|
+
let textNode = node.type === "TEXT" ? node : null;
|
|
797
|
+
if (!textNode && node.children) {
|
|
798
|
+
textNode = node.children.find((c) => c.type === "TEXT");
|
|
799
|
+
}
|
|
800
|
+
if (!textNode)
|
|
801
|
+
return;
|
|
802
|
+
const designTypo = extractTextProperties(textNode);
|
|
803
|
+
if (designTypo.fontFamily && ct.fontFamily) {
|
|
804
|
+
if (designTypo.fontFamily.toLowerCase() !== ct.fontFamily.toLowerCase()) {
|
|
805
|
+
discrepancies.push({
|
|
806
|
+
category: "typography",
|
|
807
|
+
property: "fontFamily",
|
|
808
|
+
severity: "major",
|
|
809
|
+
designValue: designTypo.fontFamily,
|
|
810
|
+
codeValue: ct.fontFamily,
|
|
811
|
+
message: `Font family mismatch: design="${designTypo.fontFamily}", code="${ct.fontFamily}"`,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (designTypo.fontSize && ct.fontSize) {
|
|
816
|
+
if (!numericClose(designTypo.fontSize, ct.fontSize)) {
|
|
817
|
+
discrepancies.push({
|
|
818
|
+
category: "typography",
|
|
819
|
+
property: "fontSize",
|
|
820
|
+
severity: "major",
|
|
821
|
+
designValue: designTypo.fontSize,
|
|
822
|
+
codeValue: ct.fontSize,
|
|
823
|
+
message: `Font size mismatch: design=${designTypo.fontSize}px, code=${ct.fontSize}px`,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (designTypo.fontWeight && ct.fontWeight) {
|
|
828
|
+
const codeWeight = typeof ct.fontWeight === "string" ? parseInt(ct.fontWeight, 10) : ct.fontWeight;
|
|
829
|
+
if (!isNaN(codeWeight) && designTypo.fontWeight !== codeWeight) {
|
|
830
|
+
discrepancies.push({
|
|
831
|
+
category: "typography",
|
|
832
|
+
property: "fontWeight",
|
|
833
|
+
severity: "minor",
|
|
834
|
+
designValue: designTypo.fontWeight,
|
|
835
|
+
codeValue: ct.fontWeight,
|
|
836
|
+
message: `Font weight mismatch: design=${designTypo.fontWeight}, code=${ct.fontWeight}`,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (designTypo.lineHeight && ct.lineHeight) {
|
|
841
|
+
const codeLH = typeof ct.lineHeight === "string" ? parseFloat(ct.lineHeight) : ct.lineHeight;
|
|
842
|
+
if (!isNaN(codeLH) && !numericClose(designTypo.lineHeight, codeLH, 1)) {
|
|
843
|
+
discrepancies.push({
|
|
844
|
+
category: "typography",
|
|
845
|
+
property: "lineHeight",
|
|
846
|
+
severity: "minor",
|
|
847
|
+
designValue: designTypo.lineHeight,
|
|
848
|
+
codeValue: ct.lineHeight,
|
|
849
|
+
message: `Line height mismatch: design=${designTypo.lineHeight}px, code=${ct.lineHeight}`,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
function compareTokens(enrichedData, codeSpec, discrepancies) {
|
|
855
|
+
const ct = codeSpec.tokens;
|
|
856
|
+
if (!ct || !enrichedData)
|
|
857
|
+
return;
|
|
858
|
+
// Check for hardcoded values in design
|
|
859
|
+
if (enrichedData.hardcoded_values && enrichedData.hardcoded_values.length > 0) {
|
|
860
|
+
for (const hv of enrichedData.hardcoded_values) {
|
|
861
|
+
discrepancies.push({
|
|
862
|
+
category: "tokens",
|
|
863
|
+
property: `hardcoded:${hv.property}`,
|
|
864
|
+
severity: "major",
|
|
865
|
+
designValue: `${hv.value} (hardcoded)`,
|
|
866
|
+
codeValue: null,
|
|
867
|
+
message: `Design has hardcoded ${hv.type} value "${hv.value}" on ${hv.property}. Should use token${hv.suggested_token ? `: ${hv.suggested_token}` : ""}`,
|
|
868
|
+
suggestion: hv.suggested_token ? `Use token: ${hv.suggested_token}` : undefined,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// Cross-reference design variables with code tokens
|
|
873
|
+
if (enrichedData.variables_used && ct.usedTokens) {
|
|
874
|
+
const designTokenNames = enrichedData.variables_used.map((v) => v.name.toLowerCase());
|
|
875
|
+
const codeTokenNames = ct.usedTokens.map((t) => t.toLowerCase());
|
|
876
|
+
for (const designToken of enrichedData.variables_used) {
|
|
877
|
+
const normalizedName = designToken.name.toLowerCase();
|
|
878
|
+
if (!codeTokenNames.some((ct) => ct.includes(normalizedName) || normalizedName.includes(ct))) {
|
|
879
|
+
discrepancies.push({
|
|
880
|
+
category: "tokens",
|
|
881
|
+
property: `token:${designToken.name}`,
|
|
882
|
+
severity: "minor",
|
|
883
|
+
designValue: designToken.name,
|
|
884
|
+
codeValue: null,
|
|
885
|
+
message: `Design uses token "${designToken.name}" but code doesn't reference it`,
|
|
886
|
+
suggestion: `Add token reference in code`,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
for (const codeToken of ct.usedTokens) {
|
|
891
|
+
const normalizedName = codeToken.toLowerCase();
|
|
892
|
+
if (!designTokenNames.some((dt) => dt.includes(normalizedName) || normalizedName.includes(dt))) {
|
|
893
|
+
discrepancies.push({
|
|
894
|
+
category: "tokens",
|
|
895
|
+
property: `token:${codeToken}`,
|
|
896
|
+
severity: "info",
|
|
897
|
+
designValue: null,
|
|
898
|
+
codeValue: codeToken,
|
|
899
|
+
message: `Code uses token "${codeToken}" but design doesn't reference it`,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Token coverage
|
|
905
|
+
if (enrichedData.token_coverage !== undefined && enrichedData.token_coverage < 80) {
|
|
906
|
+
discrepancies.push({
|
|
907
|
+
category: "tokens",
|
|
908
|
+
property: "tokenCoverage",
|
|
909
|
+
severity: enrichedData.token_coverage < 50 ? "critical" : "major",
|
|
910
|
+
designValue: `${enrichedData.token_coverage}%`,
|
|
911
|
+
codeValue: null,
|
|
912
|
+
message: `Design token coverage is ${enrichedData.token_coverage}% (target: ≥80%)`,
|
|
913
|
+
suggestion: "Replace hardcoded values with design tokens",
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function compareComponentAPI(node, codeSpec, discrepancies) {
|
|
918
|
+
const ca = codeSpec.componentAPI;
|
|
919
|
+
if (!ca?.props)
|
|
920
|
+
return;
|
|
921
|
+
const figmaProps = node.componentPropertyDefinitions || {};
|
|
922
|
+
const figmaPropNames = Object.keys(figmaProps);
|
|
923
|
+
// Map Figma property types
|
|
924
|
+
const figmaPropList = figmaPropNames.map((name) => ({
|
|
925
|
+
name,
|
|
926
|
+
type: figmaProps[name].type, // VARIANT, TEXT, BOOLEAN, INSTANCE_SWAP
|
|
927
|
+
defaultValue: figmaProps[name].defaultValue,
|
|
928
|
+
values: figmaProps[name].variantOptions || [],
|
|
929
|
+
}));
|
|
930
|
+
// Check each code prop against Figma properties
|
|
931
|
+
for (const codeProp of ca.props) {
|
|
932
|
+
const matchingFigma = figmaPropList.find((fp) => fp.name.toLowerCase().replace(/[^a-z0-9]/g, "") === codeProp.name.toLowerCase().replace(/[^a-z0-9]/g, ""));
|
|
933
|
+
if (!matchingFigma) {
|
|
934
|
+
discrepancies.push({
|
|
935
|
+
category: "componentAPI",
|
|
936
|
+
property: `prop:${codeProp.name}`,
|
|
937
|
+
severity: "minor",
|
|
938
|
+
designValue: null,
|
|
939
|
+
codeValue: codeProp.name,
|
|
940
|
+
message: `Code prop "${codeProp.name}" has no matching Figma component property`,
|
|
941
|
+
suggestion: `Add component property in Figma`,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
else if (matchingFigma.type === "VARIANT" && codeProp.values) {
|
|
945
|
+
// Check variant values match
|
|
946
|
+
const figmaValues = matchingFigma.values.map((v) => v.toLowerCase());
|
|
947
|
+
const codeValues = codeProp.values.map((v) => v.toLowerCase());
|
|
948
|
+
const missingInDesign = codeValues.filter((v) => !figmaValues.includes(v));
|
|
949
|
+
const missingInCode = figmaValues.filter((v) => !codeValues.includes(v));
|
|
950
|
+
if (missingInDesign.length > 0) {
|
|
951
|
+
discrepancies.push({
|
|
952
|
+
category: "componentAPI",
|
|
953
|
+
property: `prop:${codeProp.name}:values`,
|
|
954
|
+
severity: "major",
|
|
955
|
+
designValue: matchingFigma.values.join(", "),
|
|
956
|
+
codeValue: codeProp.values.join(", "),
|
|
957
|
+
message: `Code has variant values not in design: ${missingInDesign.join(", ")}`,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
if (missingInCode.length > 0) {
|
|
961
|
+
discrepancies.push({
|
|
962
|
+
category: "componentAPI",
|
|
963
|
+
property: `prop:${codeProp.name}:values`,
|
|
964
|
+
severity: "info",
|
|
965
|
+
designValue: matchingFigma.values.join(", "),
|
|
966
|
+
codeValue: codeProp.values.join(", "),
|
|
967
|
+
message: `Design has variant values not in code: ${missingInCode.join(", ")}`,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// Check for Figma properties not in code
|
|
973
|
+
for (const figmaProp of figmaPropList) {
|
|
974
|
+
const matchingCode = ca.props.find((cp) => cp.name.toLowerCase().replace(/[^a-z0-9]/g, "") === figmaProp.name.toLowerCase().replace(/[^a-z0-9]/g, ""));
|
|
975
|
+
if (!matchingCode) {
|
|
976
|
+
discrepancies.push({
|
|
977
|
+
category: "componentAPI",
|
|
978
|
+
property: `prop:${figmaProp.name}`,
|
|
979
|
+
severity: "info",
|
|
980
|
+
designValue: figmaProp.name,
|
|
981
|
+
codeValue: null,
|
|
982
|
+
message: `Figma property "${figmaProp.name}" (${figmaProp.type}) has no matching code prop`,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function compareAccessibility(node, codeSpec, discrepancies) {
|
|
988
|
+
const ca = codeSpec.accessibility;
|
|
989
|
+
if (!ca)
|
|
990
|
+
return;
|
|
991
|
+
// Check description/annotations for accessibility hints
|
|
992
|
+
const description = node.descriptionMarkdown || node.description || "";
|
|
993
|
+
const hasAriaAnnotation = description.toLowerCase().includes("aria") || description.toLowerCase().includes("accessibility");
|
|
994
|
+
if (ca.role && !hasAriaAnnotation) {
|
|
995
|
+
discrepancies.push({
|
|
996
|
+
category: "accessibility",
|
|
997
|
+
property: "role",
|
|
998
|
+
severity: "info",
|
|
999
|
+
designValue: null,
|
|
1000
|
+
codeValue: ca.role,
|
|
1001
|
+
message: `Code defines role="${ca.role}" but design has no accessibility annotations`,
|
|
1002
|
+
suggestion: "Add accessibility annotations in Figma description",
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
if (ca.contrastRatio !== undefined && ca.contrastRatio < 4.5) {
|
|
1006
|
+
discrepancies.push({
|
|
1007
|
+
category: "accessibility",
|
|
1008
|
+
property: "contrastRatio",
|
|
1009
|
+
severity: "critical",
|
|
1010
|
+
designValue: null,
|
|
1011
|
+
codeValue: ca.contrastRatio,
|
|
1012
|
+
message: `Contrast ratio ${ca.contrastRatio}:1 fails WCAG AA minimum (4.5:1)`,
|
|
1013
|
+
suggestion: "Increase contrast ratio to at least 4.5:1",
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function compareNaming(node, codeSpec, discrepancies) {
|
|
1018
|
+
const cm = codeSpec.metadata;
|
|
1019
|
+
if (!cm?.name)
|
|
1020
|
+
return;
|
|
1021
|
+
const designName = node.name || "";
|
|
1022
|
+
const codeName = cm.name;
|
|
1023
|
+
// Check PascalCase consistency
|
|
1024
|
+
const isPascal = (s) => /^[A-Z][a-zA-Z0-9]*$/.test(s);
|
|
1025
|
+
const isKebab = (s) => /^[a-z][a-z0-9-]*$/.test(s);
|
|
1026
|
+
if (isPascal(designName) !== isPascal(codeName) && isKebab(designName) !== isKebab(codeName)) {
|
|
1027
|
+
discrepancies.push({
|
|
1028
|
+
category: "naming",
|
|
1029
|
+
property: "componentName",
|
|
1030
|
+
severity: "info",
|
|
1031
|
+
designValue: designName,
|
|
1032
|
+
codeValue: codeName,
|
|
1033
|
+
message: `Naming convention differs: design="${designName}", code="${codeName}"`,
|
|
1034
|
+
suggestion: "Align naming conventions between design and code",
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function compareMetadata(node, componentMeta, codeSpec, discrepancies) {
|
|
1039
|
+
const cm = codeSpec.metadata;
|
|
1040
|
+
if (!cm)
|
|
1041
|
+
return;
|
|
1042
|
+
const designDesc = node.description || componentMeta?.description || "";
|
|
1043
|
+
if (cm.description && designDesc) {
|
|
1044
|
+
// Only flag if descriptions are meaningfully different (not just formatting)
|
|
1045
|
+
const normalizeDesc = (d) => d.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
|
|
1046
|
+
if (normalizeDesc(designDesc) !== normalizeDesc(cm.description)) {
|
|
1047
|
+
discrepancies.push({
|
|
1048
|
+
category: "metadata",
|
|
1049
|
+
property: "description",
|
|
1050
|
+
severity: "info",
|
|
1051
|
+
designValue: designDesc.slice(0, 100),
|
|
1052
|
+
codeValue: cm.description.slice(0, 100),
|
|
1053
|
+
message: "Component descriptions differ between design and code",
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (cm.status && componentMeta?.description) {
|
|
1058
|
+
// Check if design description contains a status
|
|
1059
|
+
const statusKeywords = ["stable", "experimental", "deprecated", "beta", "alpha", "draft"];
|
|
1060
|
+
const designStatus = statusKeywords.find((s) => componentMeta.description.toLowerCase().includes(s));
|
|
1061
|
+
if (designStatus && designStatus !== cm.status.toLowerCase()) {
|
|
1062
|
+
discrepancies.push({
|
|
1063
|
+
category: "metadata",
|
|
1064
|
+
property: "status",
|
|
1065
|
+
severity: "minor",
|
|
1066
|
+
designValue: designStatus,
|
|
1067
|
+
codeValue: cm.status,
|
|
1068
|
+
message: `Status mismatch: design implies "${designStatus}", code says "${cm.status}"`,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// ============================================================================
|
|
1074
|
+
// Action Item Generator
|
|
1075
|
+
// ============================================================================
|
|
1076
|
+
function generateActionItems(discrepancies, nodeId, canonicalSource, filePath) {
|
|
1077
|
+
const items = [];
|
|
1078
|
+
for (let i = 0; i < discrepancies.length; i++) {
|
|
1079
|
+
const d = discrepancies[i];
|
|
1080
|
+
// Determine which side needs the fix
|
|
1081
|
+
const fixSide = canonicalSource === "design" ? "code" : "design";
|
|
1082
|
+
const item = {
|
|
1083
|
+
discrepancyIndex: i,
|
|
1084
|
+
side: fixSide,
|
|
1085
|
+
};
|
|
1086
|
+
if (fixSide === "design") {
|
|
1087
|
+
// Generate Figma tool call parameters
|
|
1088
|
+
switch (d.category) {
|
|
1089
|
+
case "visual":
|
|
1090
|
+
if (d.property === "backgroundColor" && d.codeValue) {
|
|
1091
|
+
item.figmaTool = "figma_set_fills";
|
|
1092
|
+
item.figmaToolParams = {
|
|
1093
|
+
nodeId,
|
|
1094
|
+
fills: [{ type: "SOLID", color: String(d.codeValue) }],
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
else if (d.property === "borderColor" && d.codeValue) {
|
|
1098
|
+
item.figmaTool = "figma_set_strokes";
|
|
1099
|
+
item.figmaToolParams = {
|
|
1100
|
+
nodeId,
|
|
1101
|
+
strokes: [{ type: "SOLID", color: String(d.codeValue) }],
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
else if (d.property === "borderRadius" && d.codeValue) {
|
|
1105
|
+
item.figmaTool = "figma_execute";
|
|
1106
|
+
item.figmaToolParams = {
|
|
1107
|
+
code: `const node = figma.getNodeById("${nodeId}"); if (node && "cornerRadius" in node) { node.cornerRadius = ${d.codeValue}; }`,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
else if (d.property === "borderWidth" && d.codeValue) {
|
|
1111
|
+
item.figmaTool = "figma_execute";
|
|
1112
|
+
item.figmaToolParams = {
|
|
1113
|
+
code: `const node = figma.getNodeById("${nodeId}"); if (node && "strokeWeight" in node) { node.strokeWeight = ${d.codeValue}; }`,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
break;
|
|
1117
|
+
case "spacing":
|
|
1118
|
+
if (d.codeValue !== null && d.codeValue !== undefined) {
|
|
1119
|
+
const prop = d.property;
|
|
1120
|
+
if (["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "gap"].includes(prop)) {
|
|
1121
|
+
const figmaProp = prop === "gap" ? "itemSpacing" : prop;
|
|
1122
|
+
item.figmaTool = "figma_execute";
|
|
1123
|
+
item.figmaToolParams = {
|
|
1124
|
+
code: `const node = figma.getNodeById("${nodeId}"); if (node && "${figmaProp}" in node) { node.${figmaProp} = ${d.codeValue}; }`,
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
else if (prop === "width" || prop === "height") {
|
|
1128
|
+
item.figmaTool = "figma_resize_node";
|
|
1129
|
+
item.figmaToolParams = {
|
|
1130
|
+
nodeId,
|
|
1131
|
+
[prop]: Number(d.codeValue),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
break;
|
|
1136
|
+
case "componentAPI":
|
|
1137
|
+
if (d.property.startsWith("prop:") && d.codeValue && !d.designValue) {
|
|
1138
|
+
item.figmaTool = "figma_add_component_property";
|
|
1139
|
+
item.figmaToolParams = {
|
|
1140
|
+
nodeId,
|
|
1141
|
+
propertyName: String(d.codeValue),
|
|
1142
|
+
type: "VARIANT",
|
|
1143
|
+
defaultValue: "",
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
break;
|
|
1147
|
+
case "metadata":
|
|
1148
|
+
if (d.property === "description" && d.codeValue) {
|
|
1149
|
+
item.figmaTool = "figma_set_description";
|
|
1150
|
+
item.figmaToolParams = {
|
|
1151
|
+
nodeId,
|
|
1152
|
+
description: String(d.codeValue),
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
break;
|
|
1156
|
+
default:
|
|
1157
|
+
// For categories without direct tool mappings, provide a description
|
|
1158
|
+
item.codeChange = {
|
|
1159
|
+
filePath,
|
|
1160
|
+
property: d.property,
|
|
1161
|
+
currentValue: d.designValue,
|
|
1162
|
+
targetValue: d.codeValue,
|
|
1163
|
+
description: d.suggestion || d.message,
|
|
1164
|
+
};
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
1167
|
+
// If no figma tool was assigned and no codeChange, add generic guidance
|
|
1168
|
+
if (!item.figmaTool && !item.codeChange) {
|
|
1169
|
+
item.codeChange = {
|
|
1170
|
+
property: d.property,
|
|
1171
|
+
currentValue: d.designValue,
|
|
1172
|
+
targetValue: d.codeValue,
|
|
1173
|
+
description: `Update design: ${d.message}`,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
// Code-side fix
|
|
1179
|
+
item.codeChange = {
|
|
1180
|
+
filePath,
|
|
1181
|
+
property: d.property,
|
|
1182
|
+
currentValue: d.codeValue,
|
|
1183
|
+
targetValue: d.designValue,
|
|
1184
|
+
description: d.suggestion || `Update code to match design: ${d.message}`,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
items.push(item);
|
|
1188
|
+
}
|
|
1189
|
+
return items;
|
|
1190
|
+
}
|
|
1191
|
+
// ============================================================================
|
|
1192
|
+
// Parity Report Presentation Instruction
|
|
1193
|
+
// ============================================================================
|
|
1194
|
+
/**
|
|
1195
|
+
* Build the ai_instruction for parity check results.
|
|
1196
|
+
* Defines a consistent presentation structure so any AI consuming this tool
|
|
1197
|
+
* produces scannable, structured reports with conversational analysis.
|
|
1198
|
+
*/
|
|
1199
|
+
function buildParityInstruction(componentName, parityScore, counts, canonicalSource, totalDiscrepancies) {
|
|
1200
|
+
if (totalDiscrepancies === 0) {
|
|
1201
|
+
return [
|
|
1202
|
+
`No discrepancies found for ${componentName}. Design and code are in sync (parity score: 100/100).`,
|
|
1203
|
+
"",
|
|
1204
|
+
"Present as:",
|
|
1205
|
+
"",
|
|
1206
|
+
`## ${componentName} — Parity Report`,
|
|
1207
|
+
`**Score: 100/100** | 0 critical | 0 major | 0 minor | 0 info`,
|
|
1208
|
+
"",
|
|
1209
|
+
"### Action Required",
|
|
1210
|
+
"None — design and code are fully aligned.",
|
|
1211
|
+
"",
|
|
1212
|
+
"### Verdict",
|
|
1213
|
+
"Ready for sign-off. All compared properties match between design and code.",
|
|
1214
|
+
].join("\n");
|
|
1215
|
+
}
|
|
1216
|
+
return [
|
|
1217
|
+
`Found ${totalDiscrepancies} discrepancies (parity score: ${parityScore}/100) for ${componentName}. Canonical source: '${canonicalSource}'.`,
|
|
1218
|
+
"",
|
|
1219
|
+
"Present results using this consistent structure:",
|
|
1220
|
+
"",
|
|
1221
|
+
`## ${componentName} — Parity Report`,
|
|
1222
|
+
`**Score: ${parityScore}/100** | ${counts.critical} critical | ${counts.major} major | ${counts.minor} minor | ${counts.info} info`,
|
|
1223
|
+
"",
|
|
1224
|
+
"### Action Required",
|
|
1225
|
+
"List discrepancies that represent real spec gaps needing resolution before sign-off.",
|
|
1226
|
+
"For each, state what differs and suggest the fix direction based on the canonical source.",
|
|
1227
|
+
"If no actionable items exist, write: \"None — design and code are aligned on all spec values.\"",
|
|
1228
|
+
"",
|
|
1229
|
+
"### Aligned",
|
|
1230
|
+
"Compact bulleted list of properties that matched between design and code (no discrepancy flagged).",
|
|
1231
|
+
"Include: colors/fills, border, spacing/padding, gap, typography, layout — whichever were compared and matched.",
|
|
1232
|
+
"This builds confidence in what's already correct.",
|
|
1233
|
+
"",
|
|
1234
|
+
"### Notes",
|
|
1235
|
+
"Conversational analysis of remaining items. Explain paradigm differences (e.g., Figma component properties vs React composition slots),",
|
|
1236
|
+
"note missing accessibility annotations, flag metadata differences, and provide editorial recommendations.",
|
|
1237
|
+
"This is where context and judgment live — interpret the findings, don't just list them.",
|
|
1238
|
+
"",
|
|
1239
|
+
"### Verdict",
|
|
1240
|
+
"One sentence: is this component ready for sign-off, does it need minor adjustments, or are there blockers?",
|
|
1241
|
+
"",
|
|
1242
|
+
"Categorization rules:",
|
|
1243
|
+
"- Critical/major discrepancies → always Action Required",
|
|
1244
|
+
"- Minor discrepancies that are real spec gaps (colors, spacing, radius, typography, tokens) → Action Required",
|
|
1245
|
+
"- Minor discrepancies that are paradigm differences (className prop, React-only behavioral props) → Notes",
|
|
1246
|
+
"- Info-level items → always Notes",
|
|
1247
|
+
"- Keep Action Required focused — if a developer only reads one section, this is it",
|
|
1248
|
+
"- The Aligned section should be scannable in under 5 seconds",
|
|
1249
|
+
"- Offer to apply fixes when actionable items exist",
|
|
1250
|
+
].join("\n");
|
|
1251
|
+
}
|
|
1252
|
+
// ============================================================================
|
|
1253
|
+
// Documentation Section Generators
|
|
1254
|
+
// ============================================================================
|
|
1255
|
+
function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource) {
|
|
1256
|
+
const status = codeInfo?.changelog?.[0]
|
|
1257
|
+
? "stable"
|
|
1258
|
+
: componentMeta?.description?.toLowerCase().includes("deprecated")
|
|
1259
|
+
? "deprecated"
|
|
1260
|
+
: "stable";
|
|
1261
|
+
const version = codeInfo?.changelog?.[0]?.version || "1.0.0";
|
|
1262
|
+
const tags = [componentName.toLowerCase()];
|
|
1263
|
+
if (node.type === "COMPONENT_SET")
|
|
1264
|
+
tags.push("variants");
|
|
1265
|
+
if (node.componentPropertyDefinitions)
|
|
1266
|
+
tags.push("configurable");
|
|
1267
|
+
const lines = [
|
|
1268
|
+
"---",
|
|
1269
|
+
`title: ${componentName}`,
|
|
1270
|
+
`description: ${(description.split(/(?:When to Use|When NOT to Use|Variants|Content Requirements|Accessibility)/i)[0] || description).replace(/\n/g, " ").replace(/\s+/g, " ").trim() || `${componentName} component`}`,
|
|
1271
|
+
`status: ${status}`,
|
|
1272
|
+
`version: ${version}`,
|
|
1273
|
+
`category: components`,
|
|
1274
|
+
`tags: [${tags.join(", ")}]`,
|
|
1275
|
+
`figma: ${fileUrl}`,
|
|
1276
|
+
];
|
|
1277
|
+
if (codeInfo?.filePath) {
|
|
1278
|
+
lines.push(`source: ${codeInfo.filePath}`);
|
|
1279
|
+
}
|
|
1280
|
+
if (codeInfo?.packageName) {
|
|
1281
|
+
lines.push(`package: ${codeInfo.packageName}`);
|
|
1282
|
+
}
|
|
1283
|
+
if (canonicalSource) {
|
|
1284
|
+
lines.push(`canonical: ${canonicalSource}`);
|
|
1285
|
+
}
|
|
1286
|
+
lines.push(`lastUpdated: ${new Date().toISOString().split("T")[0]}`);
|
|
1287
|
+
lines.push("---");
|
|
1288
|
+
return lines.join("\n");
|
|
1289
|
+
}
|
|
1290
|
+
function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
|
|
1291
|
+
const lines = [
|
|
1292
|
+
`# ${componentName}`,
|
|
1293
|
+
"",
|
|
1294
|
+
];
|
|
1295
|
+
// Build links line
|
|
1296
|
+
const links = [`**[Open in Figma](${fileUrl})**`];
|
|
1297
|
+
if (codeInfo?.filePath) {
|
|
1298
|
+
links.push(`**[View Source](${codeInfo.filePath})**`);
|
|
1299
|
+
}
|
|
1300
|
+
// Add Storybook link if stories file exists in sourceFiles
|
|
1301
|
+
const storiesFile = codeInfo?.sourceFiles?.find((f) => f.role.toLowerCase().includes("storybook") || f.role.toLowerCase().includes("stories") || f.path.includes(".stories."));
|
|
1302
|
+
if (storiesFile) {
|
|
1303
|
+
links.push(`**[Storybook](${storiesFile.path})**`);
|
|
1304
|
+
}
|
|
1305
|
+
lines.push(links.join(" | "));
|
|
1306
|
+
lines.push("");
|
|
1307
|
+
lines.push("## Overview");
|
|
1308
|
+
lines.push("");
|
|
1309
|
+
// Use parsed overview or fall back to raw description
|
|
1310
|
+
const overviewText = parsedDesc.overview || description?.split("\n")[0] || `The ${componentName} component.`;
|
|
1311
|
+
lines.push(overviewText);
|
|
1312
|
+
lines.push("");
|
|
1313
|
+
// Base component attribution
|
|
1314
|
+
if (codeInfo?.baseComponent) {
|
|
1315
|
+
const baseLink = codeInfo.baseComponent.url
|
|
1316
|
+
? `[${codeInfo.baseComponent.name}](${codeInfo.baseComponent.url})`
|
|
1317
|
+
: codeInfo.baseComponent.name;
|
|
1318
|
+
if (codeInfo.baseComponent.description) {
|
|
1319
|
+
lines.push(`Built on ${baseLink}, ${codeInfo.baseComponent.description}`);
|
|
1320
|
+
}
|
|
1321
|
+
else {
|
|
1322
|
+
lines.push(`Built on ${baseLink}.`);
|
|
1323
|
+
}
|
|
1324
|
+
lines.push("");
|
|
1325
|
+
}
|
|
1326
|
+
// When to Use
|
|
1327
|
+
if (parsedDesc.whenToUse.length > 0) {
|
|
1328
|
+
lines.push("### When to Use");
|
|
1329
|
+
lines.push("");
|
|
1330
|
+
for (const item of parsedDesc.whenToUse) {
|
|
1331
|
+
lines.push(`- ${item}`);
|
|
1332
|
+
}
|
|
1333
|
+
lines.push("");
|
|
1334
|
+
}
|
|
1335
|
+
// When NOT to Use
|
|
1336
|
+
if (parsedDesc.whenNotToUse.length > 0) {
|
|
1337
|
+
lines.push("### When NOT to Use");
|
|
1338
|
+
lines.push("");
|
|
1339
|
+
for (const item of parsedDesc.whenNotToUse) {
|
|
1340
|
+
lines.push(`- ${item}`);
|
|
1341
|
+
}
|
|
1342
|
+
lines.push("");
|
|
1343
|
+
}
|
|
1344
|
+
return lines.join("\n");
|
|
1345
|
+
}
|
|
1346
|
+
function generateStatesAndVariantsSection(node, variantData) {
|
|
1347
|
+
const props = node.componentPropertyDefinitions;
|
|
1348
|
+
if (!props || Object.keys(props).length === 0)
|
|
1349
|
+
return "";
|
|
1350
|
+
const lines = ["", "## Variants", ""];
|
|
1351
|
+
const variants = [];
|
|
1352
|
+
const booleans = [];
|
|
1353
|
+
const textProps = [];
|
|
1354
|
+
for (const [rawName, def] of Object.entries(props)) {
|
|
1355
|
+
// Strip Figma internal ID suffixes like "#17100:0" from property names
|
|
1356
|
+
const name = rawName.replace(/#\d+:\d+$/, "").trim();
|
|
1357
|
+
if (def.type === "VARIANT") {
|
|
1358
|
+
variants.push({
|
|
1359
|
+
name,
|
|
1360
|
+
values: def.variantOptions || [],
|
|
1361
|
+
defaultValue: def.defaultValue || "",
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
else if (def.type === "BOOLEAN") {
|
|
1365
|
+
booleans.push({ name, defaultValue: def.defaultValue ?? true });
|
|
1366
|
+
}
|
|
1367
|
+
else if (def.type === "TEXT") {
|
|
1368
|
+
textProps.push({ name, defaultValue: def.defaultValue || "" });
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
// Variant matrix with per-variant color data
|
|
1372
|
+
if (variants.length > 0 && variantData && variantData.length > 0) {
|
|
1373
|
+
lines.push("### Variant Matrix");
|
|
1374
|
+
lines.push("");
|
|
1375
|
+
// Determine which columns to show based on available data
|
|
1376
|
+
const hasIcons = variantData.some((v) => v.icons.length > 0);
|
|
1377
|
+
const hasFills = variantData.some((v) => v.fills.length > 0);
|
|
1378
|
+
if (hasFills || hasIcons) {
|
|
1379
|
+
const headerParts = ["Variant", "Background"];
|
|
1380
|
+
if (hasIcons)
|
|
1381
|
+
headerParts.push("Icon");
|
|
1382
|
+
headerParts.push("Text/Icon Color");
|
|
1383
|
+
lines.push("| " + headerParts.join(" | ") + " |");
|
|
1384
|
+
const separatorParts = ["--------", "----------"];
|
|
1385
|
+
if (hasIcons)
|
|
1386
|
+
separatorParts.push("----");
|
|
1387
|
+
separatorParts.push("---------------");
|
|
1388
|
+
lines.push("|" + separatorParts.join("|") + "|");
|
|
1389
|
+
for (const vd of variantData) {
|
|
1390
|
+
const displayName = cleanVariantName(vd.variantName);
|
|
1391
|
+
// Get primary fill (background)
|
|
1392
|
+
const bgFill = vd.fills[0];
|
|
1393
|
+
const bgVal = bgFill
|
|
1394
|
+
? (bgFill.variableName ? `\`${bgFill.variableName}\` (${bgFill.hex})` : bgFill.hex)
|
|
1395
|
+
: "—";
|
|
1396
|
+
// Get primary text/icon color
|
|
1397
|
+
const textColor = vd.textColors[0] || vd.strokes[0];
|
|
1398
|
+
const textVal = textColor
|
|
1399
|
+
? (textColor.variableName ? `\`${textColor.variableName}\` (${textColor.hex})` : textColor.hex)
|
|
1400
|
+
: "—";
|
|
1401
|
+
const rowParts = [`**${displayName}**`, bgVal];
|
|
1402
|
+
if (hasIcons) {
|
|
1403
|
+
const icon = vd.icons[0]?.name || "—";
|
|
1404
|
+
rowParts.push(icon);
|
|
1405
|
+
}
|
|
1406
|
+
rowParts.push(textVal);
|
|
1407
|
+
lines.push("| " + rowParts.join(" | ") + " |");
|
|
1408
|
+
}
|
|
1409
|
+
lines.push("");
|
|
1410
|
+
}
|
|
1411
|
+
// Icon-to-variant mapping table
|
|
1412
|
+
if (hasIcons) {
|
|
1413
|
+
lines.push("### Icon Mapping");
|
|
1414
|
+
lines.push("");
|
|
1415
|
+
lines.push("| Variant | Figma Icon Instance |");
|
|
1416
|
+
lines.push("|---------|---------------------|");
|
|
1417
|
+
for (const vd of variantData) {
|
|
1418
|
+
const displayName = cleanVariantName(vd.variantName);
|
|
1419
|
+
const icon = vd.icons[0]?.name || "—";
|
|
1420
|
+
lines.push(`| ${displayName} | ${icon} |`);
|
|
1421
|
+
}
|
|
1422
|
+
lines.push("");
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
else if (variants.length > 0) {
|
|
1426
|
+
// Fallback: simple variant table without color data
|
|
1427
|
+
lines.push("| Variant | Values | Default |");
|
|
1428
|
+
lines.push("|---------|--------|---------|");
|
|
1429
|
+
for (const v of variants) {
|
|
1430
|
+
lines.push(`| ${v.name} | ${v.values.join(", ")} | ${v.defaultValue} |`);
|
|
1431
|
+
}
|
|
1432
|
+
lines.push("");
|
|
1433
|
+
}
|
|
1434
|
+
// Configurable properties table (all property types)
|
|
1435
|
+
if (booleans.length > 0 || textProps.length > 0) {
|
|
1436
|
+
lines.push("### Configurable Properties");
|
|
1437
|
+
lines.push("");
|
|
1438
|
+
lines.push("| Property | Type | Default | Description |");
|
|
1439
|
+
lines.push("|----------|------|---------|-------------|");
|
|
1440
|
+
for (const v of variants) {
|
|
1441
|
+
lines.push(`| **${v.name}** | \`${v.values.map((val) => `"${val}"`).join(" \\| ")}\` | \`"${v.defaultValue}"\` | Changes visual treatment |`);
|
|
1442
|
+
}
|
|
1443
|
+
for (const b of booleans) {
|
|
1444
|
+
lines.push(`| **${b.name}** | \`boolean\` | \`${b.defaultValue}\` | Shows/hides ${b.name.toLowerCase()} element |`);
|
|
1445
|
+
}
|
|
1446
|
+
for (const t of textProps) {
|
|
1447
|
+
lines.push(`| **${t.name}** | \`string\` | \`"${t.defaultValue}"\` | Sets ${t.name.toLowerCase()} content |`);
|
|
1448
|
+
}
|
|
1449
|
+
lines.push("");
|
|
1450
|
+
}
|
|
1451
|
+
return lines.join("\n");
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Recursively walk a node tree and collect all unique colors from fills, strokes, and text.
|
|
1455
|
+
* For COMPONENT_SET nodes, walks the first child (default variant) instead of the set frame.
|
|
1456
|
+
*/
|
|
1457
|
+
function collectNodeColors(node, colors, depth = 0, maxDepth = 3) {
|
|
1458
|
+
if (depth > maxDepth)
|
|
1459
|
+
return;
|
|
1460
|
+
// For COMPONENT_SET, walk into the first child (default variant) for visual data
|
|
1461
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 0 && depth === 0) {
|
|
1462
|
+
collectNodeColors(node.children[0], colors, 0, maxDepth);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const isText = node.type === "TEXT";
|
|
1466
|
+
// Fills
|
|
1467
|
+
if (node.fills && Array.isArray(node.fills)) {
|
|
1468
|
+
for (const fill of node.fills) {
|
|
1469
|
+
if (fill.type === "SOLID" && fill.color && fill.visible !== false) {
|
|
1470
|
+
const hex = figmaRGBAToHex({ ...fill.color, a: fill.opacity ?? fill.color.a ?? 1 });
|
|
1471
|
+
colors.push({
|
|
1472
|
+
hex,
|
|
1473
|
+
property: isText ? "text" : "fill",
|
|
1474
|
+
nodeName: node.name || "",
|
|
1475
|
+
variableId: fill.boundVariables?.color?.id,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
// Strokes
|
|
1481
|
+
if (node.strokes && Array.isArray(node.strokes)) {
|
|
1482
|
+
for (const stroke of node.strokes) {
|
|
1483
|
+
if (stroke.type === "SOLID" && stroke.color && stroke.visible !== false) {
|
|
1484
|
+
const hex = figmaRGBAToHex({ ...stroke.color, a: stroke.opacity ?? stroke.color.a ?? 1 });
|
|
1485
|
+
colors.push({
|
|
1486
|
+
hex,
|
|
1487
|
+
property: "stroke",
|
|
1488
|
+
nodeName: node.name || "",
|
|
1489
|
+
variableId: stroke.boundVariables?.color?.id,
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// Recurse into children
|
|
1495
|
+
if (node.children && Array.isArray(node.children)) {
|
|
1496
|
+
for (const child of node.children) {
|
|
1497
|
+
collectNodeColors(child, colors, depth + 1, maxDepth);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
/** Deduplicate collected colors, keeping the most descriptive context for each unique hex */
|
|
1502
|
+
function deduplicateColors(colors) {
|
|
1503
|
+
const seen = new Map();
|
|
1504
|
+
for (const c of colors) {
|
|
1505
|
+
const key = `${c.hex}:${c.property}`;
|
|
1506
|
+
if (!seen.has(key)) {
|
|
1507
|
+
seen.set(key, c);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return Array.from(seen.values());
|
|
1511
|
+
}
|
|
1512
|
+
function generateVisualSpecsSection(node, enrichedData, variantData) {
|
|
1513
|
+
const lines = ["", "## Token Specification", ""];
|
|
1514
|
+
// Build variable name lookup from enrichment data
|
|
1515
|
+
const varNameMap = new Map();
|
|
1516
|
+
if (enrichedData?.variables_used) {
|
|
1517
|
+
for (const v of enrichedData.variables_used) {
|
|
1518
|
+
varNameMap.set(v.id, v.name);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// Per-variant color token table
|
|
1522
|
+
if (variantData && variantData.length > 0) {
|
|
1523
|
+
lines.push("### Color Tokens");
|
|
1524
|
+
lines.push("");
|
|
1525
|
+
lines.push("| Element | Figma Variable | Value |");
|
|
1526
|
+
lines.push("|---------|---------------|-------|");
|
|
1527
|
+
for (const vd of variantData) {
|
|
1528
|
+
const nameMatch = vd.variantName.match(/Variant=([^,]+)/i);
|
|
1529
|
+
const displayName = nameMatch ? nameMatch[1].trim() : vd.variantName;
|
|
1530
|
+
// Section header for this variant
|
|
1531
|
+
lines.push(`| **${displayName}** | | |`);
|
|
1532
|
+
// Background fills
|
|
1533
|
+
for (const fill of vd.fills) {
|
|
1534
|
+
const varName = fill.variableName || (fill.variableId ? varNameMap.get(fill.variableId) : undefined);
|
|
1535
|
+
lines.push(`| Background | ${varName ? `\`${varName}\`` : "—"} | ${fill.hex} |`);
|
|
1536
|
+
}
|
|
1537
|
+
// Text colors
|
|
1538
|
+
for (const text of vd.textColors) {
|
|
1539
|
+
const varName = text.variableName || (text.variableId ? varNameMap.get(text.variableId) : undefined);
|
|
1540
|
+
lines.push(`| Text (${text.nodeName}) | ${varName ? `\`${varName}\`` : "—"} | ${text.hex} |`);
|
|
1541
|
+
}
|
|
1542
|
+
// Strokes
|
|
1543
|
+
for (const stroke of vd.strokes) {
|
|
1544
|
+
const varName = stroke.variableName || (stroke.variableId ? varNameMap.get(stroke.variableId) : undefined);
|
|
1545
|
+
lines.push(`| Stroke | ${varName ? `\`${varName}\`` : "—"} | ${stroke.hex} |`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
lines.push("");
|
|
1549
|
+
}
|
|
1550
|
+
else {
|
|
1551
|
+
// Fallback: collect from default variant
|
|
1552
|
+
const allColors = [];
|
|
1553
|
+
collectNodeColors(node, allColors);
|
|
1554
|
+
const uniqueColors = deduplicateColors(allColors);
|
|
1555
|
+
if (uniqueColors.length > 0) {
|
|
1556
|
+
lines.push("### Colors & Fills");
|
|
1557
|
+
lines.push("| Property | Element | Value |");
|
|
1558
|
+
lines.push("|----------|---------|-------|");
|
|
1559
|
+
const order = { fill: 0, text: 1, stroke: 2 };
|
|
1560
|
+
uniqueColors.sort((a, b) => order[a.property] - order[b.property]);
|
|
1561
|
+
for (const c of uniqueColors) {
|
|
1562
|
+
const label = c.property === "fill" ? "Fill" : c.property === "text" ? "Text" : "Stroke";
|
|
1563
|
+
const tokenName = c.variableId ? varNameMap.get(c.variableId) : undefined;
|
|
1564
|
+
const value = tokenName ? `${c.hex} (\`${tokenName}\`)` : c.hex;
|
|
1565
|
+
lines.push(`| ${label} | ${c.nodeName} | ${value} |`);
|
|
1566
|
+
}
|
|
1567
|
+
lines.push("");
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// Spacing tokens with variable names
|
|
1571
|
+
const visualNode = resolveVisualNode(node);
|
|
1572
|
+
const spacingTokens = collectSpacingTokens(visualNode);
|
|
1573
|
+
if (spacingTokens.length > 0) {
|
|
1574
|
+
lines.push("### Spacing Tokens");
|
|
1575
|
+
lines.push("");
|
|
1576
|
+
lines.push("| Property | Figma Variable | Value |");
|
|
1577
|
+
lines.push("|----------|---------------|-------|");
|
|
1578
|
+
for (const token of spacingTokens) {
|
|
1579
|
+
const varDisplay = token.variableName ? `\`${token.variableName}\`` : "—";
|
|
1580
|
+
lines.push(`| ${token.property} | ${varDisplay} | ${token.value}px |`);
|
|
1581
|
+
}
|
|
1582
|
+
lines.push("");
|
|
1583
|
+
}
|
|
1584
|
+
else {
|
|
1585
|
+
// Fallback: simple spacing output
|
|
1586
|
+
const spacing = extractSpacingProperties(visualNode);
|
|
1587
|
+
const hasSpacing = Object.keys(spacing).length > 0;
|
|
1588
|
+
if (hasSpacing) {
|
|
1589
|
+
lines.push("### Spacing & Layout");
|
|
1590
|
+
if (spacing.paddingTop !== undefined || spacing.paddingRight !== undefined) {
|
|
1591
|
+
lines.push(`- Padding: ${spacing.paddingTop ?? 0}px ${spacing.paddingRight ?? 0}px ${spacing.paddingBottom ?? 0}px ${spacing.paddingLeft ?? 0}px`);
|
|
1592
|
+
}
|
|
1593
|
+
if (spacing.gap !== undefined)
|
|
1594
|
+
lines.push(`- Gap: ${spacing.gap}px`);
|
|
1595
|
+
lines.push("");
|
|
1596
|
+
}
|
|
1597
|
+
if (visualNode.cornerRadius !== undefined) {
|
|
1598
|
+
lines.push(`- Border Radius: ${visualNode.cornerRadius}px`);
|
|
1599
|
+
lines.push("");
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
// Token coverage
|
|
1603
|
+
if (enrichedData?.token_coverage !== undefined) {
|
|
1604
|
+
lines.push("### Token Coverage");
|
|
1605
|
+
lines.push(`Score: ${enrichedData.token_coverage}%`);
|
|
1606
|
+
if (enrichedData.hardcoded_values && enrichedData.hardcoded_values.length > 0) {
|
|
1607
|
+
lines.push(`| Hardcoded values: ${enrichedData.hardcoded_values.length}`);
|
|
1608
|
+
}
|
|
1609
|
+
lines.push("");
|
|
1610
|
+
}
|
|
1611
|
+
return lines.join("\n");
|
|
1612
|
+
}
|
|
1613
|
+
function generateImplementationSection(codeInfo) {
|
|
1614
|
+
if (!codeInfo)
|
|
1615
|
+
return "";
|
|
1616
|
+
const lines = ["", "## Implementation", ""];
|
|
1617
|
+
// Source files table
|
|
1618
|
+
if (codeInfo.sourceFiles && codeInfo.sourceFiles.length > 0) {
|
|
1619
|
+
lines.push("### Source Files");
|
|
1620
|
+
lines.push("");
|
|
1621
|
+
lines.push("| File | Role | Variants |");
|
|
1622
|
+
lines.push("|------|------|----------|");
|
|
1623
|
+
for (const sf of codeInfo.sourceFiles) {
|
|
1624
|
+
lines.push(`| \`${sf.path}\` | ${sf.role} | ${sf.variants ?? "—"} |`);
|
|
1625
|
+
}
|
|
1626
|
+
lines.push("");
|
|
1627
|
+
}
|
|
1628
|
+
// Import statement
|
|
1629
|
+
if (codeInfo.importStatement) {
|
|
1630
|
+
lines.push("### Import");
|
|
1631
|
+
lines.push("");
|
|
1632
|
+
lines.push("```tsx");
|
|
1633
|
+
lines.push(codeInfo.importStatement);
|
|
1634
|
+
lines.push("```");
|
|
1635
|
+
lines.push("");
|
|
1636
|
+
}
|
|
1637
|
+
// CVA / variant definition
|
|
1638
|
+
if (codeInfo.variantDefinition) {
|
|
1639
|
+
lines.push("### Variant Definition");
|
|
1640
|
+
lines.push("");
|
|
1641
|
+
lines.push("```tsx");
|
|
1642
|
+
lines.push(codeInfo.variantDefinition);
|
|
1643
|
+
lines.push("```");
|
|
1644
|
+
lines.push("");
|
|
1645
|
+
}
|
|
1646
|
+
// Component API - main props
|
|
1647
|
+
if (codeInfo.props && codeInfo.props.length > 0) {
|
|
1648
|
+
lines.push("### Component API");
|
|
1649
|
+
lines.push("");
|
|
1650
|
+
lines.push("| Prop | Type | Default | Description |");
|
|
1651
|
+
lines.push("|------|------|---------|-------------|");
|
|
1652
|
+
for (const p of codeInfo.props) {
|
|
1653
|
+
lines.push(`| \`${p.name}\` | \`${p.type.replace(/\|/g, "\\|")}\` | ${p.defaultValue ? `\`${p.defaultValue}\`` : "—"} | ${p.description ?? "—"} |`);
|
|
1654
|
+
}
|
|
1655
|
+
lines.push("");
|
|
1656
|
+
}
|
|
1657
|
+
// Sub-component APIs
|
|
1658
|
+
if (codeInfo.subComponents && codeInfo.subComponents.length > 0) {
|
|
1659
|
+
for (const sub of codeInfo.subComponents) {
|
|
1660
|
+
lines.push(`#### ${sub.name}`);
|
|
1661
|
+
lines.push("");
|
|
1662
|
+
if (sub.description) {
|
|
1663
|
+
lines.push(sub.description);
|
|
1664
|
+
lines.push("");
|
|
1665
|
+
}
|
|
1666
|
+
if (sub.element) {
|
|
1667
|
+
lines.push(`Renders a \`<${sub.element}>\`${sub.dataSlot ? ` with \`data-slot="${sub.dataSlot}"\`` : ""}.`);
|
|
1668
|
+
lines.push("");
|
|
1669
|
+
}
|
|
1670
|
+
if (sub.props && sub.props.length > 0) {
|
|
1671
|
+
lines.push("| Prop | Type | Default | Description |");
|
|
1672
|
+
lines.push("|------|------|---------|-------------|");
|
|
1673
|
+
for (const p of sub.props) {
|
|
1674
|
+
lines.push(`| \`${p.name}\` | \`${p.type.replace(/\|/g, "\\|")}\` | ${p.defaultValue ? `\`${p.defaultValue}\`` : "—"} | ${p.description ?? "—"} |`);
|
|
1675
|
+
}
|
|
1676
|
+
lines.push("");
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
// Events
|
|
1681
|
+
if (codeInfo.events && codeInfo.events.length > 0) {
|
|
1682
|
+
lines.push("### Events");
|
|
1683
|
+
lines.push("");
|
|
1684
|
+
lines.push("| Event | Payload | Description |");
|
|
1685
|
+
lines.push("|-------|---------|-------------|");
|
|
1686
|
+
for (const e of codeInfo.events) {
|
|
1687
|
+
lines.push(`| ${e.name} | ${e.payload ?? "—"} | ${e.description ?? "—"} |`);
|
|
1688
|
+
}
|
|
1689
|
+
lines.push("");
|
|
1690
|
+
}
|
|
1691
|
+
// Slots
|
|
1692
|
+
if (codeInfo.slots && codeInfo.slots.length > 0) {
|
|
1693
|
+
lines.push("### Slots");
|
|
1694
|
+
lines.push("");
|
|
1695
|
+
for (const s of codeInfo.slots) {
|
|
1696
|
+
lines.push(`- **${s.name}**: ${s.description ?? ""}`);
|
|
1697
|
+
}
|
|
1698
|
+
lines.push("");
|
|
1699
|
+
}
|
|
1700
|
+
// Usage examples
|
|
1701
|
+
if (codeInfo.usageExamples && codeInfo.usageExamples.length > 0) {
|
|
1702
|
+
lines.push("### Usage Examples");
|
|
1703
|
+
lines.push("");
|
|
1704
|
+
for (const ex of codeInfo.usageExamples) {
|
|
1705
|
+
lines.push(`#### ${ex.title}`);
|
|
1706
|
+
lines.push("");
|
|
1707
|
+
lines.push(`\`\`\`${ex.language || "tsx"}`);
|
|
1708
|
+
lines.push(ex.code);
|
|
1709
|
+
lines.push("```");
|
|
1710
|
+
lines.push("");
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return lines.join("\n");
|
|
1714
|
+
}
|
|
1715
|
+
function generateAccessibilitySection(node, parsedDesc, codeInfo) {
|
|
1716
|
+
const lines = ["", "## Accessibility", ""];
|
|
1717
|
+
// Use structured accessibility notes from parsed description
|
|
1718
|
+
if (parsedDesc.accessibilityNotes.length > 0) {
|
|
1719
|
+
for (const note of parsedDesc.accessibilityNotes) {
|
|
1720
|
+
lines.push(`- ${note}`);
|
|
1721
|
+
}
|
|
1722
|
+
lines.push("");
|
|
1723
|
+
}
|
|
1724
|
+
else {
|
|
1725
|
+
// Fallback: check raw description for accessibility content
|
|
1726
|
+
const description = node.descriptionMarkdown || node.description || "";
|
|
1727
|
+
if (description.toLowerCase().includes("aria") || description.toLowerCase().includes("accessibility")) {
|
|
1728
|
+
// Extract accessibility-related lines from description
|
|
1729
|
+
const descLines = description.split("\n");
|
|
1730
|
+
for (const line of descLines) {
|
|
1731
|
+
const lower = line.toLowerCase();
|
|
1732
|
+
if (lower.includes("aria") || lower.includes("accessibility") || lower.includes("screen reader") || lower.includes("keyboard") || lower.includes("focus") || lower.includes("wcag") || lower.includes("contrast")) {
|
|
1733
|
+
lines.push(`- ${line.trim().replace(/^[-*•]\s*/, "")}`);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
if (lines.length > 2) {
|
|
1737
|
+
lines.push("");
|
|
1738
|
+
}
|
|
1739
|
+
else {
|
|
1740
|
+
lines.push("_Accessibility mentions found in description but not in structured format. Review the component description for details._");
|
|
1741
|
+
lines.push("");
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
lines.push("_No accessibility annotations found in Figma. Add annotations to the component description._");
|
|
1746
|
+
lines.push("");
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return lines.join("\n");
|
|
1750
|
+
}
|
|
1751
|
+
function generateChangelogSection(codeInfo) {
|
|
1752
|
+
if (!codeInfo?.changelog || codeInfo.changelog.length === 0)
|
|
1753
|
+
return "";
|
|
1754
|
+
const lines = ["", "## Changelog", ""];
|
|
1755
|
+
lines.push("| Version | Date | Changes |");
|
|
1756
|
+
lines.push("|---------|------|---------|");
|
|
1757
|
+
for (const entry of codeInfo.changelog) {
|
|
1758
|
+
lines.push(`| ${entry.version} | ${entry.date} | ${entry.changes} |`);
|
|
1759
|
+
}
|
|
1760
|
+
lines.push("");
|
|
1761
|
+
return lines.join("\n");
|
|
1762
|
+
}
|
|
1763
|
+
// ============================================================================
|
|
1764
|
+
// New Section Generators (Anatomy, Typography, Content Guidelines, Parity)
|
|
1765
|
+
// ============================================================================
|
|
1766
|
+
function generateAnatomySection(node) {
|
|
1767
|
+
const lines = ["", "## Component Anatomy", ""];
|
|
1768
|
+
// For COMPONENT_SET, list all variants first
|
|
1769
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
|
|
1770
|
+
lines.push(`**${node.children.length} variants:**`);
|
|
1771
|
+
for (const child of node.children) {
|
|
1772
|
+
lines.push(`- ${cleanVariantName(child.name || "Unknown")}`);
|
|
1773
|
+
}
|
|
1774
|
+
lines.push("");
|
|
1775
|
+
}
|
|
1776
|
+
lines.push("### Design Structure (Figma)");
|
|
1777
|
+
lines.push("");
|
|
1778
|
+
const tree = buildAnatomyTree(node);
|
|
1779
|
+
if (tree.includes("└── ") || tree.includes("├── ")) {
|
|
1780
|
+
// Rich tree with children
|
|
1781
|
+
lines.push("```");
|
|
1782
|
+
lines.push(tree);
|
|
1783
|
+
lines.push("```");
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
// Shallow tree (REST API depth limitation)
|
|
1787
|
+
lines.push("```");
|
|
1788
|
+
lines.push(tree);
|
|
1789
|
+
lines.push("```");
|
|
1790
|
+
lines.push("");
|
|
1791
|
+
lines.push("_Note: Tree depth may be limited by the Figma REST API. Use the Desktop Bridge plugin for full node-level anatomy._");
|
|
1792
|
+
}
|
|
1793
|
+
lines.push("");
|
|
1794
|
+
return lines.join("\n");
|
|
1795
|
+
}
|
|
1796
|
+
function generateTypographySection(node) {
|
|
1797
|
+
const textStyles = collectTypographyData(node);
|
|
1798
|
+
if (textStyles.length === 0)
|
|
1799
|
+
return "";
|
|
1800
|
+
const lines = ["", "## Typography", ""];
|
|
1801
|
+
lines.push("| Element | Font | Weight | Size | Line Height | Letter Spacing |");
|
|
1802
|
+
lines.push("|---------|------|--------|------|-------------|----------------|");
|
|
1803
|
+
// Deduplicate by font properties
|
|
1804
|
+
const seen = new Set();
|
|
1805
|
+
for (const ts of textStyles) {
|
|
1806
|
+
const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}`;
|
|
1807
|
+
if (seen.has(key))
|
|
1808
|
+
continue;
|
|
1809
|
+
seen.add(key);
|
|
1810
|
+
lines.push(`| ${ts.nodeName} | ${ts.fontFamily} | ${ts.fontWeightName} (${ts.fontWeight}) | ${ts.fontSize}px | ${ts.lineHeight}px | ${ts.letterSpacing === 0 ? "0" : `${ts.letterSpacing}px`} |`);
|
|
1811
|
+
}
|
|
1812
|
+
lines.push("");
|
|
1813
|
+
return lines.join("\n");
|
|
1814
|
+
}
|
|
1815
|
+
function generateContentGuidelinesSection(parsedDesc) {
|
|
1816
|
+
if (parsedDesc.contentGuidelines.length === 0 && parsedDesc.additionalNotes.length === 0)
|
|
1817
|
+
return "";
|
|
1818
|
+
const lines = ["", "## Content Guidelines", ""];
|
|
1819
|
+
for (const section of parsedDesc.contentGuidelines) {
|
|
1820
|
+
lines.push(`### ${section.heading}`);
|
|
1821
|
+
lines.push("");
|
|
1822
|
+
for (const item of section.items) {
|
|
1823
|
+
lines.push(`- ${item}`);
|
|
1824
|
+
}
|
|
1825
|
+
lines.push("");
|
|
1826
|
+
}
|
|
1827
|
+
if (parsedDesc.additionalNotes.length > 0 && parsedDesc.contentGuidelines.length === 0) {
|
|
1828
|
+
for (const note of parsedDesc.additionalNotes) {
|
|
1829
|
+
lines.push(`- ${note}`);
|
|
1830
|
+
}
|
|
1831
|
+
lines.push("");
|
|
1832
|
+
}
|
|
1833
|
+
return lines.join("\n");
|
|
1834
|
+
}
|
|
1835
|
+
function generateParitySection(node, codeInfo) {
|
|
1836
|
+
const lines = ["", "## Design-Code Parity", ""];
|
|
1837
|
+
// Variant coverage - compare Figma variants with code variants if available
|
|
1838
|
+
// Use case-insensitive comparison: Figma uses "Default", code uses "default"
|
|
1839
|
+
const figmaVariantsRaw = new Map(); // lowercase → original name
|
|
1840
|
+
if (node.type === "COMPONENT_SET" && node.children) {
|
|
1841
|
+
for (const child of node.children) {
|
|
1842
|
+
const match = child.name?.match(/Variant=([^,]+)/i) || child.name?.match(/^([^,=]+)/);
|
|
1843
|
+
if (match) {
|
|
1844
|
+
const raw = match[1].trim();
|
|
1845
|
+
figmaVariantsRaw.set(raw.toLowerCase(), raw);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
// Try to extract code variants from variant definition or props
|
|
1850
|
+
const codeVariantsRaw = new Map(); // lowercase → original name
|
|
1851
|
+
if (codeInfo.props) {
|
|
1852
|
+
const variantProp = codeInfo.props.find((p) => p.name.toLowerCase() === "variant");
|
|
1853
|
+
if (variantProp?.type) {
|
|
1854
|
+
// Match both single and double quoted values: "default" or 'default'
|
|
1855
|
+
const matches = variantProp.type.match(/["']([^"']+)["']/g);
|
|
1856
|
+
if (matches) {
|
|
1857
|
+
for (const m of matches) {
|
|
1858
|
+
const raw = m.replace(/["']/g, "");
|
|
1859
|
+
codeVariantsRaw.set(raw.toLowerCase(), raw);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
if (figmaVariantsRaw.size > 0 || codeVariantsRaw.size > 0) {
|
|
1865
|
+
// Merge by lowercase key
|
|
1866
|
+
const allKeys = new Set([...figmaVariantsRaw.keys(), ...codeVariantsRaw.keys()]);
|
|
1867
|
+
lines.push("### Variant Coverage");
|
|
1868
|
+
lines.push("");
|
|
1869
|
+
lines.push("| Variant | In Figma | In Code | Status |");
|
|
1870
|
+
lines.push("|---------|----------|---------|--------|");
|
|
1871
|
+
for (const key of allKeys) {
|
|
1872
|
+
const figmaName = figmaVariantsRaw.get(key);
|
|
1873
|
+
const codeName = codeVariantsRaw.get(key);
|
|
1874
|
+
const displayName = figmaName || codeName || key;
|
|
1875
|
+
const inFigma = figmaVariantsRaw.has(key);
|
|
1876
|
+
const inCode = codeVariantsRaw.has(key);
|
|
1877
|
+
let status;
|
|
1878
|
+
if (inFigma && inCode) {
|
|
1879
|
+
status = "In sync";
|
|
1880
|
+
}
|
|
1881
|
+
else if (inFigma && !inCode) {
|
|
1882
|
+
status = "Figma-only — needs code variant";
|
|
1883
|
+
}
|
|
1884
|
+
else {
|
|
1885
|
+
status = "Code-only — needs Figma variant";
|
|
1886
|
+
}
|
|
1887
|
+
lines.push(`| ${displayName} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
|
|
1888
|
+
}
|
|
1889
|
+
lines.push("");
|
|
1890
|
+
}
|
|
1891
|
+
return lines.join("\n");
|
|
1892
|
+
}
|
|
1893
|
+
// ============================================================================
|
|
1894
|
+
// CompanyDocsMCP Helper
|
|
1895
|
+
// ============================================================================
|
|
1896
|
+
/** Convert generated markdown into a CompanyDocsMCP-compatible content entry */
|
|
1897
|
+
export function toCompanyDocsEntry(markdown, componentName, figmaUrl, systemName) {
|
|
1898
|
+
return {
|
|
1899
|
+
title: componentName,
|
|
1900
|
+
content: markdown,
|
|
1901
|
+
category: "components",
|
|
1902
|
+
tags: [componentName.toLowerCase(), "design-system", "component"],
|
|
1903
|
+
metadata: {
|
|
1904
|
+
source: "figma-console-mcp",
|
|
1905
|
+
figmaUrl,
|
|
1906
|
+
systemName,
|
|
1907
|
+
generatedAt: new Date().toISOString(),
|
|
1908
|
+
},
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
// ============================================================================
|
|
1912
|
+
// Zod Schemas
|
|
1913
|
+
// ============================================================================
|
|
1914
|
+
const codeSpecSchema = z.object({
|
|
1915
|
+
filePath: z.string().optional().describe("Path to the component source file"),
|
|
1916
|
+
visual: z.object({
|
|
1917
|
+
backgroundColor: z.string().optional(),
|
|
1918
|
+
borderColor: z.string().optional(),
|
|
1919
|
+
borderWidth: z.number().optional(),
|
|
1920
|
+
borderRadius: z.union([z.number(), z.string()]).optional(),
|
|
1921
|
+
opacity: z.number().optional(),
|
|
1922
|
+
fills: z.array(z.object({ color: z.string().optional(), opacity: z.number().optional() })).optional(),
|
|
1923
|
+
strokes: z.array(z.object({ color: z.string().optional(), width: z.number().optional() })).optional(),
|
|
1924
|
+
effects: z.array(z.object({
|
|
1925
|
+
type: z.string(),
|
|
1926
|
+
color: z.string().optional(),
|
|
1927
|
+
offset: z.object({ x: z.number(), y: z.number() }).optional(),
|
|
1928
|
+
blur: z.number().optional(),
|
|
1929
|
+
})).optional(),
|
|
1930
|
+
}).optional().describe("Visual properties from code (colors, borders, effects)"),
|
|
1931
|
+
spacing: z.object({
|
|
1932
|
+
paddingTop: z.number().optional(),
|
|
1933
|
+
paddingRight: z.number().optional(),
|
|
1934
|
+
paddingBottom: z.number().optional(),
|
|
1935
|
+
paddingLeft: z.number().optional(),
|
|
1936
|
+
gap: z.number().optional(),
|
|
1937
|
+
width: z.union([z.number(), z.string()]).optional(),
|
|
1938
|
+
height: z.union([z.number(), z.string()]).optional(),
|
|
1939
|
+
minWidth: z.number().optional(),
|
|
1940
|
+
minHeight: z.number().optional(),
|
|
1941
|
+
maxWidth: z.number().optional(),
|
|
1942
|
+
maxHeight: z.number().optional(),
|
|
1943
|
+
layoutDirection: z.enum(["horizontal", "vertical"]).optional(),
|
|
1944
|
+
}).optional().describe("Spacing and layout properties from code"),
|
|
1945
|
+
typography: z.object({
|
|
1946
|
+
fontFamily: z.string().optional(),
|
|
1947
|
+
fontSize: z.number().optional(),
|
|
1948
|
+
fontWeight: z.union([z.number(), z.string()]).optional(),
|
|
1949
|
+
lineHeight: z.union([z.number(), z.string()]).optional(),
|
|
1950
|
+
letterSpacing: z.number().optional(),
|
|
1951
|
+
textAlign: z.string().optional(),
|
|
1952
|
+
textDecoration: z.string().optional(),
|
|
1953
|
+
textTransform: z.string().optional(),
|
|
1954
|
+
}).optional().describe("Typography properties from code"),
|
|
1955
|
+
tokens: z.object({
|
|
1956
|
+
usedTokens: z.array(z.string()).optional(),
|
|
1957
|
+
hardcodedValues: z.array(z.object({
|
|
1958
|
+
property: z.string(),
|
|
1959
|
+
value: z.union([z.string(), z.number()]),
|
|
1960
|
+
})).optional(),
|
|
1961
|
+
tokenPrefix: z.string().optional(),
|
|
1962
|
+
}).optional().describe("Design token usage in code"),
|
|
1963
|
+
componentAPI: z.object({
|
|
1964
|
+
props: z.array(z.object({
|
|
1965
|
+
name: z.string(),
|
|
1966
|
+
type: z.string(),
|
|
1967
|
+
required: z.boolean().optional(),
|
|
1968
|
+
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
1969
|
+
description: z.string().optional(),
|
|
1970
|
+
values: z.array(z.string()).optional(),
|
|
1971
|
+
})).optional(),
|
|
1972
|
+
events: z.array(z.string()).optional(),
|
|
1973
|
+
slots: z.array(z.string()).optional(),
|
|
1974
|
+
}).optional().describe("Component API (props, events, slots)"),
|
|
1975
|
+
accessibility: z.object({
|
|
1976
|
+
role: z.string().optional(),
|
|
1977
|
+
ariaLabel: z.string().optional(),
|
|
1978
|
+
ariaRequired: z.boolean().optional(),
|
|
1979
|
+
keyboardInteractions: z.array(z.string()).optional(),
|
|
1980
|
+
contrastRatio: z.number().optional(),
|
|
1981
|
+
focusVisible: z.boolean().optional(),
|
|
1982
|
+
}).optional().describe("Accessibility properties from code"),
|
|
1983
|
+
metadata: z.object({
|
|
1984
|
+
name: z.string().optional(),
|
|
1985
|
+
description: z.string().optional(),
|
|
1986
|
+
status: z.string().optional(),
|
|
1987
|
+
version: z.string().optional(),
|
|
1988
|
+
tags: z.array(z.string()).optional(),
|
|
1989
|
+
}).optional().describe("Component metadata from code"),
|
|
1990
|
+
}).describe("Structured code-side component data. Read the component source code first, then fill in the relevant sections.");
|
|
1991
|
+
const codeDocInfoSchema = z.object({
|
|
1992
|
+
props: z.array(z.object({
|
|
1993
|
+
name: z.string(),
|
|
1994
|
+
type: z.string(),
|
|
1995
|
+
required: z.boolean().optional(),
|
|
1996
|
+
defaultValue: z.string().optional(),
|
|
1997
|
+
description: z.string().optional(),
|
|
1998
|
+
})).optional().describe("Component props"),
|
|
1999
|
+
events: z.array(z.object({
|
|
2000
|
+
name: z.string(),
|
|
2001
|
+
payload: z.string().optional(),
|
|
2002
|
+
description: z.string().optional(),
|
|
2003
|
+
})).optional().describe("Events emitted by the component"),
|
|
2004
|
+
slots: z.array(z.object({
|
|
2005
|
+
name: z.string(),
|
|
2006
|
+
description: z.string().optional(),
|
|
2007
|
+
})).optional().describe("Named slots"),
|
|
2008
|
+
importStatement: z.string().optional().describe("Import statement for the component"),
|
|
2009
|
+
usageExamples: z.array(z.object({
|
|
2010
|
+
title: z.string(),
|
|
2011
|
+
code: z.string(),
|
|
2012
|
+
language: z.string().optional(),
|
|
2013
|
+
})).optional().describe("Usage examples"),
|
|
2014
|
+
changelog: z.array(z.object({
|
|
2015
|
+
version: z.string(),
|
|
2016
|
+
date: z.string(),
|
|
2017
|
+
changes: z.string(),
|
|
2018
|
+
})).optional().describe("Changelog entries"),
|
|
2019
|
+
filePath: z.string().optional().describe("Component file path"),
|
|
2020
|
+
packageName: z.string().optional().describe("Package name"),
|
|
2021
|
+
variantDefinition: z.string().optional().describe("CVA or variant definition code block"),
|
|
2022
|
+
subComponents: z.array(z.object({
|
|
2023
|
+
name: z.string(),
|
|
2024
|
+
description: z.string().optional(),
|
|
2025
|
+
element: z.string().optional().describe("HTML element rendered (e.g., 'div', 'span')"),
|
|
2026
|
+
dataSlot: z.string().optional().describe("data-slot attribute value"),
|
|
2027
|
+
props: z.array(z.object({
|
|
2028
|
+
name: z.string(),
|
|
2029
|
+
type: z.string(),
|
|
2030
|
+
required: z.boolean().optional(),
|
|
2031
|
+
defaultValue: z.string().optional(),
|
|
2032
|
+
description: z.string().optional(),
|
|
2033
|
+
})).optional(),
|
|
2034
|
+
})).optional().describe("Sub-components that compose this component (e.g., AlertTitle, AlertDescription)"),
|
|
2035
|
+
sourceFiles: z.array(z.object({
|
|
2036
|
+
path: z.string(),
|
|
2037
|
+
role: z.string(),
|
|
2038
|
+
variants: z.number().optional(),
|
|
2039
|
+
description: z.string().optional(),
|
|
2040
|
+
})).optional().describe("All source files related to this component"),
|
|
2041
|
+
baseComponent: z.object({
|
|
2042
|
+
name: z.string(),
|
|
2043
|
+
url: z.string().optional(),
|
|
2044
|
+
description: z.string().optional(),
|
|
2045
|
+
}).optional().describe("Base component this extends (e.g., shadcn/ui Alert)"),
|
|
2046
|
+
}).describe("Code-side documentation info. Read the component source code first, then fill in relevant sections. Include variantDefinition for CVA/variant code, subComponents for composable sub-parts, sourceFiles for all related files, and baseComponent for attribution.");
|
|
2047
|
+
// ============================================================================
|
|
2048
|
+
// Tool Registration
|
|
2049
|
+
// ============================================================================
|
|
2050
|
+
export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, variablesCache, options, getDesktopConnector) {
|
|
2051
|
+
const isRemoteMode = options?.isRemoteMode ?? false;
|
|
2052
|
+
// -----------------------------------------------------------------------
|
|
2053
|
+
// Tool: figma_check_design_parity
|
|
2054
|
+
// -----------------------------------------------------------------------
|
|
2055
|
+
server.tool("figma_check_design_parity", "Compare a Figma component's design specs against code-side data to find discrepancies. Returns a parity score, categorized discrepancies, and actionable fix items for both design-side (Figma tool calls) and code-side (file edits). Read the component source code first, then pass the data in codeSpec.", {
|
|
2056
|
+
fileUrl: z
|
|
2057
|
+
.string()
|
|
2058
|
+
.url()
|
|
2059
|
+
.optional()
|
|
2060
|
+
.describe("Figma file URL. Uses current URL if omitted."),
|
|
2061
|
+
nodeId: z.string().describe("Component node ID (e.g., '695:313')"),
|
|
2062
|
+
codeSpec: codeSpecSchema,
|
|
2063
|
+
canonicalSource: z
|
|
2064
|
+
.enum(["design", "code"])
|
|
2065
|
+
.optional()
|
|
2066
|
+
.default("design")
|
|
2067
|
+
.describe("Which source is the canonical truth. Fixes will target the other side. Default: 'design'"),
|
|
2068
|
+
enrich: z
|
|
2069
|
+
.boolean()
|
|
2070
|
+
.optional()
|
|
2071
|
+
.default(true)
|
|
2072
|
+
.describe("Enable token coverage and enrichment analysis. Default: true"),
|
|
2073
|
+
}, async ({ fileUrl, nodeId, codeSpec, canonicalSource = "design", enrich = true }) => {
|
|
2074
|
+
try {
|
|
2075
|
+
const url = fileUrl || getCurrentUrl();
|
|
2076
|
+
if (!url) {
|
|
2077
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
2078
|
+
}
|
|
2079
|
+
const fileKey = extractFileKey(url);
|
|
2080
|
+
if (!fileKey) {
|
|
2081
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
2082
|
+
}
|
|
2083
|
+
logger.info({ fileKey, nodeId, canonicalSource, enrich }, "Starting design-code parity check");
|
|
2084
|
+
const api = await getFigmaAPI();
|
|
2085
|
+
// Fetch component node
|
|
2086
|
+
const nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: 2 });
|
|
2087
|
+
const nodeData = nodesResponse?.nodes?.[nodeId];
|
|
2088
|
+
if (!nodeData?.document) {
|
|
2089
|
+
throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
|
|
2090
|
+
}
|
|
2091
|
+
const node = nodeData.document;
|
|
2092
|
+
// Resolve the node to use for visual/spacing/typography comparisons.
|
|
2093
|
+
// COMPONENT_SET frames have container styling (purple annotation stroke, etc.)
|
|
2094
|
+
// that are NOT actual design specs — the real properties live on the variants.
|
|
2095
|
+
const nodeForVisual = resolveVisualNode(node);
|
|
2096
|
+
if (nodeForVisual !== node) {
|
|
2097
|
+
logger.info({ defaultVariant: nodeForVisual.name, type: node.type }, "Using default variant for visual comparison");
|
|
2098
|
+
}
|
|
2099
|
+
// Fetch component metadata for descriptions
|
|
2100
|
+
let componentMeta = null;
|
|
2101
|
+
let allComponentsMeta = null;
|
|
2102
|
+
try {
|
|
2103
|
+
const componentsResponse = await api.getComponents(fileKey);
|
|
2104
|
+
if (componentsResponse?.meta?.components) {
|
|
2105
|
+
allComponentsMeta = componentsResponse.meta.components;
|
|
2106
|
+
componentMeta = allComponentsMeta.find((c) => c.node_id === nodeId);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
catch {
|
|
2110
|
+
logger.warn("Could not fetch component metadata");
|
|
2111
|
+
}
|
|
2112
|
+
// Resolve COMPONENT_SET info (property definitions, set name)
|
|
2113
|
+
let setInfo = { setName: null, setNodeId: null, propertyDefinitions: {} };
|
|
2114
|
+
if (node.type === "COMPONENT_SET") {
|
|
2115
|
+
// We already have the set — read property definitions directly
|
|
2116
|
+
setInfo = {
|
|
2117
|
+
setName: node.name,
|
|
2118
|
+
setNodeId: nodeId,
|
|
2119
|
+
propertyDefinitions: node.componentPropertyDefinitions || {},
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
else if (node.type === "COMPONENT" && isVariantName(node.name)) {
|
|
2123
|
+
try {
|
|
2124
|
+
setInfo = await resolveComponentSetInfo(api, fileKey, nodeId, componentMeta, allComponentsMeta);
|
|
2125
|
+
if (setInfo.setName) {
|
|
2126
|
+
logger.info({ setName: setInfo.setName, setNodeId: setInfo.setNodeId }, "Resolved parent component set");
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
catch {
|
|
2130
|
+
logger.warn("Could not resolve parent component set");
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
// Build a merged node for componentAPI comparison (use set's property definitions)
|
|
2134
|
+
const nodeForAPI = Object.keys(setInfo.propertyDefinitions).length > 0
|
|
2135
|
+
? { ...node, componentPropertyDefinitions: setInfo.propertyDefinitions }
|
|
2136
|
+
: node;
|
|
2137
|
+
// Enrichment for token analysis
|
|
2138
|
+
let enrichedData = null;
|
|
2139
|
+
if (enrich) {
|
|
2140
|
+
try {
|
|
2141
|
+
const enrichmentOptions = {
|
|
2142
|
+
enrich: true,
|
|
2143
|
+
include_usage: true,
|
|
2144
|
+
};
|
|
2145
|
+
enrichedData = await enrichmentService.enrichComponent(node, fileKey, enrichmentOptions);
|
|
2146
|
+
}
|
|
2147
|
+
catch {
|
|
2148
|
+
logger.warn("Enrichment failed, proceeding without token data");
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
// Run all comparators (use nodeForVisual for design properties, nodeForAPI for component API)
|
|
2152
|
+
const discrepancies = [];
|
|
2153
|
+
compareVisual(nodeForVisual, codeSpec, discrepancies);
|
|
2154
|
+
compareSpacing(nodeForVisual, codeSpec, discrepancies);
|
|
2155
|
+
compareTypography(nodeForVisual, codeSpec, discrepancies);
|
|
2156
|
+
compareTokens(enrichedData, codeSpec, discrepancies);
|
|
2157
|
+
compareComponentAPI(nodeForAPI, codeSpec, discrepancies);
|
|
2158
|
+
compareAccessibility(node, codeSpec, discrepancies);
|
|
2159
|
+
compareNaming(node, codeSpec, discrepancies);
|
|
2160
|
+
compareMetadata(node, componentMeta, codeSpec, discrepancies);
|
|
2161
|
+
// Sort by severity
|
|
2162
|
+
const severityOrder = {
|
|
2163
|
+
critical: 0,
|
|
2164
|
+
major: 1,
|
|
2165
|
+
minor: 2,
|
|
2166
|
+
info: 3,
|
|
2167
|
+
};
|
|
2168
|
+
discrepancies.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
2169
|
+
// Calculate scores
|
|
2170
|
+
const counts = { critical: 0, major: 0, minor: 0, info: 0 };
|
|
2171
|
+
const categoryMap = {};
|
|
2172
|
+
for (const d of discrepancies) {
|
|
2173
|
+
counts[d.severity]++;
|
|
2174
|
+
categoryMap[d.category] = (categoryMap[d.category] || 0) + 1;
|
|
2175
|
+
}
|
|
2176
|
+
const parityScore = calculateParityScore(counts.critical, counts.major, counts.minor, counts.info);
|
|
2177
|
+
// Generate action items
|
|
2178
|
+
const actionItems = generateActionItems(discrepancies, nodeId, canonicalSource, codeSpec.filePath);
|
|
2179
|
+
const resolvedName = resolveComponentName(node, setInfo.setName, codeSpec.metadata?.name || codeSpec.filePath?.split("/").pop()?.replace(/\.\w+$/, ""));
|
|
2180
|
+
const result = {
|
|
2181
|
+
summary: {
|
|
2182
|
+
totalDiscrepancies: discrepancies.length,
|
|
2183
|
+
parityScore,
|
|
2184
|
+
byCritical: counts.critical,
|
|
2185
|
+
byMajor: counts.major,
|
|
2186
|
+
byMinor: counts.minor,
|
|
2187
|
+
byInfo: counts.info,
|
|
2188
|
+
categories: categoryMap,
|
|
2189
|
+
},
|
|
2190
|
+
discrepancies,
|
|
2191
|
+
actionItems,
|
|
2192
|
+
ai_instruction: buildParityInstruction(resolvedName, parityScore, counts, canonicalSource, discrepancies.length),
|
|
2193
|
+
designData: {
|
|
2194
|
+
name: node.name,
|
|
2195
|
+
resolvedName,
|
|
2196
|
+
type: node.type,
|
|
2197
|
+
isComponentSet: node.type === "COMPONENT_SET",
|
|
2198
|
+
defaultVariantName: node.type === "COMPONENT_SET" ? nodeForVisual.name : undefined,
|
|
2199
|
+
componentSetName: setInfo.setName,
|
|
2200
|
+
componentSetNodeId: setInfo.setNodeId,
|
|
2201
|
+
fills: nodeForVisual.fills,
|
|
2202
|
+
strokes: nodeForVisual.strokes,
|
|
2203
|
+
cornerRadius: nodeForVisual.cornerRadius,
|
|
2204
|
+
opacity: nodeForVisual.opacity,
|
|
2205
|
+
spacing: extractSpacingProperties(nodeForVisual),
|
|
2206
|
+
componentProperties: nodeForAPI.componentPropertyDefinitions
|
|
2207
|
+
? Object.keys(nodeForAPI.componentPropertyDefinitions)
|
|
2208
|
+
: [],
|
|
2209
|
+
tokenCoverage: enrichedData?.token_coverage,
|
|
2210
|
+
},
|
|
2211
|
+
codeData: codeSpec,
|
|
2212
|
+
};
|
|
2213
|
+
return {
|
|
2214
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
catch (error) {
|
|
2218
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2219
|
+
logger.error({ error: message, nodeId }, "Design parity check failed");
|
|
2220
|
+
return {
|
|
2221
|
+
content: [
|
|
2222
|
+
{
|
|
2223
|
+
type: "text",
|
|
2224
|
+
text: JSON.stringify({
|
|
2225
|
+
error: message,
|
|
2226
|
+
ai_instruction: `Design parity check failed: ${message}. Verify the nodeId is correct and the Figma file is accessible.`,
|
|
2227
|
+
}),
|
|
2228
|
+
},
|
|
2229
|
+
],
|
|
2230
|
+
isError: true,
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2234
|
+
// -----------------------------------------------------------------------
|
|
2235
|
+
// Tool: figma_generate_component_doc
|
|
2236
|
+
// -----------------------------------------------------------------------
|
|
2237
|
+
server.tool("figma_generate_component_doc", "Generate AI-complete component documentation from a Figma component. Produces structured markdown with anatomy, per-variant color tokens, typography, content guidelines (parsed from Figma description), icon mapping, spacing tokens, and design-code parity analysis. Merges Figma design data with optional code-side info (CVA definitions, sub-component APIs, source files). Output works with any docs platform. For richest output, read the component source code first and pass codeInfo.", {
|
|
2238
|
+
fileUrl: z
|
|
2239
|
+
.string()
|
|
2240
|
+
.url()
|
|
2241
|
+
.optional()
|
|
2242
|
+
.describe("Figma file URL. Uses current URL if omitted."),
|
|
2243
|
+
nodeId: z.string().describe("Component node ID (e.g., '695:313')"),
|
|
2244
|
+
codeInfo: codeDocInfoSchema.optional(),
|
|
2245
|
+
sections: z.object({
|
|
2246
|
+
overview: z.boolean().optional().default(true),
|
|
2247
|
+
anatomy: z.boolean().optional().default(true),
|
|
2248
|
+
statesAndVariants: z.boolean().optional().default(true),
|
|
2249
|
+
visualSpecs: z.boolean().optional().default(true),
|
|
2250
|
+
typography: z.boolean().optional().default(true),
|
|
2251
|
+
contentGuidelines: z.boolean().optional().default(true),
|
|
2252
|
+
behavior: z.boolean().optional().default(false),
|
|
2253
|
+
implementation: z.boolean().optional().default(true),
|
|
2254
|
+
accessibility: z.boolean().optional().default(true),
|
|
2255
|
+
relatedComponents: z.boolean().optional().default(false),
|
|
2256
|
+
changelog: z.boolean().optional().default(true),
|
|
2257
|
+
parity: z.boolean().optional().default(true),
|
|
2258
|
+
}).optional().describe("Toggle which sections to include"),
|
|
2259
|
+
outputPath: z.string().optional().describe("Suggested output file path"),
|
|
2260
|
+
systemName: z.string().optional().describe("Design system name for headers"),
|
|
2261
|
+
enrich: z.boolean().optional().default(true).describe("Enable enrichment for token data"),
|
|
2262
|
+
includeFrontmatter: z.boolean().optional().default(true).describe("Include YAML frontmatter metadata"),
|
|
2263
|
+
}, async ({ fileUrl, nodeId, codeInfo, sections, outputPath, systemName, enrich = true, includeFrontmatter = true, }) => {
|
|
2264
|
+
try {
|
|
2265
|
+
const url = fileUrl || getCurrentUrl();
|
|
2266
|
+
if (!url) {
|
|
2267
|
+
throw new Error("No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
|
|
2268
|
+
}
|
|
2269
|
+
const fileKey = extractFileKey(url);
|
|
2270
|
+
if (!fileKey) {
|
|
2271
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
2272
|
+
}
|
|
2273
|
+
logger.info({ fileKey, nodeId, enrich }, "Generating component documentation");
|
|
2274
|
+
const api = await getFigmaAPI();
|
|
2275
|
+
// Fetch component node with deeper depth for anatomy & per-variant data
|
|
2276
|
+
const nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: 4 });
|
|
2277
|
+
const nodeData = nodesResponse?.nodes?.[nodeId];
|
|
2278
|
+
if (!nodeData?.document) {
|
|
2279
|
+
throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
|
|
2280
|
+
}
|
|
2281
|
+
const node = nodeData.document;
|
|
2282
|
+
// Resolve visual node (default variant for COMPONENT_SET, node itself otherwise)
|
|
2283
|
+
const nodeForVisual = resolveVisualNode(node);
|
|
2284
|
+
if (nodeForVisual !== node) {
|
|
2285
|
+
logger.info({ defaultVariant: nodeForVisual.name, type: node.type }, "Using default variant for visual specs in docs");
|
|
2286
|
+
}
|
|
2287
|
+
// Fetch component metadata
|
|
2288
|
+
let componentMeta = null;
|
|
2289
|
+
let allComponentsMeta = null;
|
|
2290
|
+
try {
|
|
2291
|
+
const componentsResponse = await api.getComponents(fileKey);
|
|
2292
|
+
if (componentsResponse?.meta?.components) {
|
|
2293
|
+
allComponentsMeta = componentsResponse.meta.components;
|
|
2294
|
+
componentMeta = allComponentsMeta.find((c) => c.node_id === nodeId);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
catch {
|
|
2298
|
+
logger.warn("Could not fetch component metadata");
|
|
2299
|
+
}
|
|
2300
|
+
// Resolve COMPONENT_SET info (property definitions, set name)
|
|
2301
|
+
let setInfo = { setName: null, setNodeId: null, propertyDefinitions: {} };
|
|
2302
|
+
if (node.type === "COMPONENT_SET") {
|
|
2303
|
+
setInfo = {
|
|
2304
|
+
setName: node.name,
|
|
2305
|
+
setNodeId: nodeId,
|
|
2306
|
+
propertyDefinitions: node.componentPropertyDefinitions || {},
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
else if (node.type === "COMPONENT" && isVariantName(node.name)) {
|
|
2310
|
+
try {
|
|
2311
|
+
setInfo = await resolveComponentSetInfo(api, fileKey, nodeId, componentMeta, allComponentsMeta);
|
|
2312
|
+
if (setInfo.setName) {
|
|
2313
|
+
logger.info({ setName: setInfo.setName, setNodeId: setInfo.setNodeId }, "Resolved parent component set for docs");
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
catch {
|
|
2317
|
+
logger.warn("Could not resolve parent component set");
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
// Use set's property definitions for variants section if available
|
|
2321
|
+
const nodeForVariants = Object.keys(setInfo.propertyDefinitions).length > 0
|
|
2322
|
+
? { ...node, componentPropertyDefinitions: setInfo.propertyDefinitions }
|
|
2323
|
+
: node;
|
|
2324
|
+
// Enrichment
|
|
2325
|
+
let enrichedData = null;
|
|
2326
|
+
if (enrich) {
|
|
2327
|
+
try {
|
|
2328
|
+
const enrichmentOptions = {
|
|
2329
|
+
enrich: true,
|
|
2330
|
+
include_usage: true,
|
|
2331
|
+
};
|
|
2332
|
+
enrichedData = await enrichmentService.enrichComponent(node, fileKey, enrichmentOptions);
|
|
2333
|
+
}
|
|
2334
|
+
catch {
|
|
2335
|
+
logger.warn("Enrichment failed, proceeding without token data");
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
// Build variable name lookup for per-variant color collection
|
|
2339
|
+
const varNameMap = new Map();
|
|
2340
|
+
if (enrichedData?.variables_used) {
|
|
2341
|
+
for (const v of enrichedData.variables_used) {
|
|
2342
|
+
varNameMap.set(v.id, v.name);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
// Collect per-variant color/icon data
|
|
2346
|
+
const variantData = collectAllVariantData(node, varNameMap);
|
|
2347
|
+
// Resolve clean component name (prefer set name over variant name)
|
|
2348
|
+
const componentName = resolveComponentName(node, setInfo.setName, codeInfo?.filePath?.split("/").pop()?.replace(/\.\w+$/, ""));
|
|
2349
|
+
// Prefer markdown description (has headers/bold markers for parsing) over plain text
|
|
2350
|
+
// REST API getNodes() often returns empty description for COMPONENT_SET nodes,
|
|
2351
|
+
// so fall back to Desktop Bridge plugin API which has the reliable description.
|
|
2352
|
+
let description = node.descriptionMarkdown || node.description || componentMeta?.description || "";
|
|
2353
|
+
if (!description && getDesktopConnector) {
|
|
2354
|
+
try {
|
|
2355
|
+
const connector = await getDesktopConnector();
|
|
2356
|
+
const bridgeResult = await connector.getComponentFromPluginUI(nodeId);
|
|
2357
|
+
if (bridgeResult.success && bridgeResult.component) {
|
|
2358
|
+
description = bridgeResult.component.descriptionMarkdown || bridgeResult.component.description || "";
|
|
2359
|
+
if (description) {
|
|
2360
|
+
logger.info("Fetched description via Desktop Bridge (REST API returned empty)");
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
catch {
|
|
2365
|
+
logger.warn("Desktop Bridge description fetch failed, proceeding without description");
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
const fileUrl_ = `${url}?node-id=${nodeId.replace(":", "-")}`;
|
|
2369
|
+
// Parse the component description for structured content
|
|
2370
|
+
const parsedDesc = parseComponentDescription(description);
|
|
2371
|
+
// Determine canonical source
|
|
2372
|
+
const hasCodeInfo = codeInfo !== undefined && codeInfo !== null;
|
|
2373
|
+
const hasFigmaData = node.type === "COMPONENT" || node.type === "COMPONENT_SET";
|
|
2374
|
+
const canonicalSource = hasFigmaData && hasCodeInfo ? "reconciled"
|
|
2375
|
+
: hasCodeInfo ? "code"
|
|
2376
|
+
: "figma";
|
|
2377
|
+
// Resolve sections with defaults
|
|
2378
|
+
const s = {
|
|
2379
|
+
overview: true,
|
|
2380
|
+
anatomy: true,
|
|
2381
|
+
statesAndVariants: true,
|
|
2382
|
+
visualSpecs: true,
|
|
2383
|
+
typography: true,
|
|
2384
|
+
contentGuidelines: true,
|
|
2385
|
+
behavior: false,
|
|
2386
|
+
implementation: true,
|
|
2387
|
+
accessibility: true,
|
|
2388
|
+
relatedComponents: false,
|
|
2389
|
+
changelog: true,
|
|
2390
|
+
parity: true,
|
|
2391
|
+
...sections,
|
|
2392
|
+
};
|
|
2393
|
+
// Build markdown
|
|
2394
|
+
const parts = [];
|
|
2395
|
+
const includedSections = [];
|
|
2396
|
+
if (includeFrontmatter) {
|
|
2397
|
+
parts.push(generateFrontmatter(componentName, description, node, componentMeta, fileUrl_, codeInfo, canonicalSource));
|
|
2398
|
+
parts.push("");
|
|
2399
|
+
}
|
|
2400
|
+
if (s.overview) {
|
|
2401
|
+
parts.push(generateOverviewSection(componentName, description, fileUrl_, parsedDesc, codeInfo));
|
|
2402
|
+
includedSections.push("overview");
|
|
2403
|
+
}
|
|
2404
|
+
if (s.anatomy) {
|
|
2405
|
+
const anatomySection = generateAnatomySection(node);
|
|
2406
|
+
if (anatomySection.trim()) {
|
|
2407
|
+
parts.push(anatomySection);
|
|
2408
|
+
includedSections.push("anatomy");
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
if (s.statesAndVariants) {
|
|
2412
|
+
const variantsSection = generateStatesAndVariantsSection(nodeForVariants, variantData);
|
|
2413
|
+
if (variantsSection) {
|
|
2414
|
+
parts.push(variantsSection);
|
|
2415
|
+
includedSections.push("statesAndVariants");
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
if (s.visualSpecs) {
|
|
2419
|
+
parts.push(generateVisualSpecsSection(nodeForVisual, enrichedData, variantData));
|
|
2420
|
+
includedSections.push("visualSpecs");
|
|
2421
|
+
}
|
|
2422
|
+
if (s.typography) {
|
|
2423
|
+
const typoSection = generateTypographySection(node);
|
|
2424
|
+
if (typoSection) {
|
|
2425
|
+
parts.push(typoSection);
|
|
2426
|
+
includedSections.push("typography");
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
if (s.contentGuidelines) {
|
|
2430
|
+
const contentSection = generateContentGuidelinesSection(parsedDesc);
|
|
2431
|
+
if (contentSection) {
|
|
2432
|
+
parts.push(contentSection);
|
|
2433
|
+
includedSections.push("contentGuidelines");
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
if (s.implementation && codeInfo) {
|
|
2437
|
+
parts.push(generateImplementationSection(codeInfo));
|
|
2438
|
+
includedSections.push("implementation");
|
|
2439
|
+
}
|
|
2440
|
+
if (s.accessibility) {
|
|
2441
|
+
parts.push(generateAccessibilitySection(node, parsedDesc, codeInfo));
|
|
2442
|
+
includedSections.push("accessibility");
|
|
2443
|
+
}
|
|
2444
|
+
if (s.parity && hasCodeInfo && hasFigmaData && codeInfo) {
|
|
2445
|
+
const paritySection = generateParitySection(node, codeInfo);
|
|
2446
|
+
if (paritySection) {
|
|
2447
|
+
parts.push(paritySection);
|
|
2448
|
+
includedSections.push("parity");
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
if (s.changelog && codeInfo?.changelog) {
|
|
2452
|
+
parts.push(generateChangelogSection(codeInfo));
|
|
2453
|
+
includedSections.push("changelog");
|
|
2454
|
+
}
|
|
2455
|
+
const markdown = parts.join("\n");
|
|
2456
|
+
const sanitizedName = sanitizeComponentName(componentName);
|
|
2457
|
+
const suggestedPath = outputPath || `docs/components/${sanitizedName}.md`;
|
|
2458
|
+
// Build enhanced AI instruction
|
|
2459
|
+
const aiInstParts = [
|
|
2460
|
+
`Documentation generated for ${componentName} component (canonical source: ${canonicalSource}).`,
|
|
2461
|
+
`Ask the user where they'd like to save this file. Suggested path: ${suggestedPath}`,
|
|
2462
|
+
];
|
|
2463
|
+
if (!hasCodeInfo) {
|
|
2464
|
+
aiInstParts.push("", "To enhance this documentation, read the component's source code and call this tool again with codeInfo including:", "- filePath: path to the main component file", "- importStatement: how to import the component", "- props: component API (name, type, required, defaultValue, description)", "- variantDefinition: CVA or variant definition code block", "- subComponents: sub-components with their props (e.g., AlertTitle, AlertDescription)", "- sourceFiles: all related source files with roles", "- baseComponent: base component attribution (e.g., shadcn/ui)", "- usageExamples: code examples for each variant/use case");
|
|
2465
|
+
}
|
|
2466
|
+
const result = {
|
|
2467
|
+
componentName,
|
|
2468
|
+
figmaNodeId: nodeId,
|
|
2469
|
+
fileKey,
|
|
2470
|
+
timestamp: new Date().toISOString(),
|
|
2471
|
+
markdown,
|
|
2472
|
+
includedSections,
|
|
2473
|
+
canonicalSource,
|
|
2474
|
+
dataSourceSummary: {
|
|
2475
|
+
figmaEnriched: enrichedData !== null,
|
|
2476
|
+
hasCodeInfo,
|
|
2477
|
+
variablesIncluded: enrichedData?.variables_used !== undefined,
|
|
2478
|
+
stylesIncluded: enrichedData?.styles_used !== undefined,
|
|
2479
|
+
},
|
|
2480
|
+
suggestedOutputPath: suggestedPath,
|
|
2481
|
+
ai_instruction: aiInstParts.join("\n"),
|
|
2482
|
+
};
|
|
2483
|
+
return {
|
|
2484
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
catch (error) {
|
|
2488
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2489
|
+
logger.error({ error: message, nodeId }, "Documentation generation failed");
|
|
2490
|
+
return {
|
|
2491
|
+
content: [
|
|
2492
|
+
{
|
|
2493
|
+
type: "text",
|
|
2494
|
+
text: JSON.stringify({
|
|
2495
|
+
error: message,
|
|
2496
|
+
ai_instruction: `Documentation generation failed: ${message}. Verify the nodeId is correct and the Figma file is accessible.`,
|
|
2497
|
+
}),
|
|
2498
|
+
},
|
|
2499
|
+
],
|
|
2500
|
+
isError: true,
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
//# sourceMappingURL=design-code-tools.js.map
|