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.
- package/README.md +4 -2
- package/dist/cloudflare/core/design-code-tools.js +388 -52
- package/dist/cloudflare/core/design-system-manifest.js +18 -0
- package/dist/cloudflare/core/tokens/schemas.js +2 -2
- package/dist/cloudflare/core/tokens-tools.js +472 -22
- package/dist/cloudflare/index.js +3 -3
- package/dist/core/design-code-tools.d.ts +87 -19
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +388 -52
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/design-system-manifest.d.ts +10 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -1
- package/dist/core/design-system-manifest.js +18 -0
- package/dist/core/design-system-manifest.js.map +1 -1
- package/dist/core/tokens/schemas.js +2 -2
- package/dist/core/tokens/schemas.js.map +1 -1
- package/dist/core/tokens-tools.d.ts +94 -1
- package/dist/core/tokens-tools.d.ts.map +1 -1
- package/dist/core/tokens-tools.js +472 -22
- package/dist/core/tokens-tools.js.map +1 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +27 -2
- package/dist/local.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
|
1779
|
-
|
|
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
|
-
|
|
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("
|
|
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)
|
|
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()
|