@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,357 @@
1
+ /**
2
+ * Component Metadata Scorer (weight: 0.20)
3
+ *
4
+ * Checks component quality and completeness within the design system.
5
+ * Evaluates description presence, description quality, property completeness,
6
+ * variant structure, and category organization.
7
+ *
8
+ * Scores against "scorable units" (component sets + standalone components)
9
+ * rather than raw variant count to avoid inflated totals.
10
+ */
11
+ import { clamp, getSeverity } from "./types.js";
12
+ /** Maximum examples to include in a finding. */
13
+ const MAX_EXAMPLES = 5;
14
+ /** Minimum description length to be considered "quality" documentation. */
15
+ const MIN_QUALITY_DESC_LENGTH = 20;
16
+ /**
17
+ * Build a lookup structure for matching components to variant groups.
18
+ * Collects component set node IDs for `containing_frame.containingComponentSet`
19
+ * matching (primary REST API path) and name prefixes as fallback.
20
+ */
21
+ function buildComponentSetLookup(data) {
22
+ const nodeIds = new Set();
23
+ const namePrefixes = new Set();
24
+ for (const cs of data.componentSets) {
25
+ if (cs.node_id)
26
+ nodeIds.add(cs.node_id);
27
+ if (cs.name)
28
+ namePrefixes.add(cs.name + "/");
29
+ }
30
+ return { nodeIds, namePrefixes };
31
+ }
32
+ /**
33
+ * Check if a component belongs to a variant set.
34
+ *
35
+ * Detection order:
36
+ * 1. Plugin API: `componentSetId` (set on ComponentNode by Figma plugin runtime)
37
+ * 2. REST API: `containing_frame.containingComponentSet` (present on variants
38
+ * returned by GET /v1/files/:key/components)
39
+ * 3. File JSON: `component_set_id` (snake_case field on COMPONENT nodes in file tree)
40
+ * 4. Name prefix: variant names starting with "SetName/" (some API formats)
41
+ * 5. Frame node ID: containing_frame.nodeId matching a known component set node
42
+ */
43
+ function isComponentInSet(component, lookup) {
44
+ // Plugin API: direct componentSetId
45
+ if (component.componentSetId)
46
+ return true;
47
+ // REST API: containing_frame.containingComponentSet is set on variant components
48
+ if (component.containing_frame?.containingComponentSet)
49
+ return true;
50
+ // File JSON: snake_case variant of componentSetId
51
+ if (component.component_set_id)
52
+ return true;
53
+ // Name-based fallback: variant names may start with "SetName/"
54
+ if (component.name) {
55
+ for (const prefix of lookup.namePrefixes) {
56
+ if (component.name.startsWith(prefix))
57
+ return true;
58
+ }
59
+ }
60
+ // Frame node ID fallback
61
+ const frameNodeId = component.containing_frame?.nodeId;
62
+ if (frameNodeId && lookup.nodeIds.has(frameNodeId))
63
+ return true;
64
+ return false;
65
+ }
66
+ /**
67
+ * Classify components into standalone, variants, and component sets.
68
+ * Scoring evaluates `scorableUnits` (standalone + componentSets)
69
+ * instead of the raw component list which double-counts variants.
70
+ */
71
+ export function classifyComponents(data) {
72
+ const lookup = buildComponentSetLookup(data);
73
+ const standalone = [];
74
+ const variants = [];
75
+ for (const comp of data.components) {
76
+ if (isComponentInSet(comp, lookup)) {
77
+ variants.push(comp);
78
+ }
79
+ else {
80
+ standalone.push(comp);
81
+ }
82
+ }
83
+ return {
84
+ standalone,
85
+ variants,
86
+ componentSets: data.componentSets,
87
+ scorableUnits: [...standalone, ...data.componentSets],
88
+ };
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Scoring functions (operate on scorable units)
92
+ // ---------------------------------------------------------------------------
93
+ /**
94
+ * Score description presence across scorable units.
95
+ * Component sets and standalone components should have non-empty descriptions.
96
+ */
97
+ function scoreDescriptionPresence(classification) {
98
+ const { scorableUnits } = classification;
99
+ if (scorableUnits.length === 0) {
100
+ return {
101
+ id: "component-desc-presence",
102
+ label: "Description presence",
103
+ score: 100,
104
+ severity: "info",
105
+ tooltip: "Components and component sets should have descriptions. Descriptions appear in Figma's asset panel and help designers find the right component.",
106
+ details: "No components to evaluate.",
107
+ };
108
+ }
109
+ const withDesc = scorableUnits.filter((c) => c.description && c.description.trim().length > 0);
110
+ const withoutDesc = scorableUnits.filter((c) => !c.description || c.description.trim().length === 0);
111
+ const ratio = withDesc.length / scorableUnits.length;
112
+ const score = clamp(ratio * 100);
113
+ return {
114
+ id: "component-desc-presence",
115
+ label: "Description presence",
116
+ score,
117
+ severity: getSeverity(score),
118
+ tooltip: "Components and component sets should have descriptions. Descriptions appear in Figma's asset panel and help designers find the right component.",
119
+ details: `${withDesc.length} of ${scorableUnits.length} components have descriptions (${Math.round(ratio * 100)}%).`,
120
+ examples: withoutDesc.length > 0
121
+ ? withoutDesc.slice(0, MAX_EXAMPLES).map((c) => c.name)
122
+ : undefined,
123
+ locations: withoutDesc.length > 0
124
+ ? withoutDesc.slice(0, MAX_EXAMPLES).map((c) => ({
125
+ name: c.name,
126
+ nodeId: c.node_id,
127
+ type: "component",
128
+ }))
129
+ : undefined,
130
+ };
131
+ }
132
+ /**
133
+ * Score description quality.
134
+ * Descriptions should be meaningful (>20 chars), not just the component name.
135
+ */
136
+ function scoreDescriptionQuality(classification) {
137
+ const { scorableUnits } = classification;
138
+ const withDesc = scorableUnits.filter((c) => c.description && c.description.trim().length > 0);
139
+ if (withDesc.length === 0) {
140
+ return {
141
+ id: "component-desc-quality",
142
+ label: "Description quality",
143
+ score: 0,
144
+ severity: scorableUnits.length === 0 ? "info" : "fail",
145
+ tooltip: "Descriptions should be meaningful (20+ characters) and explain usage, not just repeat the name. Good descriptions reduce misuse.",
146
+ details: scorableUnits.length === 0
147
+ ? "No components to evaluate."
148
+ : "No components have descriptions to evaluate quality.",
149
+ };
150
+ }
151
+ const shortDescs = withDesc.filter((c) => c.description.trim().length < MIN_QUALITY_DESC_LENGTH);
152
+ const qualityCount = withDesc.length - shortDescs.length;
153
+ const ratio = qualityCount / withDesc.length;
154
+ const score = clamp(ratio * 100);
155
+ return {
156
+ id: "component-desc-quality",
157
+ label: "Description quality",
158
+ score,
159
+ severity: getSeverity(score),
160
+ tooltip: "Descriptions should be meaningful (20+ characters) and explain usage, not just repeat the name. Good descriptions reduce misuse.",
161
+ details: shortDescs.length > 0
162
+ ? `${shortDescs.length} of ${withDesc.length} descriptions are too short (<${MIN_QUALITY_DESC_LENGTH} chars). Provide usage guidance, not just names.`
163
+ : `All ${withDesc.length} descriptions provide meaningful documentation.`,
164
+ examples: shortDescs.length > 0
165
+ ? shortDescs.slice(0, MAX_EXAMPLES).map((c) => c.name)
166
+ : undefined,
167
+ };
168
+ }
169
+ /**
170
+ * Score property completeness.
171
+ * Standalone components should define properties for flexibility.
172
+ * Component sets inherently have properties via their variants.
173
+ */
174
+ function scorePropertyCompleteness(classification) {
175
+ const { standalone, componentSets, scorableUnits } = classification;
176
+ if (scorableUnits.length === 0) {
177
+ return {
178
+ id: "component-property-completeness",
179
+ label: "Property completeness",
180
+ score: 100,
181
+ severity: "info",
182
+ tooltip: "Components should expose properties or use variant sets. Properties make components flexible and reduce the need for detaching instances.",
183
+ details: "No components to evaluate.",
184
+ };
185
+ }
186
+ // Component sets always count as having properties (they are variant groups)
187
+ // For standalone, check if they have any property definitions
188
+ const standaloneWithProps = standalone.filter((c) => c.componentPropertyDefinitions &&
189
+ Object.keys(c.componentPropertyDefinitions).length > 0);
190
+ const standaloneWithoutProps = standalone.filter((c) => !c.componentPropertyDefinitions ||
191
+ Object.keys(c.componentPropertyDefinitions).length === 0);
192
+ const withProperties = standaloneWithProps.length + componentSets.length;
193
+ const ratio = withProperties / scorableUnits.length;
194
+ const score = clamp(ratio * 100);
195
+ return {
196
+ id: "component-property-completeness",
197
+ label: "Property completeness",
198
+ score,
199
+ severity: getSeverity(score),
200
+ tooltip: "Components should expose properties or use variant sets. Properties make components flexible and reduce the need for detaching instances.",
201
+ details: `${withProperties} of ${scorableUnits.length} components have defined properties or variants (${Math.round(ratio * 100)}%).`,
202
+ examples: standaloneWithoutProps.length > 0
203
+ ? standaloneWithoutProps.slice(0, MAX_EXAMPLES).map((c) => c.name)
204
+ : undefined,
205
+ locations: standaloneWithoutProps.length > 0
206
+ ? standaloneWithoutProps.slice(0, MAX_EXAMPLES).map((c) => ({
207
+ name: c.name,
208
+ nodeId: c.node_id,
209
+ type: "component",
210
+ }))
211
+ : undefined,
212
+ };
213
+ }
214
+ /**
215
+ * Score variant structure.
216
+ * A higher ratio of component sets to total scorable units indicates
217
+ * good use of variant organization.
218
+ */
219
+ function scoreVariantStructure(classification) {
220
+ const { standalone, componentSets, scorableUnits } = classification;
221
+ if (scorableUnits.length === 0) {
222
+ return {
223
+ id: "component-variant-structure",
224
+ label: "Variant structure",
225
+ score: 100,
226
+ severity: "info",
227
+ tooltip: "Related component variants should be organized into component sets. Sets make variant switching easy and reduce component sprawl.",
228
+ details: "No components to evaluate.",
229
+ };
230
+ }
231
+ const setCount = componentSets.length;
232
+ const ratio = setCount / scorableUnits.length;
233
+ const score = clamp(ratio * 100);
234
+ return {
235
+ id: "component-variant-structure",
236
+ label: "Variant structure",
237
+ score,
238
+ severity: getSeverity(score),
239
+ tooltip: "Related component variants should be organized into component sets. Sets make variant switching easy and reduce component sprawl.",
240
+ details: setCount > 0
241
+ ? `${setCount} of ${scorableUnits.length} components use variant sets (${Math.round(ratio * 100)}%). ${standalone.length} standalone component${standalone.length === 1 ? "" : "s"}.`
242
+ : "No components use variant structures. Consider organizing components into sets with variants.",
243
+ examples: standalone.length > 0 && setCount > 0
244
+ ? standalone.slice(0, MAX_EXAMPLES).map((c) => `${c.name} (standalone)`)
245
+ : undefined,
246
+ };
247
+ }
248
+ /**
249
+ * Score category organization.
250
+ * Components should use path separators (/) for logical grouping.
251
+ */
252
+ function scoreCategoryOrganization(classification) {
253
+ const { scorableUnits } = classification;
254
+ if (scorableUnits.length === 0) {
255
+ return {
256
+ id: "component-category-org",
257
+ label: "Category organization",
258
+ score: 100,
259
+ severity: "info",
260
+ tooltip: 'Component names should use path separators (/) for grouping (e.g. Forms/Input). Organized naming improves asset panel navigation.',
261
+ details: "No components to evaluate.",
262
+ };
263
+ }
264
+ const withPath = scorableUnits.filter((c) => c.name?.includes("/"));
265
+ const withoutPath = scorableUnits.filter((c) => !c.name?.includes("/"));
266
+ const ratio = withPath.length / scorableUnits.length;
267
+ const score = clamp(ratio * 100);
268
+ return {
269
+ id: "component-category-org",
270
+ label: "Category organization",
271
+ score,
272
+ severity: getSeverity(score),
273
+ tooltip: 'Component names should use path separators (/) for grouping (e.g. Forms/Input). Organized naming improves asset panel navigation.',
274
+ details: withPath.length > 0
275
+ ? `${withPath.length} of ${scorableUnits.length} components use path-based grouping (${Math.round(ratio * 100)}%).`
276
+ : 'No components use path separators for grouping. Use "/" in names for organization (e.g., "Forms/Input").',
277
+ examples: withoutPath.length > 0
278
+ ? withoutPath.slice(0, MAX_EXAMPLES).map((c) => c.name)
279
+ : undefined,
280
+ locations: withoutPath.length > 0
281
+ ? withoutPath.slice(0, MAX_EXAMPLES).map((c) => ({
282
+ name: c.name,
283
+ nodeId: c.node_id,
284
+ type: "component",
285
+ }))
286
+ : undefined,
287
+ };
288
+ }
289
+ /** Matches Figma auto-generated layer names like "Frame 347", "Group 12", "Rectangle 5". */
290
+ const GENERIC_NAME_RE = /^(Frame|Group|Rectangle|Ellipse|Line|Polygon|Vector|Text|Image|Slice|Component|Instance|Section|Boolean\s*Group)\s*\d*$/i;
291
+ /**
292
+ * Score generic layer naming.
293
+ * Published components should have intentional names, not auto-generated ones.
294
+ */
295
+ function scoreGenericNaming(classification) {
296
+ const { scorableUnits } = classification;
297
+ if (scorableUnits.length === 0) {
298
+ return {
299
+ id: "component-generic-naming",
300
+ label: "Layer naming",
301
+ score: 100,
302
+ severity: "info",
303
+ tooltip: "Published components should have intentional names. Auto-generated names like Frame 347 or Group 12 indicate layers that were never renamed.",
304
+ details: "No components to evaluate.",
305
+ };
306
+ }
307
+ const genericComps = scorableUnits.filter((c) => {
308
+ const segments = (c.name || "").split("/").map((s) => s.trim());
309
+ return segments.some((seg) => GENERIC_NAME_RE.test(seg));
310
+ });
311
+ const ratio = 1 - genericComps.length / scorableUnits.length;
312
+ const score = clamp(ratio * 100);
313
+ return {
314
+ id: "component-generic-naming",
315
+ label: "Layer naming",
316
+ score,
317
+ severity: getSeverity(score),
318
+ tooltip: "Published components should have intentional names. Auto-generated names like Frame 347 or Group 12 indicate layers that were never renamed.",
319
+ details: genericComps.length > 0
320
+ ? `${genericComps.length} of ${scorableUnits.length} components have auto-generated layer names (e.g. Frame, Group, Rectangle).`
321
+ : "All components have intentional names.",
322
+ examples: genericComps.length > 0
323
+ ? genericComps.slice(0, MAX_EXAMPLES).map((c) => c.name)
324
+ : undefined,
325
+ locations: genericComps.length > 0
326
+ ? genericComps.slice(0, MAX_EXAMPLES).map((c) => ({
327
+ name: c.name,
328
+ nodeId: c.node_id,
329
+ type: "component",
330
+ }))
331
+ : undefined,
332
+ };
333
+ }
334
+ /**
335
+ * Component Metadata category scorer.
336
+ * Returns the average score across all component metadata checks.
337
+ */
338
+ export function scoreComponentMetadata(data) {
339
+ const classification = classifyComponents(data);
340
+ const findings = [
341
+ scoreDescriptionPresence(classification),
342
+ scoreDescriptionQuality(classification),
343
+ scorePropertyCompleteness(classification),
344
+ scoreVariantStructure(classification),
345
+ scoreCategoryOrganization(classification),
346
+ scoreGenericNaming(classification),
347
+ ];
348
+ const score = clamp(findings.reduce((sum, f) => sum + f.score, 0) / findings.length);
349
+ return {
350
+ id: "component-metadata",
351
+ label: "Component Metadata",
352
+ shortLabel: "Components",
353
+ score,
354
+ weight: 0.2,
355
+ findings,
356
+ };
357
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Consistency Scorer (weight: 0.15)
3
+ *
4
+ * Checks pattern uniformity across the design system.
5
+ * Evaluates naming delimiter consistency, casing consistency,
6
+ * size value consistency, and mode naming consistency.
7
+ */
8
+ import { buildCollectionNameMap, clamp, getSeverity } from "./types.js";
9
+ /** Maximum examples to include in a finding. */
10
+ const MAX_EXAMPLES = 5;
11
+ const PASCAL_CASE_RE = /^[A-Z][a-zA-Z0-9]*$/;
12
+ const CAMEL_CASE_RE = /^[a-z][a-zA-Z0-9]*$/;
13
+ const KEBAB_CASE_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
14
+ const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
15
+ /** Supported delimiters in variable names. */
16
+ const DELIMITERS = ["/", ".", "-", "_"];
17
+ /**
18
+ * Count delimiter occurrences across all variable names.
19
+ * Returns a map of delimiter to count of variables that use it.
20
+ */
21
+ function countDelimiterUsage(names) {
22
+ const counts = new Map();
23
+ for (const delimiter of DELIMITERS) {
24
+ counts.set(delimiter, 0);
25
+ }
26
+ for (const name of names) {
27
+ for (const delimiter of DELIMITERS) {
28
+ if (name.includes(delimiter)) {
29
+ counts.set(delimiter, (counts.get(delimiter) ?? 0) + 1);
30
+ }
31
+ }
32
+ }
33
+ return counts;
34
+ }
35
+ /**
36
+ * Score naming delimiter consistency.
37
+ * Variable names should consistently use the same delimiter (/, ., or -).
38
+ */
39
+ function scoreDelimiterConsistency(data) {
40
+ const names = data.variables.map((v) => v.name);
41
+ if (names.length === 0) {
42
+ return {
43
+ id: "consistency-delimiter",
44
+ label: "Naming delimiter consistency",
45
+ score: 100,
46
+ severity: "info",
47
+ tooltip: "Variable names should use the same delimiter throughout (/ or . or -). Mixed delimiters make tokens harder to find and autocomplete.",
48
+ details: "No variables to evaluate.",
49
+ };
50
+ }
51
+ const counts = countDelimiterUsage(names);
52
+ // Find variables that use any delimiter at all
53
+ const varsWithDelimiter = names.filter((name) => DELIMITERS.some((d) => name.includes(d)));
54
+ if (varsWithDelimiter.length === 0) {
55
+ return {
56
+ id: "consistency-delimiter",
57
+ label: "Naming delimiter consistency",
58
+ score: 100,
59
+ severity: "pass",
60
+ tooltip: "Variable names should use the same delimiter throughout (/ or . or -). Mixed delimiters make tokens harder to find and autocomplete.",
61
+ details: "No delimiters used in variable names (single-segment names).",
62
+ };
63
+ }
64
+ // Find the dominant delimiter
65
+ let dominantDelimiter = "/";
66
+ let dominantCount = 0;
67
+ for (const [delimiter, count] of counts.entries()) {
68
+ if (count > dominantCount) {
69
+ dominantCount = count;
70
+ dominantDelimiter = delimiter;
71
+ }
72
+ }
73
+ const ratio = dominantCount / varsWithDelimiter.length;
74
+ const score = clamp(ratio * 100);
75
+ const collectionNames = buildCollectionNameMap(data.collections);
76
+ // Find variables using non-dominant delimiters
77
+ const nonDominantVars = data.variables.filter((v) => {
78
+ const name = v.name;
79
+ const usesDelimiter = DELIMITERS.some((d) => name.includes(d));
80
+ if (!usesDelimiter)
81
+ return false;
82
+ return !name.includes(dominantDelimiter);
83
+ });
84
+ return {
85
+ id: "consistency-delimiter",
86
+ label: "Naming delimiter consistency",
87
+ score,
88
+ severity: getSeverity(score),
89
+ tooltip: "Variable names should use the same delimiter throughout (/ or . or -). Mixed delimiters make tokens harder to find and autocomplete.",
90
+ details: `${Math.round(ratio * 100)}% of variables use "${dominantDelimiter}" as delimiter. Consistent delimiter usage improves navigability.`,
91
+ examples: nonDominantVars.length > 0
92
+ ? nonDominantVars.slice(0, MAX_EXAMPLES).map((v) => v.name)
93
+ : undefined,
94
+ locations: nonDominantVars.length > 0
95
+ ? nonDominantVars.slice(0, MAX_EXAMPLES).map((v) => ({
96
+ name: v.name,
97
+ collection: collectionNames.get(v.variableCollectionId),
98
+ type: "variable",
99
+ }))
100
+ : undefined,
101
+ };
102
+ }
103
+ /**
104
+ * Detect the casing pattern of a name segment.
105
+ */
106
+ function detectCasing(segment) {
107
+ if (PASCAL_CASE_RE.test(segment))
108
+ return "PascalCase";
109
+ if (CAMEL_CASE_RE.test(segment))
110
+ return "camelCase";
111
+ if (KEBAB_CASE_RE.test(segment))
112
+ return "kebab-case";
113
+ if (SNAKE_CASE_RE.test(segment))
114
+ return "snake_case";
115
+ if (segment === segment.toUpperCase() && segment.length > 1)
116
+ return "UPPERCASE";
117
+ if (segment === segment.toLowerCase())
118
+ return "lowercase";
119
+ return "mixed";
120
+ }
121
+ /**
122
+ * Score casing consistency across component and variable names.
123
+ */
124
+ function scoreCasingConsistency(data) {
125
+ // Check component name casing
126
+ const componentSegments = [];
127
+ for (const comp of data.components) {
128
+ const segments = comp.name.split("/").map((s) => s.trim());
129
+ componentSegments.push(...segments);
130
+ }
131
+ // Check variable name leaf segments
132
+ const variableLeaves = [];
133
+ for (const v of data.variables) {
134
+ const segments = v.name.split(/[/.]/);
135
+ if (segments.length > 0) {
136
+ variableLeaves.push(...segments);
137
+ }
138
+ }
139
+ const allSegments = [...componentSegments, ...variableLeaves].filter((s) => s.length > 1);
140
+ if (allSegments.length === 0) {
141
+ return {
142
+ id: "consistency-casing",
143
+ label: "Casing consistency",
144
+ score: 100,
145
+ severity: "info",
146
+ tooltip: "Name segments should use a consistent casing convention (PascalCase, camelCase, etc.) across all components and variables.",
147
+ details: "No name segments to evaluate.",
148
+ };
149
+ }
150
+ // Count casing patterns
151
+ const casingCounts = new Map();
152
+ for (const segment of allSegments) {
153
+ const casing = detectCasing(segment);
154
+ casingCounts.set(casing, (casingCounts.get(casing) ?? 0) + 1);
155
+ }
156
+ // Find dominant casing
157
+ let dominantCasing = "mixed";
158
+ let dominantCount = 0;
159
+ for (const [casing, count] of casingCounts.entries()) {
160
+ if (count > dominantCount) {
161
+ dominantCount = count;
162
+ dominantCasing = casing;
163
+ }
164
+ }
165
+ const ratio = dominantCount / allSegments.length;
166
+ const score = clamp(ratio * 100);
167
+ // Find segments using non-dominant casing
168
+ const nonDominantSegments = allSegments.filter((seg) => detectCasing(seg) !== dominantCasing);
169
+ return {
170
+ id: "consistency-casing",
171
+ label: "Casing consistency",
172
+ score,
173
+ severity: getSeverity(score),
174
+ tooltip: "Name segments should use a consistent casing convention (PascalCase, camelCase, etc.) across all components and variables.",
175
+ details: `${Math.round(ratio * 100)}% of name segments use ${dominantCasing}. Consistent casing improves readability.`,
176
+ examples: nonDominantSegments.length > 0
177
+ ? nonDominantSegments.slice(0, MAX_EXAMPLES)
178
+ : undefined,
179
+ };
180
+ }
181
+ /**
182
+ * Check if numeric values follow a consistent scale pattern.
183
+ * Common patterns: multiples of 4, multiples of 8, powers of 2.
184
+ */
185
+ function detectScalePattern(values) {
186
+ if (values.length === 0)
187
+ return { pattern: "none", matchRatio: 0 };
188
+ const positiveValues = values.filter((v) => v > 0);
189
+ if (positiveValues.length === 0)
190
+ return { pattern: "none", matchRatio: 0 };
191
+ const scales = [
192
+ { name: "4px base", divisor: 4 },
193
+ { name: "8px base", divisor: 8 },
194
+ { name: "2px base", divisor: 2 },
195
+ ];
196
+ let bestPattern = "none";
197
+ let bestRatio = 0;
198
+ for (const scale of scales) {
199
+ const matching = positiveValues.filter((v) => v % scale.divisor === 0).length;
200
+ const ratio = matching / positiveValues.length;
201
+ if (ratio > bestRatio) {
202
+ bestRatio = ratio;
203
+ bestPattern = scale.name;
204
+ }
205
+ }
206
+ return { pattern: bestPattern, matchRatio: bestRatio };
207
+ }
208
+ /**
209
+ * Score size value consistency.
210
+ * Numeric (FLOAT) variables should follow a consistent scale.
211
+ */
212
+ function scoreSizeValueConsistency(data) {
213
+ const floatVars = data.variables.filter((v) => v.resolvedType === "FLOAT");
214
+ if (floatVars.length === 0) {
215
+ return {
216
+ id: "consistency-size-values",
217
+ label: "Size value consistency",
218
+ score: 100,
219
+ severity: "info",
220
+ tooltip: "Numeric token values should follow a consistent scale (e.g. multiples of 4 or 8). Consistent scales create visual rhythm and predictable spacing.",
221
+ details: "No numeric variables to evaluate.",
222
+ };
223
+ }
224
+ // Extract numeric values (skip aliases)
225
+ const numericValues = [];
226
+ for (const v of floatVars) {
227
+ if (!v.valuesByMode)
228
+ continue;
229
+ for (const value of Object.values(v.valuesByMode)) {
230
+ if (typeof value === "number") {
231
+ numericValues.push(value);
232
+ }
233
+ }
234
+ }
235
+ if (numericValues.length === 0) {
236
+ return {
237
+ id: "consistency-size-values",
238
+ label: "Size value consistency",
239
+ score: 50,
240
+ severity: "warning",
241
+ tooltip: "Numeric token values should follow a consistent scale (e.g. multiples of 4 or 8). Consistent scales create visual rhythm and predictable spacing.",
242
+ details: "No direct numeric values found (all aliases).",
243
+ };
244
+ }
245
+ const { pattern, matchRatio } = detectScalePattern(numericValues);
246
+ const score = clamp(matchRatio * 100);
247
+ return {
248
+ id: "consistency-size-values",
249
+ label: "Size value consistency",
250
+ score,
251
+ severity: getSeverity(score),
252
+ tooltip: "Numeric token values should follow a consistent scale (e.g. multiples of 4 or 8). Consistent scales create visual rhythm and predictable spacing.",
253
+ details: pattern !== "none"
254
+ ? `${Math.round(matchRatio * 100)}% of numeric values follow a ${pattern} scale.`
255
+ : "No consistent scale pattern detected in numeric values.",
256
+ };
257
+ }
258
+ /**
259
+ * Score mode naming consistency.
260
+ * All collections should use the same mode names.
261
+ */
262
+ function scoreModeNamingConsistency(data) {
263
+ const collections = data.collections;
264
+ if (collections.length <= 1) {
265
+ return {
266
+ id: "consistency-mode-naming",
267
+ label: "Mode naming consistency",
268
+ score: 100,
269
+ severity: collections.length === 0 ? "info" : "pass",
270
+ tooltip: "All collections with multiple modes should use the same mode names (e.g. Light/Dark). Inconsistent mode names cause confusion.",
271
+ details: collections.length === 0
272
+ ? "No collections to evaluate."
273
+ : "Only one collection; mode naming consistency is not applicable.",
274
+ };
275
+ }
276
+ // Collect mode name sets per collection
277
+ const modeNameSets = [];
278
+ for (const collection of collections) {
279
+ if (collection.modes && collection.modes.length > 0) {
280
+ const modeNames = collection.modes
281
+ .map((m) => m.name.toLowerCase())
282
+ .sort();
283
+ modeNameSets.push(modeNames);
284
+ }
285
+ }
286
+ if (modeNameSets.length <= 1) {
287
+ return {
288
+ id: "consistency-mode-naming",
289
+ label: "Mode naming consistency",
290
+ score: 100,
291
+ severity: "pass",
292
+ tooltip: "All collections with multiple modes should use the same mode names (e.g. Light/Dark). Inconsistent mode names cause confusion.",
293
+ details: "Only one collection has modes; consistency is not applicable.",
294
+ };
295
+ }
296
+ // Compare mode name sets - check how many match the most common pattern
297
+ const serialized = modeNameSets.map((s) => s.join(","));
298
+ const patternCounts = new Map();
299
+ for (const s of serialized) {
300
+ patternCounts.set(s, (patternCounts.get(s) ?? 0) + 1);
301
+ }
302
+ let dominantCount = 0;
303
+ for (const count of patternCounts.values()) {
304
+ if (count > dominantCount) {
305
+ dominantCount = count;
306
+ }
307
+ }
308
+ const ratio = dominantCount / modeNameSets.length;
309
+ const score = clamp(ratio * 100);
310
+ return {
311
+ id: "consistency-mode-naming",
312
+ label: "Mode naming consistency",
313
+ score,
314
+ severity: getSeverity(score),
315
+ tooltip: "All collections with multiple modes should use the same mode names (e.g. Light/Dark). Inconsistent mode names cause confusion.",
316
+ details: ratio < 1
317
+ ? `${Math.round(ratio * 100)}% of collections share the same mode names. Align mode names across collections.`
318
+ : "All collections use consistent mode names.",
319
+ };
320
+ }
321
+ /**
322
+ * Consistency category scorer.
323
+ * Returns the average score across all consistency checks.
324
+ */
325
+ export function scoreConsistency(data) {
326
+ const findings = [
327
+ scoreDelimiterConsistency(data),
328
+ scoreCasingConsistency(data),
329
+ scoreSizeValueConsistency(data),
330
+ scoreModeNamingConsistency(data),
331
+ ];
332
+ const score = clamp(findings.reduce((sum, f) => sum + f.score, 0) / findings.length);
333
+ return {
334
+ id: "consistency",
335
+ label: "Consistency",
336
+ shortLabel: "Consistency",
337
+ score,
338
+ weight: 0.15,
339
+ findings,
340
+ };
341
+ }