@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.
Files changed (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +816 -0
  3. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
  4. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
  5. package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
  6. package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
  7. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
  8. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
  9. package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
  10. package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
  11. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
  12. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
  13. package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
  14. package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
  15. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
  16. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
  17. package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
  18. package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
  19. package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
  20. package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
  21. package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
  22. package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
  23. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
  24. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
  25. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
  26. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
  27. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
  28. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
  29. package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
  30. package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
  31. package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
  32. package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
  33. package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
  34. package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
  35. package/dist/apps/design-system-dashboard/server.d.ts +24 -0
  36. package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
  37. package/dist/apps/design-system-dashboard/server.js +160 -0
  38. package/dist/apps/design-system-dashboard/server.js.map +1 -0
  39. package/dist/apps/token-browser/server.d.ts +26 -0
  40. package/dist/apps/token-browser/server.d.ts.map +1 -0
  41. package/dist/apps/token-browser/server.js +137 -0
  42. package/dist/apps/token-browser/server.js.map +1 -0
  43. package/dist/browser/base.d.ts +58 -0
  44. package/dist/browser/base.d.ts.map +1 -0
  45. package/dist/browser/base.js +6 -0
  46. package/dist/browser/base.js.map +1 -0
  47. package/dist/browser/local.d.ts +87 -0
  48. package/dist/browser/local.d.ts.map +1 -0
  49. package/dist/browser/local.js +318 -0
  50. package/dist/browser/local.js.map +1 -0
  51. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
  52. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
  53. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
  54. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
  55. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
  56. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
  57. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
  58. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
  59. package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
  60. package/dist/cloudflare/apps/token-browser/server.js +136 -0
  61. package/dist/cloudflare/browser/base.js +5 -0
  62. package/dist/cloudflare/browser/cloudflare.js +156 -0
  63. package/dist/cloudflare/browser-manager.js +157 -0
  64. package/dist/cloudflare/core/cloud-websocket-connector.js +267 -0
  65. package/dist/cloudflare/core/cloud-websocket-relay.js +199 -0
  66. package/dist/cloudflare/core/comment-tools.js +292 -0
  67. package/dist/cloudflare/core/config.js +161 -0
  68. package/dist/cloudflare/core/console-monitor.js +427 -0
  69. package/dist/cloudflare/core/design-code-tools.js +2504 -0
  70. package/dist/cloudflare/core/design-system-manifest.js +260 -0
  71. package/dist/cloudflare/core/design-system-tools.js +863 -0
  72. package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
  73. package/dist/cloudflare/core/enrichment/index.js +7 -0
  74. package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
  75. package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
  76. package/dist/cloudflare/core/figma-api.js +409 -0
  77. package/dist/cloudflare/core/figma-connector.js +7 -0
  78. package/dist/cloudflare/core/figma-desktop-connector.js +1184 -0
  79. package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
  80. package/dist/cloudflare/core/figma-style-extractor.js +311 -0
  81. package/dist/cloudflare/core/figma-tools.js +2947 -0
  82. package/dist/cloudflare/core/logger.js +53 -0
  83. package/dist/cloudflare/core/port-discovery.js +282 -0
  84. package/dist/cloudflare/core/snippet-injector.js +96 -0
  85. package/dist/cloudflare/core/types/design-code.js +4 -0
  86. package/dist/cloudflare/core/types/enriched.js +5 -0
  87. package/dist/cloudflare/core/types/index.js +4 -0
  88. package/dist/cloudflare/core/websocket-connector.js +256 -0
  89. package/dist/cloudflare/core/websocket-server.js +646 -0
  90. package/dist/cloudflare/core/write-tools.js +2091 -0
  91. package/dist/cloudflare/index.js +2899 -0
  92. package/dist/cloudflare/test-browser.js +88 -0
  93. package/dist/core/comment-tools.d.ts +11 -0
  94. package/dist/core/comment-tools.d.ts.map +1 -0
  95. package/dist/core/comment-tools.js +293 -0
  96. package/dist/core/comment-tools.js.map +1 -0
  97. package/dist/core/config.d.ts +17 -0
  98. package/dist/core/config.d.ts.map +1 -0
  99. package/dist/core/config.js +162 -0
  100. package/dist/core/config.js.map +1 -0
  101. package/dist/core/console-monitor.d.ts +82 -0
  102. package/dist/core/console-monitor.d.ts.map +1 -0
  103. package/dist/core/console-monitor.js +428 -0
  104. package/dist/core/console-monitor.js.map +1 -0
  105. package/dist/core/design-code-tools.d.ts +127 -0
  106. package/dist/core/design-code-tools.d.ts.map +1 -0
  107. package/dist/core/design-code-tools.js +2505 -0
  108. package/dist/core/design-code-tools.js.map +1 -0
  109. package/dist/core/design-system-manifest.d.ts +272 -0
  110. package/dist/core/design-system-manifest.d.ts.map +1 -0
  111. package/dist/core/design-system-manifest.js +261 -0
  112. package/dist/core/design-system-manifest.js.map +1 -0
  113. package/dist/core/design-system-tools.d.ts +17 -0
  114. package/dist/core/design-system-tools.d.ts.map +1 -0
  115. package/dist/core/design-system-tools.js +864 -0
  116. package/dist/core/design-system-tools.js.map +1 -0
  117. package/dist/core/enrichment/enrichment-service.d.ts +52 -0
  118. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
  119. package/dist/core/enrichment/enrichment-service.js +273 -0
  120. package/dist/core/enrichment/enrichment-service.js.map +1 -0
  121. package/dist/core/enrichment/index.d.ts +8 -0
  122. package/dist/core/enrichment/index.d.ts.map +1 -0
  123. package/dist/core/enrichment/index.js +8 -0
  124. package/dist/core/enrichment/index.js.map +1 -0
  125. package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
  126. package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
  127. package/dist/core/enrichment/relationship-mapper.js +352 -0
  128. package/dist/core/enrichment/relationship-mapper.js.map +1 -0
  129. package/dist/core/enrichment/style-resolver.d.ts +80 -0
  130. package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
  131. package/dist/core/enrichment/style-resolver.js +327 -0
  132. package/dist/core/enrichment/style-resolver.js.map +1 -0
  133. package/dist/core/figma-api.d.ts +201 -0
  134. package/dist/core/figma-api.d.ts.map +1 -0
  135. package/dist/core/figma-api.js +410 -0
  136. package/dist/core/figma-api.js.map +1 -0
  137. package/dist/core/figma-connector.d.ts +48 -0
  138. package/dist/core/figma-connector.d.ts.map +1 -0
  139. package/dist/core/figma-connector.js +8 -0
  140. package/dist/core/figma-connector.js.map +1 -0
  141. package/dist/core/figma-desktop-connector.d.ts +265 -0
  142. package/dist/core/figma-desktop-connector.d.ts.map +1 -0
  143. package/dist/core/figma-desktop-connector.js +1184 -0
  144. package/dist/core/figma-desktop-connector.js.map +1 -0
  145. package/dist/core/figma-reconstruction-spec.d.ts +166 -0
  146. package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
  147. package/dist/core/figma-reconstruction-spec.js +403 -0
  148. package/dist/core/figma-reconstruction-spec.js.map +1 -0
  149. package/dist/core/figma-style-extractor.d.ts +76 -0
  150. package/dist/core/figma-style-extractor.d.ts.map +1 -0
  151. package/dist/core/figma-style-extractor.js +312 -0
  152. package/dist/core/figma-style-extractor.js.map +1 -0
  153. package/dist/core/figma-tools.d.ts +23 -0
  154. package/dist/core/figma-tools.d.ts.map +1 -0
  155. package/dist/core/figma-tools.js +2948 -0
  156. package/dist/core/figma-tools.js.map +1 -0
  157. package/dist/core/logger.d.ts +22 -0
  158. package/dist/core/logger.d.ts.map +1 -0
  159. package/dist/core/logger.js +54 -0
  160. package/dist/core/logger.js.map +1 -0
  161. package/dist/core/port-discovery.d.ts +110 -0
  162. package/dist/core/port-discovery.d.ts.map +1 -0
  163. package/dist/core/port-discovery.js +283 -0
  164. package/dist/core/port-discovery.js.map +1 -0
  165. package/dist/core/snippet-injector.d.ts +24 -0
  166. package/dist/core/snippet-injector.d.ts.map +1 -0
  167. package/dist/core/snippet-injector.js +97 -0
  168. package/dist/core/snippet-injector.js.map +1 -0
  169. package/dist/core/types/design-code.d.ts +262 -0
  170. package/dist/core/types/design-code.d.ts.map +1 -0
  171. package/dist/core/types/design-code.js +5 -0
  172. package/dist/core/types/design-code.js.map +1 -0
  173. package/dist/core/types/enriched.d.ts +213 -0
  174. package/dist/core/types/enriched.d.ts.map +1 -0
  175. package/dist/core/types/enriched.js +6 -0
  176. package/dist/core/types/enriched.js.map +1 -0
  177. package/dist/core/types/index.d.ts +112 -0
  178. package/dist/core/types/index.d.ts.map +1 -0
  179. package/dist/core/types/index.js +5 -0
  180. package/dist/core/types/index.js.map +1 -0
  181. package/dist/core/websocket-connector.d.ts +55 -0
  182. package/dist/core/websocket-connector.d.ts.map +1 -0
  183. package/dist/core/websocket-connector.js +257 -0
  184. package/dist/core/websocket-connector.js.map +1 -0
  185. package/dist/core/websocket-server.d.ts +191 -0
  186. package/dist/core/websocket-server.d.ts.map +1 -0
  187. package/dist/core/websocket-server.js +647 -0
  188. package/dist/core/websocket-server.js.map +1 -0
  189. package/dist/core/write-tools.d.ts +7 -0
  190. package/dist/core/write-tools.d.ts.map +1 -0
  191. package/dist/core/write-tools.js +2092 -0
  192. package/dist/core/write-tools.js.map +1 -0
  193. package/dist/local.d.ts +84 -0
  194. package/dist/local.d.ts.map +1 -0
  195. package/dist/local.js +5039 -0
  196. package/dist/local.js.map +1 -0
  197. package/figma-desktop-bridge/README.md +313 -0
  198. package/figma-desktop-bridge/code.js +2818 -0
  199. package/figma-desktop-bridge/manifest.json +67 -0
  200. package/figma-desktop-bridge/ui.html +1236 -0
  201. package/package.json +87 -0
@@ -0,0 +1,2504 @@
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
+ }