@muloka/figma-console-mcp 0.5.2 → 0.5.4

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 (103) hide show
  1. package/README.md +5 -4
  2. package/dist/apps/design-system-dashboard/mcp-app.html +521 -0
  3. package/dist/apps/token-browser/mcp-app.html +410 -0
  4. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
  5. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
  6. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
  7. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
  8. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
  9. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
  10. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
  11. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
  12. package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
  13. package/dist/cloudflare/apps/token-browser/server.js +136 -0
  14. package/dist/cloudflare/browser-manager.js +157 -0
  15. package/dist/cloudflare/core/accessibility-tools.js +361 -0
  16. package/dist/cloudflare/core/annotation-tools.js +230 -0
  17. package/dist/cloudflare/core/cloud-websocket-connector.js +419 -0
  18. package/dist/cloudflare/core/cloud-websocket-relay.js +198 -0
  19. package/dist/cloudflare/core/comment-tools.js +292 -0
  20. package/dist/cloudflare/core/config.js +153 -0
  21. package/dist/cloudflare/core/console-monitor.js +427 -0
  22. package/dist/cloudflare/core/deep-component-tools.js +128 -0
  23. package/dist/cloudflare/core/design-code-tools.js +2793 -0
  24. package/dist/cloudflare/core/design-system-manifest.js +265 -0
  25. package/dist/cloudflare/core/design-system-tools.js +882 -0
  26. package/dist/cloudflare/core/diagnose-tool.js +100 -0
  27. package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
  28. package/dist/cloudflare/core/diff/diff-engine.js +334 -0
  29. package/dist/cloudflare/core/diff/property-compare.js +36 -0
  30. package/dist/cloudflare/core/diff/version-cache.js +74 -0
  31. package/dist/cloudflare/core/enrichment/enrichment-service.js +374 -0
  32. package/dist/cloudflare/core/enrichment/index.js +7 -0
  33. package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
  34. package/dist/cloudflare/core/enrichment/style-resolver.js +346 -0
  35. package/dist/cloudflare/core/figjam-tools.js +547 -0
  36. package/dist/cloudflare/core/figma-api.js +509 -0
  37. package/dist/cloudflare/core/figma-connector.js +7 -0
  38. package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
  39. package/dist/cloudflare/core/figma-style-extractor.js +311 -0
  40. package/dist/cloudflare/core/figma-tools.js +3353 -0
  41. package/dist/cloudflare/core/identity.js +96 -0
  42. package/dist/cloudflare/core/library-tools.js +580 -0
  43. package/dist/cloudflare/core/logger.js +53 -0
  44. package/dist/cloudflare/core/port-discovery.js +935 -0
  45. package/dist/cloudflare/core/resolve-package-root.js +11 -0
  46. package/dist/cloudflare/core/slides-tools.js +714 -0
  47. package/dist/cloudflare/core/slot-tools.js +334 -0
  48. package/dist/cloudflare/core/snippet-injector.js +96 -0
  49. package/dist/cloudflare/core/tokens/alias-resolver.js +205 -0
  50. package/dist/cloudflare/core/tokens/config.js +294 -0
  51. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  52. package/dist/cloudflare/core/tokens/figma-converter.js +323 -0
  53. package/dist/cloudflare/core/tokens/formatters/css-vars.js +338 -0
  54. package/dist/cloudflare/core/tokens/formatters/dtcg.js +376 -0
  55. package/dist/cloudflare/core/tokens/formatters/index.js +45 -0
  56. package/dist/cloudflare/core/tokens/formatters/json.js +205 -0
  57. package/dist/cloudflare/core/tokens/formatters/less.js +4 -0
  58. package/dist/cloudflare/core/tokens/formatters/scss.js +258 -0
  59. package/dist/cloudflare/core/tokens/formatters/stubs.js +13 -0
  60. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +213 -0
  61. package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +237 -0
  62. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +335 -0
  63. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +256 -0
  64. package/dist/cloudflare/core/tokens/formatters/ts-module.js +198 -0
  65. package/dist/cloudflare/core/tokens/index.js +16 -0
  66. package/dist/cloudflare/core/tokens/parsers/css-vars.js +4 -0
  67. package/dist/cloudflare/core/tokens/parsers/dtcg.js +280 -0
  68. package/dist/cloudflare/core/tokens/parsers/index.js +138 -0
  69. package/dist/cloudflare/core/tokens/parsers/json.js +7 -0
  70. package/dist/cloudflare/core/tokens/parsers/scss.js +4 -0
  71. package/dist/cloudflare/core/tokens/parsers/stubs.js +20 -0
  72. package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +4 -0
  73. package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +4 -0
  74. package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +4 -0
  75. package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +4 -0
  76. package/dist/cloudflare/core/tokens/schemas.js +152 -0
  77. package/dist/cloudflare/core/tokens/transforms/color.js +12 -0
  78. package/dist/cloudflare/core/tokens/transforms/index.js +29 -0
  79. package/dist/cloudflare/core/tokens/transforms/size.js +7 -0
  80. package/dist/cloudflare/core/tokens/types.js +18 -0
  81. package/dist/cloudflare/core/tokens-tools.js +1788 -0
  82. package/dist/cloudflare/core/types/design-code.js +4 -0
  83. package/dist/cloudflare/core/types/enriched.js +5 -0
  84. package/dist/cloudflare/core/types/index.js +4 -0
  85. package/dist/cloudflare/core/variable-resolver.js +85 -0
  86. package/dist/cloudflare/core/version-tools.js +1199 -0
  87. package/dist/cloudflare/core/websocket-connector.js +436 -0
  88. package/dist/cloudflare/core/websocket-server.js +1009 -0
  89. package/dist/cloudflare/core/write-tools.js +2506 -0
  90. package/dist/cloudflare/index.js +3010 -0
  91. package/dist/core/accessibility-tools.d.ts.map +1 -1
  92. package/dist/core/accessibility-tools.js +7 -6
  93. package/dist/core/accessibility-tools.js.map +1 -1
  94. package/dist/core/http-request-guard.d.ts +20 -0
  95. package/dist/core/http-request-guard.d.ts.map +1 -0
  96. package/dist/core/http-request-guard.js +61 -0
  97. package/dist/core/http-request-guard.js.map +1 -0
  98. package/dist/core/tokens/schemas.d.ts +6 -6
  99. package/dist/local.d.ts.map +1 -1
  100. package/dist/local.js +4 -0
  101. package/dist/local.js.map +1 -1
  102. package/figma-desktop-bridge/ui.html +7 -0
  103. package/package.json +2 -2
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Accessibility Scorer (weight: 0.15)
3
+ *
4
+ * Checks accessibility-related signals in the design system.
5
+ * Evaluates color contrast ratios, state variant coverage,
6
+ * and semantic color naming patterns.
7
+ */
8
+ import { clamp, getSeverity } from "./types.js";
9
+ /** Maximum examples to include in a finding. */
10
+ const MAX_EXAMPLES = 5;
11
+ /** WCAG AA minimum contrast ratio for normal text. */
12
+ const WCAG_AA_RATIO = 4.5;
13
+ /** State-related variant values that indicate accessible component design. */
14
+ const STATE_VARIANTS = [
15
+ "disabled",
16
+ "error",
17
+ "focus",
18
+ "hover",
19
+ "active",
20
+ "pressed",
21
+ "selected",
22
+ ];
23
+ /** Semantic color token name patterns that indicate accessibility awareness. */
24
+ const SEMANTIC_COLOR_NAMES = ["error", "warning", "success", "info", "danger"];
25
+ /**
26
+ * Linearize an sRGB channel value for luminance calculation.
27
+ * Input: channel value in 0-1 range.
28
+ */
29
+ function linearize(channel) {
30
+ return channel <= 0.04045
31
+ ? channel / 12.92
32
+ : ((channel + 0.055) / 1.055) ** 2.4;
33
+ }
34
+ /**
35
+ * Calculate relative luminance of a color.
36
+ * r, g, b are in 0-1 range (as Figma provides them).
37
+ */
38
+ function luminance(r, g, b) {
39
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
40
+ }
41
+ /**
42
+ * Calculate contrast ratio between two colors.
43
+ * Returns a ratio >= 1 (e.g., 4.5 for WCAG AA compliance).
44
+ */
45
+ function contrastRatio(r1, g1, b1, r2, g2, b2) {
46
+ const lum1 = luminance(r1, g1, b1);
47
+ const lum2 = luminance(r2, g2, b2);
48
+ const lighter = Math.max(lum1, lum2);
49
+ const darker = Math.min(lum1, lum2);
50
+ return (lighter + 0.05) / (darker + 0.05);
51
+ }
52
+ /**
53
+ * Check if a value is a direct color (not an alias).
54
+ */
55
+ function isDirectColor(value) {
56
+ if (typeof value !== "object" || value === null)
57
+ return false;
58
+ const v = value;
59
+ return (typeof v.r === "number" &&
60
+ typeof v.g === "number" &&
61
+ typeof v.b === "number" &&
62
+ v.type !== "VARIABLE_ALIAS");
63
+ }
64
+ /**
65
+ * Extract resolved color values from variables.
66
+ * Returns an array of { name, r, g, b } objects for direct color values.
67
+ */
68
+ function extractColorValues(variables) {
69
+ const colors = [];
70
+ for (const variable of variables) {
71
+ if (variable.resolvedType !== "COLOR" || !variable.valuesByMode)
72
+ continue;
73
+ for (const value of Object.values(variable.valuesByMode)) {
74
+ if (isDirectColor(value)) {
75
+ colors.push({
76
+ name: variable.name,
77
+ r: value.r,
78
+ g: value.g,
79
+ b: value.b,
80
+ });
81
+ break; // Use the first direct color value per variable
82
+ }
83
+ }
84
+ }
85
+ return colors;
86
+ }
87
+ /**
88
+ * Identify likely foreground/background color pairs and check contrast.
89
+ *
90
+ * Strategy: pair colors whose names suggest fg/bg relationships:
91
+ * - Names containing "background"/"bg"/"surface" are backgrounds
92
+ * - Names containing "text"/"foreground"/"fg"/"on" are foregrounds
93
+ * Falls back to pairing dark colors with light colors if no naming convention found.
94
+ */
95
+ function scoreColorContrast(data) {
96
+ const colors = extractColorValues(data.variables);
97
+ if (colors.length < 2) {
98
+ return {
99
+ id: "a11y-color-contrast",
100
+ label: "Color contrast",
101
+ score: 100,
102
+ severity: "info",
103
+ tooltip: "Foreground/background color pairs should meet WCAG AA contrast ratio (4.5:1). Low contrast makes content unreadable for users with vision impairments.",
104
+ details: colors.length === 0
105
+ ? "No direct color values to evaluate."
106
+ : "Only one color found; need at least two to check contrast.",
107
+ };
108
+ }
109
+ const bgPattern = /background|bg|surface|canvas|base/i;
110
+ const fgPattern = /text|foreground|fg|on-|on\.|label|title|body|heading/i;
111
+ const backgrounds = colors.filter((c) => bgPattern.test(c.name));
112
+ const foregrounds = colors.filter((c) => fgPattern.test(c.name));
113
+ // If naming conventions are not used, use luminance-based heuristic
114
+ let pairs = [];
115
+ if (backgrounds.length > 0 && foregrounds.length > 0) {
116
+ // Pair foregrounds with backgrounds by naming proximity
117
+ for (const fg of foregrounds) {
118
+ for (const bg of backgrounds) {
119
+ pairs.push({ fg, bg });
120
+ }
121
+ }
122
+ }
123
+ else {
124
+ // Heuristic: separate into light (luminance > 0.5) and dark (luminance <= 0.5)
125
+ const lightColors = colors.filter((c) => luminance(c.r, c.g, c.b) > 0.5);
126
+ const darkColors = colors.filter((c) => luminance(c.r, c.g, c.b) <= 0.5);
127
+ for (const dark of darkColors) {
128
+ for (const light of lightColors) {
129
+ pairs.push({ fg: dark, bg: light });
130
+ }
131
+ }
132
+ }
133
+ if (pairs.length === 0) {
134
+ return {
135
+ id: "a11y-color-contrast",
136
+ label: "Color contrast",
137
+ score: 50,
138
+ severity: "warning",
139
+ tooltip: "Foreground/background color pairs should meet WCAG AA contrast ratio (4.5:1). Low contrast makes content unreadable for users with vision impairments.",
140
+ details: "Could not identify foreground/background color pairs to check contrast.",
141
+ };
142
+ }
143
+ // Limit pair evaluation to avoid performance issues with large token sets
144
+ const maxPairs = 50;
145
+ if (pairs.length > maxPairs) {
146
+ pairs = pairs.slice(0, maxPairs);
147
+ }
148
+ let passingPairs = 0;
149
+ const failingExamples = [];
150
+ for (const { fg, bg } of pairs) {
151
+ const ratio = contrastRatio(fg.r, fg.g, fg.b, bg.r, bg.g, bg.b);
152
+ if (ratio >= WCAG_AA_RATIO) {
153
+ passingPairs++;
154
+ }
155
+ else if (failingExamples.length < MAX_EXAMPLES) {
156
+ failingExamples.push(`${fg.name} / ${bg.name} (${ratio.toFixed(1)}:1)`);
157
+ }
158
+ }
159
+ const passRatio = passingPairs / pairs.length;
160
+ const score = clamp(passRatio * 100);
161
+ return {
162
+ id: "a11y-color-contrast",
163
+ label: "Color contrast",
164
+ score,
165
+ severity: getSeverity(score),
166
+ tooltip: "Foreground/background color pairs should meet WCAG AA contrast ratio (4.5:1). Low contrast makes content unreadable for users with vision impairments.",
167
+ details: `${passingPairs} of ${pairs.length} color pairs meet WCAG AA contrast ratio (${WCAG_AA_RATIO}:1).`,
168
+ examples: failingExamples.length > 0 ? failingExamples : undefined,
169
+ };
170
+ }
171
+ /**
172
+ * Score state variant coverage.
173
+ * Components should include state-related variants for accessibility.
174
+ */
175
+ function scoreStateVariants(data) {
176
+ const components = data.components;
177
+ if (components.length === 0) {
178
+ return {
179
+ id: "a11y-state-variants",
180
+ label: "State variants",
181
+ score: 100,
182
+ severity: "info",
183
+ tooltip: "Interactive components should include state variants (disabled, error, focus, hover, active, pressed, selected) for accessible interactions.",
184
+ details: "No components to evaluate.",
185
+ };
186
+ }
187
+ // Check which state variants exist across all component names
188
+ const allNames = components.map((c) => c.name.toLowerCase()).join(" ");
189
+ const foundStates = STATE_VARIANTS.filter((state) => allNames.includes(state));
190
+ const ratio = foundStates.length / STATE_VARIANTS.length;
191
+ const score = clamp(ratio * 100);
192
+ const missingStates = STATE_VARIANTS.filter((state) => !allNames.includes(state));
193
+ return {
194
+ id: "a11y-state-variants",
195
+ label: "State variants",
196
+ score,
197
+ severity: getSeverity(score),
198
+ tooltip: "Interactive components should include state variants (disabled, error, focus, hover, active, pressed, selected) for accessible interactions.",
199
+ details: missingStates.length > 0
200
+ ? `Found ${foundStates.length} of ${STATE_VARIANTS.length} state variants. Missing: ${missingStates.join(", ")}.`
201
+ : `All ${STATE_VARIANTS.length} state variants are represented.`,
202
+ };
203
+ }
204
+ /**
205
+ * Score semantic color naming.
206
+ * The token set should include semantic color tokens for error/warning/success/info.
207
+ */
208
+ function variableDataUnavailable(data) {
209
+ return (data.dataAvailability !== undefined && !data.dataAvailability.variables);
210
+ }
211
+ function scoreSemanticColorNaming(data) {
212
+ const colorVars = data.variables.filter((v) => v.resolvedType === "COLOR");
213
+ if (colorVars.length === 0) {
214
+ const unavailable = variableDataUnavailable(data);
215
+ return {
216
+ id: "a11y-semantic-colors",
217
+ label: "Semantic color naming",
218
+ score: 0,
219
+ severity: unavailable ? "info" : "fail",
220
+ tooltip: "The token set should include semantic color categories (error, warning, success, info, danger) to convey meaning beyond color alone.",
221
+ details: unavailable
222
+ ? `Variable data unavailable: ${data.dataAvailability?.variableError || "Requires Desktop Bridge or Enterprise plan."}`
223
+ : "No color variables found.",
224
+ };
225
+ }
226
+ const foundSemantic = [];
227
+ const missingSemantic = [];
228
+ const semanticExamples = [];
229
+ for (const name of SEMANTIC_COLOR_NAMES) {
230
+ const matching = colorVars.filter((v) => v.name.toLowerCase().includes(name));
231
+ if (matching.length > 0) {
232
+ foundSemantic.push(name);
233
+ semanticExamples.push(`${name}: ${matching
234
+ .slice(0, 2)
235
+ .map((v) => v.name)
236
+ .join(", ")}`);
237
+ }
238
+ else {
239
+ missingSemantic.push(name);
240
+ }
241
+ }
242
+ const ratio = foundSemantic.length / SEMANTIC_COLOR_NAMES.length;
243
+ const score = clamp(ratio * 100);
244
+ return {
245
+ id: "a11y-semantic-colors",
246
+ label: "Semantic color naming",
247
+ score,
248
+ severity: getSeverity(score),
249
+ tooltip: "The token set should include semantic color categories (error, warning, success, info, danger) to convey meaning beyond color alone.",
250
+ details: missingSemantic.length > 0
251
+ ? `Found ${foundSemantic.length} of ${SEMANTIC_COLOR_NAMES.length} semantic color categories. Missing: ${missingSemantic.join(", ")}.`
252
+ : "All semantic color categories (error, warning, success, info, danger) are present.",
253
+ examples: semanticExamples.length > 0
254
+ ? semanticExamples.slice(0, MAX_EXAMPLES)
255
+ : undefined,
256
+ };
257
+ }
258
+ /**
259
+ * Accessibility category scorer.
260
+ * Returns the average score across all accessibility checks.
261
+ */
262
+ export function scoreAccessibility(data) {
263
+ const findings = [
264
+ scoreColorContrast(data),
265
+ scoreStateVariants(data),
266
+ scoreSemanticColorNaming(data),
267
+ ];
268
+ const score = clamp(findings.reduce((sum, f) => sum + f.score, 0) / findings.length);
269
+ return {
270
+ id: "accessibility",
271
+ label: "Accessibility",
272
+ shortLabel: "Accessibility",
273
+ score,
274
+ weight: 0.15,
275
+ findings,
276
+ };
277
+ }
@@ -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
+ }