figma-console-mcp 1.40.0 → 1.40.2

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.
@@ -283,11 +283,97 @@ export function parseComponentDescription(description) {
283
283
  result.overview = overviewLines.join(" ").trim();
284
284
  return result;
285
285
  }
286
+ /** Strip Figma's internal "#123:4" id suffix from a component property name */
287
+ function stripPropertyIdSuffix(rawName) {
288
+ return rawName.replace(/#\d+:\d+$/, "").trim();
289
+ }
290
+ /**
291
+ * Human-readable name of the main component behind an instance/component id.
292
+ * Variants resolve to "Set name (Value / Value)" since a bare "Size=16" is useless.
293
+ */
294
+ export function resolveMainComponentName(componentId, lookup = {}) {
295
+ if (!componentId)
296
+ return undefined;
297
+ const comp = lookup.components?.[componentId];
298
+ if (!comp?.name)
299
+ return undefined;
300
+ const setName = comp.componentSetId ? lookup.componentSets?.[comp.componentSetId]?.name : undefined;
301
+ if (setName)
302
+ return `${setName} (${cleanVariantName(comp.name)})`;
303
+ return comp.name;
304
+ }
305
+ /** Largest dimension (px) an instance-swap slot can have and still be treated as an icon */
306
+ const MAX_ICON_SLOT_SIZE = 64;
307
+ /**
308
+ * Decide whether an INSTANCE is an icon, and name it.
309
+ * Signals, any of which qualifies:
310
+ * - the layer name or the MAIN COMPONENT name mentions "icon"
311
+ * - the instance is driven by an INSTANCE_SWAP property and is icon-sized —
312
+ * covers slots with neutral layer names like "Leading Modifier"
313
+ * The reported name is the main component (what's actually in the slot) when it
314
+ * can be resolved, falling back to the cleaned layer name.
315
+ */
316
+ function detectIconInstance(node, lookup) {
317
+ if (node.type !== "INSTANCE")
318
+ return null;
319
+ const layerName = node.name || "";
320
+ const mainName = resolveMainComponentName(node.componentId, lookup);
321
+ const swapRef = node.componentPropertyReferences?.mainComponent;
322
+ const swapProperty = typeof swapRef === "string" ? stripPropertyIdSuffix(swapRef) : undefined;
323
+ const box = node.absoluteBoundingBox || node.size;
324
+ const width = box?.width ?? box?.x;
325
+ const height = box?.height ?? box?.y;
326
+ const iconSized = typeof width !== "number" || typeof height !== "number"
327
+ ? true
328
+ : Math.max(width, height) <= MAX_ICON_SLOT_SIZE;
329
+ const nameSaysIcon = /icon/i.test(layerName) || (mainName ? /icon/i.test(mainName) : false);
330
+ if (!nameSaysIcon && !(swapProperty && iconSized))
331
+ return null;
332
+ const cleanedLayerName = layerName.replace(/^icon\s*\/?\s*/i, "").trim() || layerName;
333
+ return {
334
+ name: mainName || cleanedLayerName,
335
+ type: "instance",
336
+ layerName,
337
+ ...(swapProperty ? { swapProperty } : {}),
338
+ };
339
+ }
340
+ /** True when the node has at least one visible solid fill */
341
+ function hasVisibleSolidFill(node) {
342
+ return Array.isArray(node?.fills)
343
+ && node.fills.some((f) => f.type === "SOLID" && f.color && f.visible !== false);
344
+ }
345
+ /**
346
+ * When a variant's root is transparent, some systems paint the surface with a
347
+ * dedicated full-bleed layer instead. Returns that layer: a visible, non-text,
348
+ * non-instance direct child that covers the root's bounds (±1px) and has a
349
+ * solid fill. Anything smaller (an icon, a dot, a divider) is NOT a background.
350
+ */
351
+ function findBackgroundLayer(root) {
352
+ if (hasVisibleSolidFill(root))
353
+ return null;
354
+ const rootBox = root.absoluteBoundingBox;
355
+ if (!rootBox || !Array.isArray(root.children))
356
+ return null;
357
+ for (const child of root.children) {
358
+ if (child.visible === false || child.type === "TEXT" || child.type === "INSTANCE")
359
+ continue;
360
+ const box = child.absoluteBoundingBox;
361
+ if (!box || !hasVisibleSolidFill(child))
362
+ continue;
363
+ const covers = Math.abs(box.x - rootBox.x) <= 1
364
+ && Math.abs(box.y - rootBox.y) <= 1
365
+ && Math.abs(box.width - rootBox.width) <= 1
366
+ && Math.abs(box.height - rootBox.height) <= 1;
367
+ if (covers)
368
+ return child;
369
+ }
370
+ return null;
371
+ }
286
372
  /**
287
373
  * Collect color data from all variants in a COMPONENT_SET.
288
374
  * For single COMPONENTs, returns data for just that component.
289
375
  */
290
- export function collectAllVariantData(node, varNameMap) {
376
+ export function collectAllVariantData(node, varNameMap, lookup = {}) {
291
377
  const variants = [];
292
378
  const nodesToWalk = node.type === "COMPONENT_SET" && node.children?.length > 0
293
379
  ? node.children
@@ -296,44 +382,64 @@ export function collectAllVariantData(node, varNameMap) {
296
382
  const data = {
297
383
  variantName: variant.name || "Default",
298
384
  fills: [],
385
+ descendantFills: [],
386
+ iconColors: [],
299
387
  strokes: [],
300
388
  textColors: [],
301
389
  icons: [],
302
390
  };
303
- walkVariantNode(variant, data, varNameMap, 0, 5);
391
+ const backgroundLayer = findBackgroundLayer(variant);
392
+ if (backgroundLayer)
393
+ data.backgroundLayer = backgroundLayer.name || "background layer";
394
+ walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0, 5, null);
304
395
  variants.push(data);
305
396
  }
306
397
  return variants;
307
398
  }
308
399
  /** Walk a single variant node tree to collect colors and icons */
309
- function walkVariantNode(node, data, varNameMap, depth, maxDepth) {
400
+ function walkVariantNode(node, data, ctx, depth, maxDepth,
401
+ /** Label of the icon instance we're inside of, or null when outside any icon */
402
+ insideIcon) {
310
403
  if (depth > maxDepth)
311
404
  return;
312
405
  const isText = node.type === "TEXT";
313
- // Check if this is an icon instance
314
- if (node.type === "INSTANCE" && (node.name?.toLowerCase().includes("icon") ||
315
- node.name?.toLowerCase().startsWith("icon"))) {
316
- const iconName = node.name.replace(/^icon\s*\/?\s*/i, "").trim();
317
- data.icons.push({ name: iconName || node.name, type: "instance" });
318
- }
319
- // Collect fills
406
+ // Icons are reported once, at the outermost instance — everything beneath it
407
+ // (vectors, nested instances) is that icon's artwork, not a separate icon.
408
+ if (insideIcon === null) {
409
+ const icon = detectIconInstance(node, ctx.lookup);
410
+ if (icon) {
411
+ data.icons.push(icon);
412
+ insideIcon = icon.layerName || icon.name;
413
+ }
414
+ }
415
+ const toEntry = (paint) => {
416
+ const varId = paint.boundVariables?.color?.id;
417
+ return {
418
+ hex: figmaRGBAToHex({ ...paint.color, a: paint.opacity ?? paint.color.a ?? 1 }),
419
+ nodeName: node.name || "",
420
+ variableId: varId,
421
+ variableName: varId ? ctx.varNameMap.get(varId) : undefined,
422
+ };
423
+ };
424
+ // Collect fills. Only the variant root (or its full-bleed stand-in) is a
425
+ // BACKGROUND — a descendant's fill must never be promoted to one, or a
426
+ // transparent variant gets documented with its icon's color as the surface.
320
427
  if (node.fills && Array.isArray(node.fills)) {
321
428
  for (const fill of node.fills) {
322
429
  if (fill.type === "SOLID" && fill.color && fill.visible !== false) {
323
- const hex = figmaRGBAToHex({ ...fill.color, a: fill.opacity ?? fill.color.a ?? 1 });
324
- const varId = fill.boundVariables?.color?.id;
325
- const entry = {
326
- hex,
327
- nodeName: node.name || "",
328
- variableId: varId,
329
- variableName: varId ? varNameMap.get(varId) : undefined,
330
- };
430
+ const entry = toEntry(fill);
331
431
  if (isText) {
332
432
  data.textColors.push(entry);
333
433
  }
334
- else {
434
+ else if (insideIcon !== null) {
435
+ data.iconColors.push({ ...entry, iconLabel: insideIcon });
436
+ }
437
+ else if (depth === 0 || node === ctx.backgroundLayer) {
335
438
  data.fills.push(entry);
336
439
  }
440
+ else {
441
+ data.descendantFills.push(entry);
442
+ }
337
443
  }
338
444
  }
339
445
  }
@@ -341,21 +447,20 @@ function walkVariantNode(node, data, varNameMap, depth, maxDepth) {
341
447
  if (node.strokes && Array.isArray(node.strokes)) {
342
448
  for (const stroke of node.strokes) {
343
449
  if (stroke.type === "SOLID" && stroke.color && stroke.visible !== false) {
344
- const hex = figmaRGBAToHex({ ...stroke.color, a: stroke.opacity ?? stroke.color.a ?? 1 });
345
- const varId = stroke.boundVariables?.color?.id;
346
- data.strokes.push({
347
- hex,
348
- nodeName: node.name || "",
349
- variableId: varId,
350
- variableName: varId ? varNameMap.get(varId) : undefined,
351
- });
450
+ const entry = toEntry(stroke);
451
+ if (insideIcon !== null) {
452
+ data.iconColors.push({ ...entry, iconLabel: insideIcon });
453
+ }
454
+ else {
455
+ data.strokes.push(entry);
456
+ }
352
457
  }
353
458
  }
354
459
  }
355
460
  // Recurse into children
356
461
  if (node.children && Array.isArray(node.children)) {
357
462
  for (const child of node.children) {
358
- walkVariantNode(child, data, varNameMap, depth + 1, maxDepth);
463
+ walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon);
359
464
  }
360
465
  }
361
466
  }
@@ -510,6 +615,159 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
510
615
  }
511
616
  return tokens;
512
617
  }
618
+ /** Parse "Size=lg, State=hover" into ordered [property, value] pairs */
619
+ export function parseVariantProperties(rawName) {
620
+ const pairs = [];
621
+ for (const part of (rawName || "").split(",")) {
622
+ const eqIdx = part.indexOf("=");
623
+ if (eqIdx <= 0)
624
+ continue;
625
+ pairs.push([part.slice(0, eqIdx).trim(), part.slice(eqIdx + 1).trim()]);
626
+ }
627
+ return pairs;
628
+ }
629
+ const SPACING_COMPARISON_PROPS = [
630
+ { key: "paddingTop", label: "Padding top" },
631
+ { key: "paddingRight", label: "Padding right" },
632
+ { key: "paddingBottom", label: "Padding bottom" },
633
+ { key: "paddingLeft", label: "Padding left" },
634
+ { key: "itemSpacing", label: "Gap" },
635
+ { key: "cornerRadius", label: "Border radius" },
636
+ { key: "strokeWeight", label: "Border width" },
637
+ ];
638
+ /** Cap on variant names spelled out in a single note, so 100-variant sets stay readable */
639
+ const MAX_NAMED_VARIANTS = 5;
640
+ function formatSpacingValue(value) {
641
+ return typeof value === "number" ? `${value}px` : value;
642
+ }
643
+ /**
644
+ * Compare spacing across EVERY variant of a COMPONENT_SET instead of reporting
645
+ * the first child as if it spoke for the whole set.
646
+ *
647
+ * - all variants agree → one plain row, same as a single component
648
+ * - they differ along one variant
649
+ * property (e.g. Size) → "varies by Size: sm 4px · md 8px"
650
+ * - they differ with no pattern → "varies: 0px ×8, 55px (Primary / Default)"
651
+ *
652
+ * Separately flags any property bound to a variable on some variants and left
653
+ * hardcoded on others with NO variant property accounting for the split — a
654
+ * reliable fingerprint of an accidental edit.
655
+ *
656
+ * The REST API omits zero-valued spacing fields, so a property missing on one
657
+ * variant but present on another is compared as 0, not skipped.
658
+ */
659
+ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
660
+ const variants = Array.isArray(setNode?.children) ? setNode.children : [];
661
+ const result = { variantCount: variants.length, rows: [], inconsistencies: [] };
662
+ if (variants.length === 0)
663
+ return result;
664
+ for (const { key, label } of SPACING_COMPARISON_PROPS) {
665
+ const isSet = (v) => (v[key] !== undefined && v[key] !== null)
666
+ // Per-corner rounding: REST drops the scalar and sends only the 4-tuple
667
+ || (key === "cornerRadius" && Array.isArray(v.rectangleCornerRadii));
668
+ if (!variants.some(isSet))
669
+ continue;
670
+ const samples = variants.map((v) => {
671
+ let value = v[key] ?? 0;
672
+ // Per-corner radii: `cornerRadius` is absent and the 4-tuple carries the values
673
+ if (key === "cornerRadius" && (v[key] === undefined || v[key] === null) && Array.isArray(v.rectangleCornerRadii)) {
674
+ value = v.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
675
+ }
676
+ const varId = typeof v.boundVariables?.[key]?.id === "string"
677
+ ? v.boundVariables[key].id
678
+ : undefined;
679
+ return {
680
+ displayName: cleanVariantName(v.name || "Unknown"),
681
+ props: parseVariantProperties(v.name || ""),
682
+ value,
683
+ variableName: varId ? varNameMap.get(varId) || varId : undefined,
684
+ signature: `${value}|${varId ?? ""}`,
685
+ display: "",
686
+ };
687
+ });
688
+ const signatures = new Set(samples.map((x) => x.signature));
689
+ if (signatures.size === 1) {
690
+ const only = samples[0];
691
+ result.rows.push({
692
+ property: label,
693
+ uniform: true,
694
+ variableCell: only.variableName ? `\`${only.variableName}\`` : "—",
695
+ valueCell: formatSpacingValue(only.value),
696
+ });
697
+ continue;
698
+ }
699
+ // Variable cell: every distinct token in play, plus a marker when some variants have none
700
+ const variableNames = [...new Set(samples.map((x) => x.variableName).filter((n) => !!n))];
701
+ const bound = samples.filter((x) => x.variableName);
702
+ const unbound = samples.filter((x) => !x.variableName);
703
+ const variableParts = variableNames.map((n) => `\`${n}\``);
704
+ if (bound.length > 0 && unbound.length > 0)
705
+ variableParts.push("unbound");
706
+ const variableCell = variableParts.length > 0 ? variableParts.join(", ") : "—";
707
+ // Two variants can share a pixel value yet bind different tokens. When the
708
+ // binding is part of what varies, show it next to the value — otherwise
709
+ // "sm 8px · lg 8px" reads as a contradiction.
710
+ const bindingVaries = variableNames.length > 1 || (bound.length > 0 && unbound.length > 0);
711
+ for (const x of samples) {
712
+ x.display = bindingVaries
713
+ ? `${formatSpacingValue(x.value)} (${x.variableName ? `\`${x.variableName}\`` : "unbound"})`
714
+ : formatSpacingValue(x.value);
715
+ }
716
+ // Is the variation fully explained by ONE variant property (e.g. Size)?
717
+ let explainedBy = null;
718
+ let explainedGroups = [];
719
+ for (const [propName] of samples[0].props) {
720
+ const groups = new Map();
721
+ for (const x of samples) {
722
+ const propValue = x.props.find(([n]) => n === propName)?.[1];
723
+ if (propValue === undefined) {
724
+ groups.clear();
725
+ break;
726
+ }
727
+ if (!groups.has(propValue))
728
+ groups.set(propValue, new Set());
729
+ groups.get(propValue).add(x.display);
730
+ }
731
+ // groups.size < samples.length: with one variant per group the "explanation"
732
+ // is vacuous — any values at all would satisfy it (single-property sets).
733
+ if (groups.size > 1 && groups.size < samples.length && [...groups.values()].every((vals) => vals.size === 1)) {
734
+ explainedBy = propName;
735
+ explainedGroups = [...groups.entries()].map(([pv, vals]) => [pv, [...vals][0]]);
736
+ break;
737
+ }
738
+ }
739
+ let valueCell;
740
+ if (explainedBy) {
741
+ valueCell = `varies by **${explainedBy}**: ${explainedGroups.map(([pv, val]) => `${pv} ${val}`).join(" · ")}`;
742
+ }
743
+ else {
744
+ // No pattern — list each distinct value, most common first, naming the rare ones
745
+ const byValue = new Map();
746
+ for (const x of samples) {
747
+ if (!byValue.has(x.display))
748
+ byValue.set(x.display, []);
749
+ byValue.get(x.display).push(x.displayName);
750
+ }
751
+ const parts = [...byValue.entries()]
752
+ .sort((a, b) => b[1].length - a[1].length)
753
+ .map(([val, names]) => names.length <= 2 ? `${val} — ${names.join(", ")}` : `${val} ×${names.length}`);
754
+ valueCell = `varies: ${parts.join(", ")}`;
755
+ }
756
+ result.rows.push({ property: label, uniform: false, variableCell, valueCell });
757
+ // Only an UNEXPLAINED binding split is suspicious. When a variant property
758
+ // accounts for it (a "Dot" shape that has no padding at all), it's a design
759
+ // decision — the table row already shows it, and flagging it would be noise.
760
+ if (bound.length > 0 && unbound.length > 0 && !explainedBy) {
761
+ const named = unbound
762
+ .slice(0, MAX_NAMED_VARIANTS)
763
+ .map((x) => `${x.displayName} (${formatSpacingValue(x.value)})`)
764
+ .join(", ");
765
+ const more = unbound.length > MAX_NAMED_VARIANTS ? `, +${unbound.length - MAX_NAMED_VARIANTS} more` : "";
766
+ result.inconsistencies.push(`**${label}** is bound to ${variableNames.map((n) => `\`${n}\``).join(", ")} on ${bound.length} of ${samples.length} variants but hardcoded on ${unbound.length}: ${named}${more}.`);
767
+ }
768
+ }
769
+ return result;
770
+ }
513
771
  // ============================================================================
514
772
  // Component Set Resolution Helpers
515
773
  // ============================================================================
@@ -1546,7 +1804,13 @@ function generateOverviewSection(componentName, description, fileUrl, parsedDesc
1546
1804
  }
1547
1805
  return lines.join("\n");
1548
1806
  }
1549
- function generateStatesAndVariantsSection(node, variantData) {
1807
+ /** Icon cell text: the icon in the slot, flagged when it's only an instance-swap default */
1808
+ function formatIconCell(icon) {
1809
+ if (!icon)
1810
+ return "—";
1811
+ return icon.swapProperty ? `${icon.name} _(default — swappable via **${icon.swapProperty}**)_` : icon.name;
1812
+ }
1813
+ export function generateStatesAndVariantsSection(node, variantData, lookup = {}) {
1550
1814
  const props = node.componentPropertyDefinitions;
1551
1815
  if (!props || Object.keys(props).length === 0)
1552
1816
  return "";
@@ -1554,9 +1818,10 @@ function generateStatesAndVariantsSection(node, variantData) {
1554
1818
  const variants = [];
1555
1819
  const booleans = [];
1556
1820
  const textProps = [];
1821
+ const instanceSwaps = [];
1557
1822
  for (const [rawName, def] of Object.entries(props)) {
1558
1823
  // Strip Figma internal ID suffixes like "#17100:0" from property names
1559
- const name = rawName.replace(/#\d+:\d+$/, "").trim();
1824
+ const name = stripPropertyIdSuffix(rawName);
1560
1825
  if (def.type === "VARIANT") {
1561
1826
  variants.push({
1562
1827
  name,
@@ -1570,6 +1835,14 @@ function generateStatesAndVariantsSection(node, variantData) {
1570
1835
  else if (def.type === "TEXT") {
1571
1836
  textProps.push({ name, defaultValue: def.defaultValue || "" });
1572
1837
  }
1838
+ else if (def.type === "INSTANCE_SWAP") {
1839
+ // defaultValue is the default component's node id — name it when we can
1840
+ instanceSwaps.push({
1841
+ name,
1842
+ defaultName: resolveMainComponentName(def.defaultValue, lookup) ?? null,
1843
+ preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
1844
+ });
1845
+ }
1573
1846
  }
1574
1847
  // Variant matrix with per-variant color data
1575
1848
  if (variants.length > 0 && variantData && variantData.length > 0) {
@@ -1577,8 +1850,10 @@ function generateStatesAndVariantsSection(node, variantData) {
1577
1850
  lines.push("");
1578
1851
  // Determine which columns to show based on available data
1579
1852
  const hasIcons = variantData.some((v) => v.icons.length > 0);
1580
- const hasFills = variantData.some((v) => v.fills.length > 0);
1581
- if (hasFills || hasIcons) {
1853
+ // Any color at all earns the matrix — a set of transparent (outline/ghost)
1854
+ // variants still has text and icon colors worth tabulating.
1855
+ const hasColors = variantData.some((v) => v.fills.length > 0 || v.textColors.length > 0 || v.iconColors.length > 0 || v.strokes.length > 0);
1856
+ if (hasColors || hasIcons) {
1582
1857
  const headerParts = ["Variant", "Background"];
1583
1858
  if (hasIcons)
1584
1859
  headerParts.push("Icon");
@@ -1597,14 +1872,13 @@ function generateStatesAndVariantsSection(node, variantData) {
1597
1872
  ? (bgFill.variableName ? `\`${bgFill.variableName}\` (${bgFill.hex})` : bgFill.hex)
1598
1873
  : "—";
1599
1874
  // Get primary text/icon color
1600
- const textColor = vd.textColors[0] || vd.strokes[0];
1875
+ const textColor = vd.textColors[0] || vd.iconColors[0] || vd.strokes[0];
1601
1876
  const textVal = textColor
1602
1877
  ? (textColor.variableName ? `\`${textColor.variableName}\` (${textColor.hex})` : textColor.hex)
1603
1878
  : "—";
1604
1879
  const rowParts = [`**${displayName}**`, bgVal];
1605
1880
  if (hasIcons) {
1606
- const icon = vd.icons[0]?.name || "—";
1607
- rowParts.push(icon);
1881
+ rowParts.push(formatIconCell(vd.icons[0]));
1608
1882
  }
1609
1883
  rowParts.push(textVal);
1610
1884
  lines.push("| " + rowParts.join(" | ") + " |");
@@ -1619,8 +1893,7 @@ function generateStatesAndVariantsSection(node, variantData) {
1619
1893
  lines.push("|---------|---------------------|");
1620
1894
  for (const vd of variantData) {
1621
1895
  const displayName = cleanVariantName(vd.variantName);
1622
- const icon = vd.icons[0]?.name || "—";
1623
- lines.push(`| ${displayName} | ${icon} |`);
1896
+ lines.push(`| ${displayName} | ${formatIconCell(vd.icons[0])} |`);
1624
1897
  }
1625
1898
  lines.push("");
1626
1899
  }
@@ -1635,7 +1908,7 @@ function generateStatesAndVariantsSection(node, variantData) {
1635
1908
  lines.push("");
1636
1909
  }
1637
1910
  // Configurable properties table (all property types)
1638
- if (booleans.length > 0 || textProps.length > 0) {
1911
+ if (booleans.length > 0 || textProps.length > 0 || instanceSwaps.length > 0) {
1639
1912
  lines.push("### Configurable Properties");
1640
1913
  lines.push("");
1641
1914
  lines.push("| Property | Type | Default | Description |");
@@ -1649,6 +1922,10 @@ function generateStatesAndVariantsSection(node, variantData) {
1649
1922
  for (const t of textProps) {
1650
1923
  lines.push(`| **${t.name}** | \`string\` | \`"${t.defaultValue}"\` | Sets ${t.name.toLowerCase()} content |`);
1651
1924
  }
1925
+ for (const sw of instanceSwaps) {
1926
+ const preferred = sw.preferredCount > 0 ? ` (${sw.preferredCount} preferred values)` : "";
1927
+ lines.push(`| **${sw.name}** | \`instance swap\` | ${sw.defaultName ? `\`${sw.defaultName}\`` : "—"} | Swaps the nested ${sw.name.toLowerCase()} instance${preferred} |`);
1928
+ }
1652
1929
  lines.push("");
1653
1930
  }
1654
1931
  return lines.join("\n");
@@ -1712,7 +1989,9 @@ function deduplicateColors(colors) {
1712
1989
  }
1713
1990
  return Array.from(seen.values());
1714
1991
  }
1715
- function generateVisualSpecsSection(node, enrichedData, variantData, varNameMap = new Map()) {
1992
+ export function generateVisualSpecsSection(node, enrichedData, variantData, varNameMap = new Map(),
1993
+ /** The full COMPONENT_SET when `node` is its default variant — enables cross-variant spacing comparison */
1994
+ setNode) {
1716
1995
  const lines = ["", "## Token Specification", ""];
1717
1996
  // Fill any gaps in the caller-supplied name map from enrichment data.
1718
1997
  // enrichment entries key off variableId/variableName (NOT id/name), and only
@@ -1731,15 +2010,37 @@ function generateVisualSpecsSection(node, enrichedData, variantData, varNameMap
1731
2010
  lines.push("| Element | Figma Variable | Value |");
1732
2011
  lines.push("|---------|---------------|-------|");
1733
2012
  for (const vd of variantData) {
1734
- const nameMatch = vd.variantName.match(/Variant=([^,]+)/i);
1735
- const displayName = nameMatch ? nameMatch[1].trim() : vd.variantName;
2013
+ // Same naming as the Variant Matrix: every property value, whatever the
2014
+ // properties are called ("Variant=Secondary, Appearance=Critical" → "Secondary / Critical").
2015
+ const displayName = cleanVariantName(vd.variantName);
1736
2016
  // Section header for this variant
1737
2017
  lines.push(`| **${displayName}** | | |`);
1738
- // Background fills
1739
- for (const fill of vd.fills) {
1740
- const varName = fill.variableName || (fill.variableId ? varNameMap.get(fill.variableId) : undefined);
1741
- lines.push(`| Background | ${varName ? `\`${varName}\`` : "—"} | ${fill.hex} |`);
1742
- }
2018
+ const tokenCell = (c) => {
2019
+ const varName = c.variableName || (c.variableId ? varNameMap.get(c.variableId) : undefined);
2020
+ return varName ? `\`${varName}\`` : "—";
2021
+ };
2022
+ // Multi-path icons and repeated shapes would otherwise emit identical rows
2023
+ const emitted = new Set();
2024
+ const pushRow = (label, c) => {
2025
+ const row = `| ${label} | ${tokenCell(c)} | ${c.hex} |`;
2026
+ if (emitted.has(row))
2027
+ return;
2028
+ emitted.add(row);
2029
+ lines.push(row);
2030
+ };
2031
+ // Background — the variant's own surface only. Say so when there isn't one.
2032
+ const bgLabel = vd.backgroundLayer ? `Background (${vd.backgroundLayer})` : "Background";
2033
+ if (vd.fills.length === 0) {
2034
+ lines.push("| Background | — | none (transparent) |");
2035
+ }
2036
+ for (const fill of vd.fills)
2037
+ pushRow(bgLabel, fill);
2038
+ // Fills on inner layers — reported under the layer's name, never as a background
2039
+ for (const fill of vd.descendantFills)
2040
+ pushRow(`Fill (${fill.nodeName})`, fill);
2041
+ // Icon artwork colors
2042
+ for (const c of vd.iconColors)
2043
+ pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
1743
2044
  // Text colors
1744
2045
  for (const text of vd.textColors) {
1745
2046
  const varName = text.variableName || (text.variableId ? varNameMap.get(text.variableId) : undefined);
@@ -1775,10 +2076,39 @@ function generateVisualSpecsSection(node, enrichedData, variantData, varNameMap
1775
2076
  }
1776
2077
  // Spacing tokens with variable names
1777
2078
  const visualNode = resolveVisualNode(node);
1778
- const spacingTokens = collectSpacingTokens(visualNode, varNameMap);
1779
- if (spacingTokens.length > 0) {
2079
+ const comparison = setNode?.type === "COMPONENT_SET" && setNode.children?.length > 1
2080
+ ? collectSpacingAcrossVariants(setNode, varNameMap)
2081
+ : null;
2082
+ const spacingTokens = comparison ? [] : collectSpacingTokens(visualNode, varNameMap);
2083
+ if (comparison && comparison.rows.length > 0) {
2084
+ lines.push("### Spacing Tokens");
2085
+ lines.push("");
2086
+ lines.push(`_Compared across all ${comparison.variantCount} variants — a single value means every variant agrees._`);
2087
+ lines.push("");
2088
+ lines.push("| Property | Figma Variable | Value |");
2089
+ lines.push("|----------|---------------|-------|");
2090
+ for (const row of comparison.rows) {
2091
+ lines.push(`| ${row.property} | ${row.variableCell} | ${row.valueCell} |`);
2092
+ }
2093
+ lines.push("");
2094
+ if (comparison.inconsistencies.length > 0) {
2095
+ lines.push("#### Spacing Inconsistencies");
2096
+ lines.push("");
2097
+ lines.push("_A property tokenized on most variants but hardcoded on a few usually means an accidental edit._");
2098
+ lines.push("");
2099
+ for (const note of comparison.inconsistencies)
2100
+ lines.push(`- ${note}`);
2101
+ lines.push("");
2102
+ }
2103
+ }
2104
+ else if (spacingTokens.length > 0) {
1780
2105
  lines.push("### Spacing Tokens");
1781
2106
  lines.push("");
2107
+ // A lone variant doesn't speak for its whole set — say which one this is
2108
+ if (isVariantName(visualNode.name || "") || /^[^=,]+=[^=,]+$/.test(visualNode.name || "")) {
2109
+ lines.push(`_Describes the \`${cleanVariantName(visualNode.name)}\` variant only._`);
2110
+ lines.push("");
2111
+ }
1782
2112
  lines.push("| Property | Figma Variable | Value |");
1783
2113
  lines.push("|----------|---------------|-------|");
1784
2114
  for (const token of spacingTokens) {
@@ -2686,8 +3016,14 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
2686
3016
  logger.warn("Could not load bridge variables for doc token names — colors may fall back to hex");
2687
3017
  }
2688
3018
  }
2689
- // Collect per-variant color/icon data
2690
- const variantData = collectAllVariantData(node, varNameMap);
3019
+ // Collect per-variant color/icon data. The REST response carries component
3020
+ // metadata next to the document — that's what names the icon actually sitting
3021
+ // in a slot, instead of guessing from its layer name.
3022
+ const componentLookup = {
3023
+ components: nodeData.components,
3024
+ componentSets: nodeData.componentSets,
3025
+ };
3026
+ const variantData = collectAllVariantData(node, varNameMap, componentLookup);
2691
3027
  // Resolve clean component name (prefer set name over variant name)
2692
3028
  const componentName = resolveComponentName(node, setInfo.setName, codeInfo?.filePath?.split("/").pop()?.replace(/\.\w+$/, ""));
2693
3029
  // Prefer markdown description (has headers/bold markers for parsing) over plain text
@@ -2812,14 +3148,14 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
2812
3148
  }
2813
3149
  }
2814
3150
  if (s.statesAndVariants) {
2815
- const variantsSection = generateStatesAndVariantsSection(nodeForVariants, variantData);
3151
+ const variantsSection = generateStatesAndVariantsSection(nodeForVariants, variantData, componentLookup);
2816
3152
  if (variantsSection) {
2817
3153
  parts.push(variantsSection);
2818
3154
  includedSections.push("statesAndVariants");
2819
3155
  }
2820
3156
  }
2821
3157
  if (s.visualSpecs) {
2822
- parts.push(generateVisualSpecsSection(nodeForVisual, enrichedData, variantData, varNameMap));
3158
+ parts.push(generateVisualSpecsSection(nodeForVisual, enrichedData, variantData, varNameMap, node));
2823
3159
  includedSections.push("visualSpecs");
2824
3160
  }
2825
3161
  if (s.typography) {
@@ -59,6 +59,24 @@ export class DesignSystemManifestCache {
59
59
  return stats;
60
60
  }
61
61
  }
62
+ /**
63
+ * Decide whether a component search can be answered from this manifest.
64
+ *
65
+ * When the components fetch failed (`loadWarning` set) and the manifest holds
66
+ * no components at all, there is nothing to search — and an empty result list
67
+ * would read as "no such component exists". Returns the error message to
68
+ * surface in that case, or null when the search can proceed (including a
69
+ * genuinely empty file, which loads WITHOUT a warning).
70
+ */
71
+ export function componentSearchLoadFailure(manifest, loadWarning) {
72
+ if (!loadWarning)
73
+ return null;
74
+ const loaded = Object.keys(manifest.components).length +
75
+ Object.keys(manifest.componentSets).length;
76
+ if (loaded > 0)
77
+ return null;
78
+ return `Component search could not run: ${loadWarning}`;
79
+ }
62
80
  /**
63
81
  * Search components by name, category, or description
64
82
  */
@@ -66,12 +66,12 @@ export const ExportTokensInputSchema = z.object({
66
66
  outputPath: z
67
67
  .string()
68
68
  .optional()
69
- .describe("Filesystem path to write the output file(s) to. Relative paths resolve against the project root (the directory containing tokens.config.json) or cwd if no config. When omitted, the output is returned inline in the response (suitable for the AI to inspect or write via its own file tools)."),
69
+ .describe("Where to write the output. Treated as a DIRECTORY by default — each generated file (e.g. tokens.tokens.json, tokens.css) is written inside it and the directory is created if missing. Treated as a FILE when it is an existing file or ends in a token-file extension (.json, .css, .scss, .less, .ts, .js): the export must then produce exactly ONE file (one format, no splitByMode/splitByCollection — combine with collectionIds to re-export a single collection into e.g. typography.tokens.json), which is written at exactly that path, overwriting it. A file path combined with a multi-file export is rejected with an explanatory error before anything is written. End the path with '/' to force directory handling. Relative paths resolve against the project root (the directory containing tokens.config.json) or cwd if no config. When omitted, the output is returned inline in the response (suitable for the AI to inspect or write via its own file tools)."),
70
70
  configPath: z
71
71
  .string()
72
72
  .optional()
73
73
  .describe("Explicit path to a tokens.config.json file. When omitted, the tool walks up from cwd looking for one — typical case is zero-arg."),
74
- strategy: SyncStrategySchema.optional().describe("How to handle existing output files. 'merge' (default) diffs against current contents and writes only changed tokens, preserving code-only additions. 'replace' wipes and rewrites. 'dry-run' computes the diff and reports what would change without writing."),
74
+ strategy: SyncStrategySchema.optional().describe("How to handle existing output files. Export always replaces a target file's contents with the current Figma state — it does not merge token-by-token. 'merge' (default) is the SAFE mode: it refuses to write, leaving the file untouched, if overwriting an existing DTCG file would delete tokens the export does not manage (hand-added tokens, or tokens from collections not included in this export). 'replace' overwrites unconditionally. 'dry-run' reports what would be written without writing."),
75
75
  prefix: z
76
76
  .string()
77
77
  .optional()