figma-console-mcp 1.40.3 → 1.40.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.
@@ -376,14 +376,16 @@ function describeEffect(effect) {
376
376
  if (!effect || effect.visible === false || typeof effect.type !== "string")
377
377
  return null;
378
378
  if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") {
379
- const color = effect.color ? figmaRGBAToHex({ ...effect.color, a: effect.color.a ?? 1 }) : "";
380
- const x = effect.offset?.x ?? 0, y = effect.offset?.y ?? 0;
381
- return `${effect.type === "DROP_SHADOW" ? "drop" : "inner"} shadow: x ${x} · y ${y} · blur ${effect.radius ?? 0} · spread ${effect.spread ?? 0}${color ? ` · ${color}` : ""}`;
379
+ const color = effect.color ? figmaRGBAToHex({ ...effect.color, a: effect.color.a ?? 1 }).toUpperCase() : "";
380
+ // Scaled instances produce values like 0.39000001549720764
381
+ const n = (v) => Math.round((v ?? 0) * 100) / 100;
382
+ return `${effect.type === "DROP_SHADOW" ? "drop" : "inner"} shadow: x ${n(effect.offset?.x)} · y ${n(effect.offset?.y)} · blur ${n(effect.radius)} · spread ${n(effect.spread)}${color ? ` · ${color}` : ""}`;
382
383
  }
384
+ const radius = Math.round((effect.radius ?? 0) * 100) / 100;
383
385
  if (effect.type === "LAYER_BLUR")
384
- return `layer blur ${effect.radius ?? 0}px`;
386
+ return `layer blur ${radius}px`;
385
387
  if (effect.type === "BACKGROUND_BLUR")
386
- return `background blur ${effect.radius ?? 0}px`;
388
+ return `background blur ${radius}px`;
387
389
  return `${effect.type.toLowerCase().replace(/_/g, " ")} effect`;
388
390
  }
389
391
  /** Largest dimension (px) an instance-swap slot can have and still be treated as an icon */
@@ -414,13 +416,32 @@ function detectIconInstance(node, lookup) {
414
416
  if (!nameSaysIcon && !(swapProperty && iconSized))
415
417
  return null;
416
418
  const cleanedLayerName = layerName.replace(/^icon\s*\/?\s*/i, "").trim() || layerName;
419
+ // Many systems wrap the glyph: an "Icon (small)" instance whose only job is to
420
+ // hold a swappable "MagnifyingGlass". The wrapper says nothing about WHICH icon
421
+ // this is — the instance nested inside it does.
422
+ const glyph = findNestedGlyphName(node, lookup);
417
423
  return {
418
- name: mainName || cleanedLayerName,
424
+ name: glyph || mainName || cleanedLayerName,
419
425
  type: "instance",
420
426
  layerName,
421
427
  ...(swapProperty ? { swapProperty } : {}),
422
428
  };
423
429
  }
430
+ /** Name of the first instance nested inside an icon wrapper (breadth-first, ≤3 levels) */
431
+ function findNestedGlyphName(icon, lookup) {
432
+ let level = Array.isArray(icon.children) ? icon.children : [];
433
+ for (let depth = 0; depth < 3 && level.length > 0; depth++) {
434
+ for (const child of level) {
435
+ if (child.type === "INSTANCE") {
436
+ const name = resolveMainComponentName(child.componentId, lookup);
437
+ if (name)
438
+ return name;
439
+ }
440
+ }
441
+ level = level.flatMap((c) => (Array.isArray(c.children) ? c.children : []));
442
+ }
443
+ return undefined;
444
+ }
424
445
  /** True when the node has at least one visible paint (solid, gradient, image…) */
425
446
  function hasVisibleSolidFill(node) {
426
447
  return Array.isArray(node?.fills) && node.fills.some((f) => describePaint(f) !== null);
@@ -626,64 +647,43 @@ const typographySignature = (ts) => `${ts.fontFamily}|${ts.fontWeight}|${ts.font
626
647
  */
627
648
  export function collectTypographyAcrossVariants(setNode) {
628
649
  const variants = Array.isArray(setNode?.children) ? setNode.children : [];
650
+ // A row's identity is (element name, style) — NOT its position in the tree.
651
+ // Positional identity ("text-8 #4") made ten nav items' badges ten separate
652
+ // elements, each with its own scope, and printed the same fact three times.
653
+ const rowKey = (ts) => `${ts.nodeName}|${typographySignature(ts)}`;
629
654
  const perVariant = variants.map((v) => {
630
- // Same-named text layers within one variant are told apart by order
631
- const seen = new Map();
632
- const byElement = new Map();
633
- for (const ts of collectTypographyData(v)) {
634
- const n = (seen.get(ts.nodeName) ?? 0) + 1;
635
- seen.set(ts.nodeName, n);
636
- byElement.set(n === 1 ? ts.nodeName : `${ts.nodeName} #${n}`, ts);
637
- }
638
- return { name: cleanVariantName(v.name || "Unknown"), props: parseVariantProperties(v.name || ""), byElement };
655
+ const keys = new Map();
656
+ for (const ts of collectTypographyData(v))
657
+ if (!keys.has(rowKey(ts)))
658
+ keys.set(rowKey(ts), ts);
659
+ return { name: cleanVariantName(v.name || "Unknown"), props: parseVariantProperties(v.name || ""), keys };
639
660
  });
640
- const elements = [];
641
- for (const pv of perVariant)
642
- for (const el of pv.byElement.keys())
643
- if (!elements.includes(el))
644
- elements.push(el);
645
- const rows = [];
646
- for (const el of elements) {
647
- const having = perVariant.filter((pv) => pv.byElement.has(el));
648
- const distinct = new Map();
649
- for (const pv of having) {
650
- const style = pv.byElement.get(el);
651
- const sig = typographySignature(style);
652
- if (!distinct.has(sig))
653
- distinct.set(sig, { style, names: [] });
654
- distinct.get(sig).names.push(pv.name);
655
- }
656
- if (distinct.size === 1) {
657
- const only = [...distinct.values()][0];
658
- // Present in only some variants? Say which, rather than implying all.
659
- const scope = having.length === perVariant.length ? "" : scopeLabel(only.names, perVariant.length);
660
- rows.push({ style: { ...only.style, nodeName: el }, scope });
661
- continue;
661
+ const order = [];
662
+ const styles = new Map();
663
+ for (const pv of perVariant) {
664
+ for (const [key, ts] of pv.keys) {
665
+ if (!styles.has(key)) {
666
+ styles.set(key, ts);
667
+ order.push(key);
668
+ }
669
+ }
670
+ }
671
+ return order.map((key) => {
672
+ const style = styles.get(key);
673
+ const having = perVariant.filter((pv) => pv.keys.has(key));
674
+ let scope = "";
675
+ if (having.length < perVariant.length) {
676
+ // Which variant property decides where this (element, style) appears?
677
+ const explanation = explainByVariantProperty(perVariant.map((pv) => ({ props: pv.props, display: pv.keys.has(key) ? "yes" : "no" })));
678
+ const values = explanation?.groups.filter(([, display]) => display === "yes").map(([pv]) => pv) ?? [];
679
+ scope = explanation && values.length > 0
680
+ ? `${explanation.property}=${values.join(" | ")}`
681
+ : scopeLabel(having.map((pv) => pv.name), perVariant.length);
662
682
  }
663
- const explanation = explainByVariantProperty(having.map((pv) => ({ props: pv.props, display: typographySignature(pv.byElement.get(el)) })));
664
- for (const [sig, group] of distinct) {
665
- const propValue = explanation?.groups.filter(([, display]) => display === sig).map(([pv]) => pv);
666
- const scope = explanation && propValue && propValue.length > 0
667
- ? `${explanation.property}=${propValue.join(" | ")}`
668
- : scopeLabel(group.names, perVariant.length);
669
- rows.push({ style: { ...group.style, nodeName: el }, scope });
670
- }
671
- }
672
- // Same-named layers ("Label", "Label #2", "Label #3" — three tabs in a tab list)
673
- // that share a style and a scope are one fact, not three rows.
674
- const merged = [];
675
- const seenRows = new Set();
676
- for (const row of rows) {
677
- const baseName = row.style.nodeName.replace(/ #\d+$/, "");
678
- const key = `${baseName}|${typographySignature(row.style)}|${row.scope}|${row.style.hidden ? "h" : ""}`;
679
- if (seenRows.has(key))
680
- continue;
681
- seenRows.add(key);
682
683
  // Text layers are often auto-named after their content — keep the table readable
683
- const label = baseName.length > 40 ? `${baseName.slice(0, 40)}…` : baseName;
684
- merged.push({ ...row, style: { ...row.style, nodeName: label } });
685
- }
686
- return merged;
684
+ const label = style.nodeName.length > 40 ? `${style.nodeName.slice(0, 40)}…` : style.nodeName;
685
+ return { style: { ...style, nodeName: label }, scope };
686
+ });
687
687
  }
688
688
  /** "Primary / Default, Primary / Hover, +3 more" — enough to identify, short enough to read */
689
689
  function scopeLabel(names, total) {
@@ -729,7 +729,9 @@ function countChildDepth(node) {
729
729
  }
730
730
  return 1 + max;
731
731
  }
732
- function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
732
+ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth,
733
+ /** Appended to this node's own line — " ×7" for a collapsed run of identical siblings */
734
+ suffix = "") {
733
735
  if (depth > maxDepth)
734
736
  return;
735
737
  const connector = depth === 0 ? "" : (isLast ? "└── " : "├── ");
@@ -755,12 +757,16 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
755
757
  let sizingInfo = "";
756
758
  if (node.primaryAxisSizingMode || node.counterAxisSizingMode) {
757
759
  const parts = [];
760
+ // The PRIMARY axis is width only in a horizontal layout — in a vertical one it
761
+ // is height. (These were hard-wired to width/height, mislabeling every
762
+ // vertical auto-layout: a fixed-width sidebar printed as "fixed-height".)
763
+ const vertical = node.layoutMode === "VERTICAL";
758
764
  if (node.primaryAxisSizingMode === "FIXED")
759
- parts.push("fixed-width");
765
+ parts.push(vertical ? "fixed-height" : "fixed-width");
760
766
  if (node.primaryAxisSizingMode === "AUTO")
761
767
  parts.push("hug-content");
762
768
  if (node.counterAxisSizingMode === "FIXED")
763
- parts.push("fixed-height");
769
+ parts.push(vertical ? "fixed-width" : "fixed-height");
764
770
  if (node.layoutGrow === 1)
765
771
  parts.push("fill");
766
772
  if (parts.length > 0)
@@ -769,14 +775,28 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
769
775
  // Hidden layers stay in the tree, marked: the color table names them (a hidden
770
776
  // focus ring is real design intent), so the tree must not pretend they don't exist.
771
777
  const hiddenHint = node.visible === false ? " (hidden)" : "";
772
- lines.push(`${prefix}${connector}${label}${typeHint}${hiddenHint}${layoutInfo}${sizingInfo}`);
773
- // Recurse into children
774
- if (node.children && Array.isArray(node.children)) {
775
- const visibleChildren = node.children;
776
- for (let i = 0; i < visibleChildren.length; i++) {
777
- const isChildLast = i === visibleChildren.length - 1;
778
- buildAnatomyLines(visibleChildren[i], lines, prefix + childPrefix, isChildLast, depth + 1, maxDepth);
779
- }
778
+ lines.push(`${prefix}${connector}${label}${typeHint}${suffix}${hiddenHint}${layoutInfo}${sizingInfo}`);
779
+ // Recurse into children, collapsing RUNS of siblings that would print
780
+ // identically (ten nav items, five list rows) into one entry marked ×N. Compared
781
+ // on the rendered text, so anything the tree shows — a different gap, one hidden
782
+ // caret — keeps a sibling separate.
783
+ if (node.children && Array.isArray(node.children) && depth < maxDepth) {
784
+ const rendered = node.children.map((child) => {
785
+ const own = [];
786
+ buildAnatomyLines(child, own, "", true, depth + 1, maxDepth);
787
+ return own.join("\n");
788
+ });
789
+ const runs = [];
790
+ node.children.forEach((child, i) => {
791
+ const last = runs[runs.length - 1];
792
+ if (last && rendered[i] === rendered[i - 1])
793
+ last.count++;
794
+ else
795
+ runs.push({ child, count: 1 });
796
+ });
797
+ runs.forEach((run, i) => {
798
+ buildAnatomyLines(run.child, lines, prefix + childPrefix, i === runs.length - 1, depth + 1, maxDepth, run.count > 1 ? ` ×${run.count}` : "");
799
+ });
780
800
  }
781
801
  }
782
802
  /**
@@ -2032,15 +2052,15 @@ export function generateOverviewSection(componentName, description, fileUrl, par
2032
2052
  }
2033
2053
  lines.push(links.join(" | "));
2034
2054
  lines.push("");
2035
- lines.push("## Overview");
2036
- lines.push("");
2055
+ // Collected separately so an empty overview doesn't leave an orphaned heading
2056
+ const section = [];
2037
2057
  // Use parsed overview or fall back to raw description
2038
2058
  // No filler: libraries that deliberately keep descriptions empty would get a
2039
2059
  // sentence carrying no information on every page. Nothing says more than that.
2040
2060
  const overviewText = parsedDesc.overview || description?.split("\n")[0] || "";
2041
2061
  if (overviewText.trim()) {
2042
- lines.push(overviewText);
2043
- lines.push("");
2062
+ section.push(overviewText);
2063
+ section.push("");
2044
2064
  }
2045
2065
  // Base component attribution
2046
2066
  if (codeInfo?.baseComponent) {
@@ -2048,30 +2068,35 @@ export function generateOverviewSection(componentName, description, fileUrl, par
2048
2068
  ? `[${codeInfo.baseComponent.name}](${codeInfo.baseComponent.url})`
2049
2069
  : codeInfo.baseComponent.name;
2050
2070
  if (codeInfo.baseComponent.description) {
2051
- lines.push(`Built on ${baseLink}, ${codeInfo.baseComponent.description}`);
2071
+ section.push(`Built on ${baseLink}, ${codeInfo.baseComponent.description}`);
2052
2072
  }
2053
2073
  else {
2054
- lines.push(`Built on ${baseLink}.`);
2074
+ section.push(`Built on ${baseLink}.`);
2055
2075
  }
2056
- lines.push("");
2076
+ section.push("");
2057
2077
  }
2058
2078
  // When to Use
2059
2079
  if (parsedDesc.whenToUse.length > 0) {
2060
- lines.push("### When to Use");
2061
- lines.push("");
2080
+ section.push("### When to Use");
2081
+ section.push("");
2062
2082
  for (const item of parsedDesc.whenToUse) {
2063
- lines.push(`- ${item}`);
2083
+ section.push(`- ${item}`);
2064
2084
  }
2065
- lines.push("");
2085
+ section.push("");
2066
2086
  }
2067
2087
  // When NOT to Use
2068
2088
  if (parsedDesc.whenNotToUse.length > 0) {
2069
- lines.push("### When NOT to Use");
2070
- lines.push("");
2089
+ section.push("### When NOT to Use");
2090
+ section.push("");
2071
2091
  for (const item of parsedDesc.whenNotToUse) {
2072
- lines.push(`- ${item}`);
2092
+ section.push(`- ${item}`);
2073
2093
  }
2094
+ section.push("");
2095
+ }
2096
+ if (section.length > 0) {
2097
+ lines.push("## Overview");
2074
2098
  lines.push("");
2099
+ lines.push(...section);
2075
2100
  }
2076
2101
  return lines.join("\n");
2077
2102
  }
@@ -2079,8 +2104,30 @@ export function generateOverviewSection(componentName, description, fileUrl, par
2079
2104
  function formatIconCell(icons) {
2080
2105
  if (!icons || icons.length === 0)
2081
2106
  return "—";
2082
- // A chip with a leading AND a trailing icon has two — show both
2083
- return icons.map(formatOneIcon).join("; ");
2107
+ // A chip with a leading AND a trailing icon has two — show both. A nav with
2108
+ // twenty repeats of the same one shows it once, counted.
2109
+ const groups = new Map();
2110
+ for (const icon of icons) {
2111
+ const key = `${icon.name}|${icon.swapProperty ?? ""}`;
2112
+ if (!groups.has(key))
2113
+ groups.set(key, { icon, total: 0, hidden: 0 });
2114
+ const g = groups.get(key);
2115
+ g.total++;
2116
+ if (icon.hidden)
2117
+ g.hidden++;
2118
+ }
2119
+ const MAX_ICON_GROUPS = 5;
2120
+ const parts = [...groups.values()].slice(0, MAX_ICON_GROUPS).map(({ icon, total, hidden }) => {
2121
+ if (total === 1)
2122
+ return formatOneIcon(icon);
2123
+ // Count first, then the note: "Placeholder ×9 _(all hidden)_"
2124
+ const label = formatOneIcon({ ...icon, hidden: false });
2125
+ const note = hidden === 0 ? "" : hidden === total ? " _(all hidden)_" : ` _(${hidden} hidden)_`;
2126
+ return `${label} ×${total}${note}`;
2127
+ });
2128
+ if (groups.size > MAX_ICON_GROUPS)
2129
+ parts.push(`+${groups.size - MAX_ICON_GROUPS} more`);
2130
+ return parts.join("; ");
2084
2131
  }
2085
2132
  function formatOneIcon(icon) {
2086
2133
  const notes = [];
@@ -2318,6 +2365,8 @@ setNode) {
2318
2365
  };
2319
2366
  // Multi-path icons and repeated shapes would otherwise emit identical rows
2320
2367
  const emitted = new Set();
2368
+ // Text layers are often auto-named after their content ("Lorem ipsum dolor…")
2369
+ const short = (name) => (name.length > 40 ? `${name.slice(0, 40)}…` : name);
2321
2370
  const pushRow = (label, c) => {
2322
2371
  const row = `| ${label}${c.hidden ? " _(hidden layer)_" : ""} | ${tokenCell(c)} | ${c.hex} |`;
2323
2372
  if (emitted.has(row))
@@ -2340,7 +2389,7 @@ setNode) {
2340
2389
  pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
2341
2390
  // Text colors
2342
2391
  for (const text of vd.textColors)
2343
- pushRow(`Text (${text.nodeName})`, text);
2392
+ pushRow(`Text (${short(text.nodeName)})`, text);
2344
2393
  // Strokes — through the same writer, so a visible underline and a hidden
2345
2394
  // focus ring sharing one color don't print as two indistinguishable rows
2346
2395
  for (const stroke of vd.strokes)
@@ -3405,7 +3454,11 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3405
3454
  if (getDesktopConnector) {
3406
3455
  try {
3407
3456
  const connector = await getDesktopConnector();
3408
- const varsResult = await connector.getVariables();
3457
+ // Target the file being DOCUMENTED, not whichever file is active:
3458
+ // variable ids are file-local, so the active file's variables either
3459
+ // resolve nothing (every token prints as "—") or, on an id collision,
3460
+ // resolve to the wrong names. Rejects → caught → hex fallback.
3461
+ const varsResult = await connector.getVariables(fileKey);
3409
3462
  const varList = varsResult?.variables || varsResult?.result?.variables;
3410
3463
  if (Array.isArray(varList)) {
3411
3464
  for (const [id, name] of buildVariableNameMap(varList, varsResult?.variableCollections || varsResult?.result?.variableCollections))
@@ -3434,7 +3487,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3434
3487
  if (getDesktopConnector) {
3435
3488
  try {
3436
3489
  const connector = await getDesktopConnector();
3437
- const bridgeResult = await connector.getComponentFromPluginUI(nodeId);
3490
+ const bridgeResult = await connector.getComponentFromPluginUI(nodeId, fileKey);
3438
3491
  if (bridgeResult.success && bridgeResult.component) {
3439
3492
  // Fetch description from bridge if REST API returned empty
3440
3493
  if (!description) {