figma-console-mcp 1.40.2 → 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.
@@ -4,7 +4,7 @@
4
4
  * and generating platform-agnostic component documentation.
5
5
  */
6
6
  import { z } from "zod";
7
- import { extractFileKey } from "./figma-api.js";
7
+ import { extractFileKey, withTimeout } from "./figma-api.js";
8
8
  import { createChildLogger } from "./logger.js";
9
9
  import { EnrichmentService } from "./enrichment/index.js";
10
10
  import { buildDesignHistory, DEFAULT_HISTORY_VERSIONS, MAX_HISTORY_VERSIONS, } from "./history/component-history.js";
@@ -107,6 +107,18 @@ export function chunkMarkdownByHeaders(markdown) {
107
107
  * Clean a raw Figma variant name like "Type=Image, Size=12" into "Image / 12".
108
108
  * Extracts just the values from "Key=Value" pairs, joined by " / ".
109
109
  */
110
+ /**
111
+ * How many levels of a component's tree the doc tool fetches and walks. The REST
112
+ * fetch counts from the requested node, so for a COMPONENT_SET one level is
113
+ * spent on the variants themselves. Anything deeper is cut off by the API — and
114
+ * REST returns a cut-off container as `children: []`, indistinguishable from an
115
+ * empty frame, so the walkers report when they reach this limit.
116
+ */
117
+ export const DOC_TREE_DEPTH = 8;
118
+ /** Depth used when the deep fetch fails or times out — the pre-1.40.3 behavior */
119
+ const SHALLOW_FETCH_DEPTH = 4;
120
+ const DEEP_FETCH_TIMEOUT_MS = 45_000;
121
+ const BOOLEAN_LIKE_VALUE = /^(true|false|yes|no|on|off)$/i;
110
122
  export function cleanVariantName(rawName) {
111
123
  // Match Key=Value pairs separated by comma/space
112
124
  const pairs = rawName.match(/(\w[\w\s]*)=([^,]+)/g);
@@ -114,7 +126,10 @@ export function cleanVariantName(rawName) {
114
126
  return rawName;
115
127
  const values = pairs.map((p) => {
116
128
  const eqIdx = p.indexOf("=");
117
- return p.slice(eqIdx + 1).trim();
129
+ const value = p.slice(eqIdx + 1).trim();
130
+ // "False" is not a name. A boolean-like value only means something next to
131
+ // its property: "Is scrollable=False", "Hover / Is Selected=True".
132
+ return BOOLEAN_LIKE_VALUE.test(value) ? `${p.slice(0, eqIdx).trim()}=${value}` : value;
118
133
  });
119
134
  return values.join(" / ");
120
135
  }
@@ -283,6 +298,31 @@ export function parseComponentDescription(description) {
283
298
  result.overview = overviewLines.join(" ").trim();
284
299
  return result;
285
300
  }
301
+ /**
302
+ * id → token name. Two collections can each hold a variable with the SAME name
303
+ * ("Primitive/2x" in both Spacing and Radius), which is ambiguous to paste into
304
+ * code — those, and only those, are qualified with their collection. Every other
305
+ * token keeps the plain name people already map to code.
306
+ */
307
+ export function buildVariableNameMap(variables, collections) {
308
+ const collectionNames = new Map();
309
+ if (Array.isArray(collections))
310
+ for (const c of collections)
311
+ if (c?.id && c?.name)
312
+ collectionNames.set(c.id, c.name);
313
+ const nameCounts = new Map();
314
+ for (const v of variables)
315
+ if (v?.name)
316
+ nameCounts.set(v.name, (nameCounts.get(v.name) ?? 0) + 1);
317
+ const out = new Map();
318
+ for (const v of variables) {
319
+ if (!v?.id || !v?.name)
320
+ continue;
321
+ const collection = collectionNames.get(v.variableCollectionId);
322
+ out.set(v.id, (nameCounts.get(v.name) ?? 0) > 1 && collection ? `${collection}/${v.name}` : v.name);
323
+ }
324
+ return out;
325
+ }
286
326
  /** Strip Figma's internal "#123:4" id suffix from a component property name */
287
327
  function stripPropertyIdSuffix(rawName) {
288
328
  return rawName.replace(/#\d+:\d+$/, "").trim();
@@ -302,6 +342,52 @@ export function resolveMainComponentName(componentId, lookup = {}) {
302
342
  return `${setName} (${cleanVariantName(comp.name)})`;
303
343
  return comp.name;
304
344
  }
345
+ /**
346
+ * Describe a visible paint as a table value. Solid paints are a hex; anything
347
+ * else is named rather than dropped — silence would read as "nothing there".
348
+ */
349
+ function describePaint(paint) {
350
+ if (!paint || paint.visible === false)
351
+ return null;
352
+ if (paint.type === "SOLID") {
353
+ if (!paint.color)
354
+ return null;
355
+ return figmaRGBAToHex({ ...paint.color, a: paint.opacity ?? paint.color.a ?? 1 });
356
+ }
357
+ if (typeof paint.type === "string" && paint.type.startsWith("GRADIENT_")) {
358
+ const kind = paint.type.replace("GRADIENT_", "").toLowerCase();
359
+ const stops = Array.isArray(paint.gradientStops) ? paint.gradientStops : [];
360
+ const stopHexes = stops
361
+ .filter((st) => st?.color)
362
+ .map((st) => figmaRGBAToHex({ ...st.color, a: st.color.a ?? 1 }));
363
+ const detail = stopHexes.length > 0 ? `: ${stopHexes.join(" → ")}` : "";
364
+ return `${kind} gradient (${stops.length} stops${detail})`;
365
+ }
366
+ if (paint.type === "IMAGE")
367
+ return "image fill";
368
+ if (paint.type === "VIDEO")
369
+ return "video fill";
370
+ if (paint.type === "PATTERN")
371
+ return "pattern fill";
372
+ return typeof paint.type === "string" ? `${paint.type.toLowerCase()} paint` : null;
373
+ }
374
+ /** Describe a visible effect (shadow / blur) as a table value */
375
+ function describeEffect(effect) {
376
+ if (!effect || effect.visible === false || typeof effect.type !== "string")
377
+ return null;
378
+ if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") {
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}` : ""}`;
383
+ }
384
+ const radius = Math.round((effect.radius ?? 0) * 100) / 100;
385
+ if (effect.type === "LAYER_BLUR")
386
+ return `layer blur ${radius}px`;
387
+ if (effect.type === "BACKGROUND_BLUR")
388
+ return `background blur ${radius}px`;
389
+ return `${effect.type.toLowerCase().replace(/_/g, " ")} effect`;
390
+ }
305
391
  /** Largest dimension (px) an instance-swap slot can have and still be treated as an icon */
306
392
  const MAX_ICON_SLOT_SIZE = 64;
307
393
  /**
@@ -330,17 +416,35 @@ function detectIconInstance(node, lookup) {
330
416
  if (!nameSaysIcon && !(swapProperty && iconSized))
331
417
  return null;
332
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);
333
423
  return {
334
- name: mainName || cleanedLayerName,
424
+ name: glyph || mainName || cleanedLayerName,
335
425
  type: "instance",
336
426
  layerName,
337
427
  ...(swapProperty ? { swapProperty } : {}),
338
428
  };
339
429
  }
340
- /** True when the node has at least one visible solid fill */
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
+ }
445
+ /** True when the node has at least one visible paint (solid, gradient, image…) */
341
446
  function hasVisibleSolidFill(node) {
342
- return Array.isArray(node?.fills)
343
- && node.fills.some((f) => f.type === "SOLID" && f.color && f.visible !== false);
447
+ return Array.isArray(node?.fills) && node.fills.some((f) => describePaint(f) !== null);
344
448
  }
345
449
  /**
346
450
  * When a variant's root is transparent, some systems paint the surface with a
@@ -386,12 +490,13 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
386
490
  iconColors: [],
387
491
  strokes: [],
388
492
  textColors: [],
493
+ effects: [],
389
494
  icons: [],
390
495
  };
391
496
  const backgroundLayer = findBackgroundLayer(variant);
392
497
  if (backgroundLayer)
393
498
  data.backgroundLayer = backgroundLayer.name || "background layer";
394
- walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0, 5, null);
499
+ walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0, DOC_TREE_DEPTH, null);
395
500
  variants.push(data);
396
501
  }
397
502
  return variants;
@@ -399,26 +504,33 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
399
504
  /** Walk a single variant node tree to collect colors and icons */
400
505
  function walkVariantNode(node, data, ctx, depth, maxDepth,
401
506
  /** Label of the icon instance we're inside of, or null when outside any icon */
402
- insideIcon) {
507
+ insideIcon,
508
+ /** An ancestor is hidden, so nothing beneath it paints either */
509
+ ancestorHidden = false) {
403
510
  if (depth > maxDepth)
404
511
  return;
405
512
  const isText = node.type === "TEXT";
513
+ // Hidden layers are COLLECTED, not skipped: a hidden focus ring is real design
514
+ // intent. They are labeled so a reader can tell it from what actually renders.
515
+ const hidden = ancestorHidden || node.visible === false;
406
516
  // Icons are reported once, at the outermost instance — everything beneath it
407
517
  // (vectors, nested instances) is that icon's artwork, not a separate icon.
408
518
  if (insideIcon === null) {
409
519
  const icon = detectIconInstance(node, ctx.lookup);
410
520
  if (icon) {
411
- data.icons.push(icon);
521
+ data.icons.push(hidden ? { ...icon, hidden: true } : icon);
412
522
  insideIcon = icon.layerName || icon.name;
413
523
  }
414
524
  }
415
- const toEntry = (paint) => {
525
+ const toEntry = (paint, value) => {
416
526
  const varId = paint.boundVariables?.color?.id;
417
527
  return {
418
- hex: figmaRGBAToHex({ ...paint.color, a: paint.opacity ?? paint.color.a ?? 1 }),
419
- nodeName: node.name || "",
528
+ hex: value,
529
+ // The variant root's own name is "Size=lg, State=hover" — not a layer name
530
+ nodeName: depth === 0 ? "" : node.name || "",
420
531
  variableId: varId,
421
532
  variableName: varId ? ctx.varNameMap.get(varId) : undefined,
533
+ ...(hidden ? { hidden: true } : {}),
422
534
  };
423
535
  };
424
536
  // Collect fills. Only the variant root (or its full-bleed stand-in) is a
@@ -426,8 +538,9 @@ insideIcon) {
426
538
  // transparent variant gets documented with its icon's color as the surface.
427
539
  if (node.fills && Array.isArray(node.fills)) {
428
540
  for (const fill of node.fills) {
429
- if (fill.type === "SOLID" && fill.color && fill.visible !== false) {
430
- const entry = toEntry(fill);
541
+ const value = describePaint(fill);
542
+ if (value) {
543
+ const entry = toEntry(fill, value);
431
544
  if (isText) {
432
545
  data.textColors.push(entry);
433
546
  }
@@ -446,8 +559,9 @@ insideIcon) {
446
559
  // Collect strokes
447
560
  if (node.strokes && Array.isArray(node.strokes)) {
448
561
  for (const stroke of node.strokes) {
449
- if (stroke.type === "SOLID" && stroke.color && stroke.visible !== false) {
450
- const entry = toEntry(stroke);
562
+ const value = describePaint(stroke);
563
+ if (value) {
564
+ const entry = toEntry(stroke, value);
451
565
  if (insideIcon !== null) {
452
566
  data.iconColors.push({ ...entry, iconLabel: insideIcon });
453
567
  }
@@ -457,24 +571,44 @@ insideIcon) {
457
571
  }
458
572
  }
459
573
  }
574
+ // Effects and opacity — outside icon artwork, which has its own internals
575
+ if (insideIcon === null || depth === 0) {
576
+ if (Array.isArray(node.effects)) {
577
+ for (const effect of node.effects) {
578
+ const value = describeEffect(effect);
579
+ if (value)
580
+ data.effects.push(toEntry(effect, value));
581
+ }
582
+ }
583
+ if (typeof node.opacity === "number" && node.opacity < 1) {
584
+ data.effects.push({
585
+ ...toEntry({}, `opacity ${Math.round(node.opacity * 100)}%`),
586
+ variableId: node.boundVariables?.opacity?.id,
587
+ variableName: node.boundVariables?.opacity?.id ? ctx.varNameMap.get(node.boundVariables.opacity.id) : undefined,
588
+ });
589
+ }
590
+ }
460
591
  // Recurse into children
461
592
  if (node.children && Array.isArray(node.children)) {
462
593
  for (const child of node.children) {
463
- walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon);
594
+ walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden);
464
595
  }
465
596
  }
466
597
  }
467
598
  /**
468
599
  * Collect typography data from all text nodes in a component tree.
469
600
  */
470
- export function collectTypographyData(node, depth = 0, maxDepth = 5) {
601
+ export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false) {
471
602
  const results = [];
472
603
  if (depth > maxDepth)
473
604
  return results;
474
- // For COMPONENT_SET, walk the default (first) variant
605
+ // A COMPONENT_SET resolves to its default (first) variant HERE ONLY. Anything
606
+ // documenting a set must use collectTypographyAcrossVariants — variants
607
+ // routinely differ in weight (a selected tab is SemiBold, the rest Regular).
475
608
  if (node.type === "COMPONENT_SET" && node.children?.length > 0 && depth === 0) {
476
609
  return collectTypographyData(node.children[0], 0, maxDepth);
477
610
  }
611
+ const hidden = ancestorHidden || node.visible === false;
478
612
  if (node.type === "TEXT" && node.style) {
479
613
  const s = node.style;
480
614
  const weightNames = {
@@ -489,20 +623,80 @@ export function collectTypographyData(node, depth = 0, maxDepth = 5) {
489
623
  fontSize: s.fontSize || 14,
490
624
  lineHeight: s.lineHeightPx || s.fontSize || 14,
491
625
  letterSpacing: s.letterSpacing || 0,
626
+ ...(hidden ? { hidden: true } : {}),
627
+ // REST: characterStyleOverrides maps characters → styleOverrideTable ids; any
628
+ // non-zero entry means part of the text departs from `style`
629
+ ...(Array.isArray(node.characterStyleOverrides) && node.characterStyleOverrides.some((id) => id !== 0)
630
+ ? { mixed: true } : {}),
492
631
  });
493
632
  }
494
633
  if (node.children && Array.isArray(node.children)) {
495
634
  for (const child of node.children) {
496
- results.push(...collectTypographyData(child, depth + 1, maxDepth));
635
+ results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden));
497
636
  }
498
637
  }
499
638
  return results;
500
639
  }
640
+ // `hidden` and `mixed` belong in the signature: a label shown in one variant and
641
+ // hidden in another is NOT "the same in all variants".
642
+ const typographySignature = (ts) => `${ts.fontFamily}|${ts.fontWeight}|${ts.fontSize}|${ts.lineHeight}|${ts.letterSpacing}|${ts.hidden ? "hidden" : ""}|${ts.mixed ? "mixed" : ""}`;
643
+ /**
644
+ * Typography for EVERY variant of a set. Per text element: one row when all
645
+ * variants agree, otherwise one row per distinct style, scoped by the variant
646
+ * property that drives it ("Is Selected=True") or, failing that, by variant name.
647
+ */
648
+ export function collectTypographyAcrossVariants(setNode) {
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)}`;
654
+ const perVariant = variants.map((v) => {
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 };
660
+ });
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);
682
+ }
683
+ // Text layers are often auto-named after their content — keep the table readable
684
+ const label = style.nodeName.length > 40 ? `${style.nodeName.slice(0, 40)}…` : style.nodeName;
685
+ return { style: { ...style, nodeName: label }, scope };
686
+ });
687
+ }
688
+ /** "Primary / Default, Primary / Hover, +3 more" — enough to identify, short enough to read */
689
+ function scopeLabel(names, total) {
690
+ if (names.length === total)
691
+ return "";
692
+ const shown = names.slice(0, 3).join(", ");
693
+ return names.length > 3 ? `${shown}, +${names.length - 3} more` : shown;
694
+ }
501
695
  /**
502
696
  * Build an anatomy tree representation from a Figma node structure.
503
697
  * Returns a formatted string showing the component's nested structure.
504
698
  */
505
- export function buildAnatomyTree(node, depth = 0, maxDepth = 5) {
699
+ export function buildAnatomyTree(node, depth = 0, maxDepth = DOC_TREE_DEPTH) {
506
700
  if (depth > maxDepth)
507
701
  return "";
508
702
  // For COMPONENT_SET, pick the variant with the deepest children tree for the richest anatomy
@@ -535,7 +729,9 @@ function countChildDepth(node) {
535
729
  }
536
730
  return 1 + max;
537
731
  }
538
- 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 = "") {
539
735
  if (depth > maxDepth)
540
736
  return;
541
737
  const connector = depth === 0 ? "" : (isLast ? "└── " : "├── ");
@@ -561,25 +757,46 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
561
757
  let sizingInfo = "";
562
758
  if (node.primaryAxisSizingMode || node.counterAxisSizingMode) {
563
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";
564
764
  if (node.primaryAxisSizingMode === "FIXED")
565
- parts.push("fixed-width");
765
+ parts.push(vertical ? "fixed-height" : "fixed-width");
566
766
  if (node.primaryAxisSizingMode === "AUTO")
567
767
  parts.push("hug-content");
568
768
  if (node.counterAxisSizingMode === "FIXED")
569
- parts.push("fixed-height");
769
+ parts.push(vertical ? "fixed-width" : "fixed-height");
570
770
  if (node.layoutGrow === 1)
571
771
  parts.push("fill");
572
772
  if (parts.length > 0)
573
773
  sizingInfo = ` [${parts.join(", ")}]`;
574
774
  }
575
- lines.push(`${prefix}${connector}${label}${typeHint}${layoutInfo}${sizingInfo}`);
576
- // Recurse into children
577
- if (node.children && Array.isArray(node.children)) {
578
- const visibleChildren = node.children.filter((c) => c.visible !== false);
579
- for (let i = 0; i < visibleChildren.length; i++) {
580
- const isChildLast = i === visibleChildren.length - 1;
581
- buildAnatomyLines(visibleChildren[i], lines, prefix + childPrefix, isChildLast, depth + 1, maxDepth);
582
- }
775
+ // Hidden layers stay in the tree, marked: the color table names them (a hidden
776
+ // focus ring is real design intent), so the tree must not pretend they don't exist.
777
+ const hiddenHint = node.visible === false ? " (hidden)" : "";
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
+ });
583
800
  }
584
801
  }
585
802
  /**
@@ -587,6 +804,7 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
587
804
  */
588
805
  function collectSpacingTokens(node, varNameMap = new Map()) {
589
806
  const tokens = [];
807
+ const autoLayout = !!node.layoutMode && node.layoutMode !== "NONE";
590
808
  const boundVars = node.boundVariables || {};
591
809
  const spacingProps = [
592
810
  { key: "paddingTop", label: "Padding top" },
@@ -598,7 +816,16 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
598
816
  { key: "strokeWeight", label: "Border width" },
599
817
  ];
600
818
  for (const { key, label } of spacingProps) {
601
- const value = node[key];
819
+ let value = node[key];
820
+ // Same rules as the cross-variant table: a border width only where a border
821
+ // is painted (and per-side weights win); zero padding is reported, not omitted.
822
+ if (key === "strokeWeight")
823
+ value = hasVisibleStroke(node) ? describeStrokeWeight(node) : undefined;
824
+ else if ((key.startsWith("padding") || key === "itemSpacing") && autoLayout)
825
+ value = value ?? 0;
826
+ else if (key === "cornerRadius" && (value === undefined || value === null) && Array.isArray(node.rectangleCornerRadii)) {
827
+ value = node.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
828
+ }
602
829
  if (value !== undefined && value !== null) {
603
830
  const varBinding = boundVars[key];
604
831
  // Bound spacing variables expose their id as boundVariables[key].id.
@@ -615,6 +842,35 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
615
842
  }
616
843
  return tokens;
617
844
  }
845
+ /** True when the node paints at least one stroke */
846
+ function hasVisibleStroke(node) {
847
+ return Array.isArray(node?.strokes) && node.strokes.some((st) => describePaint(st) !== null);
848
+ }
849
+ /**
850
+ * The border width that actually RENDERS. With per-side weights (underlines,
851
+ * dividers, accent bars) the scalar `strokeWeight` is a leftover from before the
852
+ * override and appears nowhere in the component — `individualStrokeWeights` wins.
853
+ */
854
+ export function describeStrokeWeight(node) {
855
+ if (!hasVisibleStroke(node))
856
+ return "none";
857
+ const sides = node.individualStrokeWeights;
858
+ if (sides && typeof sides === "object") {
859
+ const order = [
860
+ ["top", sides.top ?? 0], ["right", sides.right ?? 0],
861
+ ["bottom", sides.bottom ?? 0], ["left", sides.left ?? 0],
862
+ ];
863
+ const weights = order.map(([, w]) => w);
864
+ if (weights.every((w) => w === weights[0]))
865
+ return weights[0];
866
+ const painted = order.filter(([, w]) => w > 0);
867
+ // "4px bottom" reads better than "0 / 0 / 4 / 0" when few sides are in play
868
+ if (painted.length <= 2)
869
+ return painted.map(([side, w]) => `${w}px ${side}`).join(", ");
870
+ return `${weights.map((w) => `${w}px`).join(" / ")} (top / right / bottom / left)`;
871
+ }
872
+ return node.strokeWeight ?? 0;
873
+ }
618
874
  /** Parse "Size=lg, State=hover" into ordered [property, value] pairs */
619
875
  export function parseVariantProperties(rawName) {
620
876
  const pairs = [];
@@ -626,6 +882,38 @@ export function parseVariantProperties(rawName) {
626
882
  }
627
883
  return pairs;
628
884
  }
885
+ /**
886
+ * Find ONE variant property that fully accounts for how a value differs across
887
+ * variants: grouping by that property leaves a single value per group.
888
+ *
889
+ * The explanation must be non-vacuous — with one variant per group any values
890
+ * at all would "fit" (single-property sets), so that doesn't count.
891
+ * Shared by spacing, typography and anatomy so they explain variation alike.
892
+ */
893
+ export function explainByVariantProperty(samples) {
894
+ if (samples.length < 2)
895
+ return null;
896
+ for (const [propName] of samples[0].props) {
897
+ const groups = new Map();
898
+ let complete = true;
899
+ for (const x of samples) {
900
+ const propValue = x.props.find(([n]) => n === propName)?.[1];
901
+ if (propValue === undefined) {
902
+ complete = false;
903
+ break;
904
+ }
905
+ if (!groups.has(propValue))
906
+ groups.set(propValue, new Set());
907
+ groups.get(propValue).add(x.display);
908
+ }
909
+ if (!complete)
910
+ continue;
911
+ if (groups.size > 1 && groups.size < samples.length && [...groups.values()].every((vals) => vals.size === 1)) {
912
+ return { property: propName, groups: [...groups.entries()].map(([pv, vals]) => [pv, [...vals][0]]) };
913
+ }
914
+ }
915
+ return null;
916
+ }
629
917
  const SPACING_COMPARISON_PROPS = [
630
918
  { key: "paddingTop", label: "Padding top" },
631
919
  { key: "paddingRight", label: "Padding right" },
@@ -662,9 +950,17 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
662
950
  if (variants.length === 0)
663
951
  return result;
664
952
  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));
953
+ const isPadding = key.startsWith("padding") || key === "itemSpacing";
954
+ const isSet = (v) => (key === "strokeWeight"
955
+ // A border width only means something where a border is painted. REST
956
+ // reports strokeWeight (default 1) on every node, stroked or not.
957
+ ? hasVisibleStroke(v)
958
+ : (v[key] !== undefined && v[key] !== null)
959
+ // Per-corner rounding: REST drops the scalar and sends only the 4-tuple
960
+ || (key === "cornerRadius" && Array.isArray(v.rectangleCornerRadii))
961
+ // Auto-layout: REST omits zero-valued padding/gap, but 0 is a real,
962
+ // reportable value — leaving it out is indistinguishable from "unknown"
963
+ || (isPadding && !!v.layoutMode && v.layoutMode !== "NONE"));
668
964
  if (!variants.some(isSet))
669
965
  continue;
670
966
  const samples = variants.map((v) => {
@@ -673,6 +969,8 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
673
969
  if (key === "cornerRadius" && (v[key] === undefined || v[key] === null) && Array.isArray(v.rectangleCornerRadii)) {
674
970
  value = v.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
675
971
  }
972
+ if (key === "strokeWeight")
973
+ value = describeStrokeWeight(v);
676
974
  const varId = typeof v.boundVariables?.[key]?.id === "string"
677
975
  ? v.boundVariables[key].id
678
976
  : undefined;
@@ -714,31 +1012,20 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
714
1012
  : formatSpacingValue(x.value);
715
1013
  }
716
1014
  // 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
- }
1015
+ const explanation = explainByVariantProperty(samples);
1016
+ const explainedBy = explanation?.property ?? null;
1017
+ const explainedGroups = explanation?.groups ?? [];
739
1018
  let valueCell;
740
1019
  if (explainedBy) {
741
- valueCell = `varies by **${explainedBy}**: ${explainedGroups.map(([pv, val]) => `${pv} ${val}`).join(" · ")}`;
1020
+ // Property values that land on the same result read better together:
1021
+ // "Active, Default, Hover 2px bottom · Focus 2px"
1022
+ const byResult = new Map();
1023
+ for (const [pv, val] of explainedGroups) {
1024
+ if (!byResult.has(val))
1025
+ byResult.set(val, []);
1026
+ byResult.get(val).push(pv);
1027
+ }
1028
+ valueCell = `varies by **${explainedBy}**: ${[...byResult.entries()].map(([val, pvs]) => `${pvs.join(", ")} ${val}`).join(" · ")}`;
742
1029
  }
743
1030
  else {
744
1031
  // No pattern — list each distinct value, most common first, naming the rare ones
@@ -1691,19 +1978,19 @@ async function detectAtomicLevel(api, fileKey, nodeId, setNodeId, _componentMeta
1691
1978
  return null;
1692
1979
  }
1693
1980
  }
1694
- function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
1981
+ export function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
1695
1982
  /**
1696
1983
  * Newest Figma version that touched this component, when design history was
1697
1984
  * pulled. Emitted as its own `figmaVersion` field rather than folded into
1698
1985
  * `version` — that one is the code-side semver and means something different.
1699
1986
  */
1700
1987
  latestDesignVersion) {
1701
- const status = codeInfo?.changelog?.[0]
1702
- ? "stable"
1703
- : componentMeta?.description?.toLowerCase().includes("deprecated")
1704
- ? "deprecated"
1705
- : "stable";
1706
- const version = codeInfo?.changelog?.[0]?.version || "1.0.0";
1988
+ // Only state what is actually known. "stable" / "1.0.0" used to be emitted for
1989
+ // every component and read as extracted facts.
1990
+ const status = componentMeta?.description?.toLowerCase().includes("deprecated")
1991
+ ? "deprecated"
1992
+ : codeInfo?.changelog?.[0] ? "stable" : null;
1993
+ const version = codeInfo?.changelog?.[0]?.version || null;
1707
1994
  const tags = [componentName.toLowerCase()];
1708
1995
  if (level)
1709
1996
  tags.push(level);
@@ -1714,9 +2001,9 @@ latestDesignVersion) {
1714
2001
  const lines = [
1715
2002
  "---",
1716
2003
  `title: ${componentName}`,
1717
- `description: ${((description.split(/\n\s*\n|\n#{1,6}\s|\n\*\*/)[0] || description).replace(/\n/g, " ").replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/)[0] || `${componentName} component`)}`,
1718
- `status: ${status}`,
1719
- `version: ${version}`,
2004
+ ...((((description.split(/\n\s*\n|\n#{1,6}\s|\n\*\*/)[0] || description).replace(/\n/g, " ").replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/)[0])) ? [`description: ${((description.split(/\n\s*\n|\n#{1,6}\s|\n\*\*/)[0] || description).replace(/\n/g, " ").replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/)[0])}`] : []),
2005
+ ...(status ? [`status: ${status}`] : []),
2006
+ ...(version ? [`version: ${version}`] : []),
1720
2007
  `category: components`,
1721
2008
  ...(level ? [`level: ${level}`] : []),
1722
2009
  `tags: [${tags.join(", ")}]`,
@@ -1748,7 +2035,7 @@ latestDesignVersion) {
1748
2035
  lines.push("---");
1749
2036
  return lines.join("\n");
1750
2037
  }
1751
- function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
2038
+ export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
1752
2039
  const lines = [
1753
2040
  `# ${componentName}`,
1754
2041
  "",
@@ -1765,50 +2052,90 @@ function generateOverviewSection(componentName, description, fileUrl, parsedDesc
1765
2052
  }
1766
2053
  lines.push(links.join(" | "));
1767
2054
  lines.push("");
1768
- lines.push("## Overview");
1769
- lines.push("");
2055
+ // Collected separately so an empty overview doesn't leave an orphaned heading
2056
+ const section = [];
1770
2057
  // Use parsed overview or fall back to raw description
1771
- const overviewText = parsedDesc.overview || description?.split("\n")[0] || `The ${componentName} component.`;
1772
- lines.push(overviewText);
1773
- lines.push("");
2058
+ // No filler: libraries that deliberately keep descriptions empty would get a
2059
+ // sentence carrying no information on every page. Nothing says more than that.
2060
+ const overviewText = parsedDesc.overview || description?.split("\n")[0] || "";
2061
+ if (overviewText.trim()) {
2062
+ section.push(overviewText);
2063
+ section.push("");
2064
+ }
1774
2065
  // Base component attribution
1775
2066
  if (codeInfo?.baseComponent) {
1776
2067
  const baseLink = codeInfo.baseComponent.url
1777
2068
  ? `[${codeInfo.baseComponent.name}](${codeInfo.baseComponent.url})`
1778
2069
  : codeInfo.baseComponent.name;
1779
2070
  if (codeInfo.baseComponent.description) {
1780
- lines.push(`Built on ${baseLink}, ${codeInfo.baseComponent.description}`);
2071
+ section.push(`Built on ${baseLink}, ${codeInfo.baseComponent.description}`);
1781
2072
  }
1782
2073
  else {
1783
- lines.push(`Built on ${baseLink}.`);
2074
+ section.push(`Built on ${baseLink}.`);
1784
2075
  }
1785
- lines.push("");
2076
+ section.push("");
1786
2077
  }
1787
2078
  // When to Use
1788
2079
  if (parsedDesc.whenToUse.length > 0) {
1789
- lines.push("### When to Use");
1790
- lines.push("");
2080
+ section.push("### When to Use");
2081
+ section.push("");
1791
2082
  for (const item of parsedDesc.whenToUse) {
1792
- lines.push(`- ${item}`);
2083
+ section.push(`- ${item}`);
1793
2084
  }
1794
- lines.push("");
2085
+ section.push("");
1795
2086
  }
1796
2087
  // When NOT to Use
1797
2088
  if (parsedDesc.whenNotToUse.length > 0) {
1798
- lines.push("### When NOT to Use");
1799
- lines.push("");
2089
+ section.push("### When NOT to Use");
2090
+ section.push("");
1800
2091
  for (const item of parsedDesc.whenNotToUse) {
1801
- lines.push(`- ${item}`);
2092
+ section.push(`- ${item}`);
1802
2093
  }
2094
+ section.push("");
2095
+ }
2096
+ if (section.length > 0) {
2097
+ lines.push("## Overview");
1803
2098
  lines.push("");
2099
+ lines.push(...section);
1804
2100
  }
1805
2101
  return lines.join("\n");
1806
2102
  }
1807
2103
  /** Icon cell text: the icon in the slot, flagged when it's only an instance-swap default */
1808
- function formatIconCell(icon) {
1809
- if (!icon)
2104
+ function formatIconCell(icons) {
2105
+ if (!icons || icons.length === 0)
1810
2106
  return "—";
1811
- return icon.swapProperty ? `${icon.name} _(default — swappable via **${icon.swapProperty}**)_` : icon.name;
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("; ");
2131
+ }
2132
+ function formatOneIcon(icon) {
2133
+ const notes = [];
2134
+ if (icon.swapProperty)
2135
+ notes.push(`default — swappable via **${icon.swapProperty}**`);
2136
+ if (icon.hidden)
2137
+ notes.push("hidden in this variant");
2138
+ return notes.length > 0 ? `${icon.name} _(${notes.join("; ")})_` : icon.name;
1812
2139
  }
1813
2140
  export function generateStatesAndVariantsSection(node, variantData, lookup = {}) {
1814
2141
  const props = node.componentPropertyDefinitions;
@@ -1819,6 +2146,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1819
2146
  const booleans = [];
1820
2147
  const textProps = [];
1821
2148
  const instanceSwaps = [];
2149
+ const slots = [];
1822
2150
  for (const [rawName, def] of Object.entries(props)) {
1823
2151
  // Strip Figma internal ID suffixes like "#17100:0" from property names
1824
2152
  const name = stripPropertyIdSuffix(rawName);
@@ -1843,6 +2171,14 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1843
2171
  preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
1844
2172
  });
1845
2173
  }
2174
+ else if (def.type === "SLOT") {
2175
+ // For a compositional component the slots ARE the API
2176
+ slots.push({
2177
+ name,
2178
+ preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
2179
+ description: typeof def.description === "string" && def.description.trim() ? def.description.trim() : null,
2180
+ });
2181
+ }
1846
2182
  }
1847
2183
  // Variant matrix with per-variant color data
1848
2184
  if (variants.length > 0 && variantData && variantData.length > 0) {
@@ -1866,19 +2202,21 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1866
2202
  lines.push("|" + separatorParts.join("|") + "|");
1867
2203
  for (const vd of variantData) {
1868
2204
  const displayName = cleanVariantName(vd.variantName);
2205
+ // A hidden layer never stands in for what renders
2206
+ const painted = (list) => list.find((c) => !c.hidden);
1869
2207
  // Get primary fill (background)
1870
- const bgFill = vd.fills[0];
2208
+ const bgFill = painted(vd.fills);
1871
2209
  const bgVal = bgFill
1872
2210
  ? (bgFill.variableName ? `\`${bgFill.variableName}\` (${bgFill.hex})` : bgFill.hex)
1873
2211
  : "—";
1874
2212
  // Get primary text/icon color
1875
- const textColor = vd.textColors[0] || vd.iconColors[0] || vd.strokes[0];
2213
+ const textColor = painted(vd.textColors) || painted(vd.iconColors) || painted(vd.strokes);
1876
2214
  const textVal = textColor
1877
2215
  ? (textColor.variableName ? `\`${textColor.variableName}\` (${textColor.hex})` : textColor.hex)
1878
2216
  : "—";
1879
2217
  const rowParts = [`**${displayName}**`, bgVal];
1880
2218
  if (hasIcons) {
1881
- rowParts.push(formatIconCell(vd.icons[0]));
2219
+ rowParts.push(formatIconCell(vd.icons));
1882
2220
  }
1883
2221
  rowParts.push(textVal);
1884
2222
  lines.push("| " + rowParts.join(" | ") + " |");
@@ -1893,7 +2231,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1893
2231
  lines.push("|---------|---------------------|");
1894
2232
  for (const vd of variantData) {
1895
2233
  const displayName = cleanVariantName(vd.variantName);
1896
- lines.push(`| ${displayName} | ${formatIconCell(vd.icons[0])} |`);
2234
+ lines.push(`| ${displayName} | ${formatIconCell(vd.icons)} |`);
1897
2235
  }
1898
2236
  lines.push("");
1899
2237
  }
@@ -1908,7 +2246,9 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1908
2246
  lines.push("");
1909
2247
  }
1910
2248
  // Configurable properties table (all property types)
1911
- if (booleans.length > 0 || textProps.length > 0 || instanceSwaps.length > 0) {
2249
+ // `variants` belongs in this guard: the table below renders them as its first
2250
+ // rows, so a set with only VARIANT properties used to get no table at all.
2251
+ if (variants.length > 0 || booleans.length > 0 || textProps.length > 0 || instanceSwaps.length > 0 || slots.length > 0) {
1912
2252
  lines.push("### Configurable Properties");
1913
2253
  lines.push("");
1914
2254
  lines.push("| Property | Type | Default | Description |");
@@ -1926,6 +2266,10 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1926
2266
  const preferred = sw.preferredCount > 0 ? ` (${sw.preferredCount} preferred values)` : "";
1927
2267
  lines.push(`| **${sw.name}** | \`instance swap\` | ${sw.defaultName ? `\`${sw.defaultName}\`` : "—"} | Swaps the nested ${sw.name.toLowerCase()} instance${preferred} |`);
1928
2268
  }
2269
+ for (const slot of slots) {
2270
+ const preferred = slot.preferredCount > 0 ? ` (${slot.preferredCount} preferred values)` : "";
2271
+ lines.push(`| **${slot.name}** | \`slot\` | — | ${slot.description ?? `Accepts nested content for ${slot.name.toLowerCase()}`}${preferred} |`);
2272
+ }
1929
2273
  lines.push("");
1930
2274
  }
1931
2275
  return lines.join("\n");
@@ -2021,8 +2365,10 @@ setNode) {
2021
2365
  };
2022
2366
  // Multi-path icons and repeated shapes would otherwise emit identical rows
2023
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);
2024
2370
  const pushRow = (label, c) => {
2025
- const row = `| ${label} | ${tokenCell(c)} | ${c.hex} |`;
2371
+ const row = `| ${label}${c.hidden ? " _(hidden layer)_" : ""} | ${tokenCell(c)} | ${c.hex} |`;
2026
2372
  if (emitted.has(row))
2027
2373
  return;
2028
2374
  emitted.add(row);
@@ -2042,15 +2388,15 @@ setNode) {
2042
2388
  for (const c of vd.iconColors)
2043
2389
  pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
2044
2390
  // Text colors
2045
- for (const text of vd.textColors) {
2046
- const varName = text.variableName || (text.variableId ? varNameMap.get(text.variableId) : undefined);
2047
- lines.push(`| Text (${text.nodeName}) | ${varName ? `\`${varName}\`` : "—"} | ${text.hex} |`);
2048
- }
2049
- // Strokes
2050
- for (const stroke of vd.strokes) {
2051
- const varName = stroke.variableName || (stroke.variableId ? varNameMap.get(stroke.variableId) : undefined);
2052
- lines.push(`| Stroke | ${varName ? `\`${varName}\`` : "—"} | ${stroke.hex} |`);
2053
- }
2391
+ for (const text of vd.textColors)
2392
+ pushRow(`Text (${short(text.nodeName)})`, text);
2393
+ // Strokes — through the same writer, so a visible underline and a hidden
2394
+ // focus ring sharing one color don't print as two indistinguishable rows
2395
+ for (const stroke of vd.strokes)
2396
+ pushRow(stroke.nodeName ? `Stroke (${stroke.nodeName})` : "Stroke", stroke);
2397
+ // Shadows, blurs, opacity — dropping them would imply the component is flat
2398
+ for (const fx of vd.effects)
2399
+ pushRow(fx.nodeName ? `Effect (${fx.nodeName})` : "Effect", fx);
2054
2400
  }
2055
2401
  lines.push("");
2056
2402
  }
@@ -2113,7 +2459,7 @@ setNode) {
2113
2459
  lines.push("|----------|---------------|-------|");
2114
2460
  for (const token of spacingTokens) {
2115
2461
  const varDisplay = token.variableName ? `\`${token.variableName}\`` : "—";
2116
- lines.push(`| ${token.property} | ${varDisplay} | ${token.value}px |`);
2462
+ lines.push(`| ${token.property} | ${varDisplay} | ${formatSpacingValue(token.value)} |`);
2117
2463
  }
2118
2464
  lines.push("");
2119
2465
  }
@@ -2343,7 +2689,38 @@ function generateChangelogSection(codeInfo) {
2343
2689
  // ============================================================================
2344
2690
  // New Section Generators (Anatomy, Typography, Content Guidelines, Parity)
2345
2691
  // ============================================================================
2346
- function generateAnatomySection(node) {
2692
+ /** Layer-structure fingerprint: names, types and nesting — not styling */
2693
+ function structureSignature(node, depth = 0) {
2694
+ if (depth > DOC_TREE_DEPTH)
2695
+ return "";
2696
+ const kids = Array.isArray(node.children) ? node.children.map((c) => structureSignature(c, depth + 1)).join(",") : "";
2697
+ return `${node.type}:${depth === 0 ? "" : node.name}${node.visible === false ? "!" : ""}[${kids}]`;
2698
+ }
2699
+ const CONTAINER_TYPES = new Set(["FRAME", "GROUP", "INSTANCE", "COMPONENT", "COMPONENT_SET", "SECTION", "BOOLEAN_OPERATION"]);
2700
+ /**
2701
+ * Count containers sitting exactly at the fetch-depth limit with no children.
2702
+ * REST cuts a tree off by returning `children: []`, which looks identical to an
2703
+ * empty frame — so at that level an empty container MAY be hiding real content.
2704
+ * `fetchedRoot` is the node the REST call requested (levels count from it).
2705
+ */
2706
+ export function countPossiblyTruncated(fetchedRoot, fetchDepth = DOC_TREE_DEPTH) {
2707
+ let count = 0;
2708
+ const walk = (node, level) => {
2709
+ if (!node)
2710
+ return;
2711
+ const kids = Array.isArray(node.children) ? node.children : [];
2712
+ if (level >= fetchDepth) {
2713
+ if (CONTAINER_TYPES.has(node.type) && kids.length === 0)
2714
+ count++;
2715
+ return;
2716
+ }
2717
+ for (const child of kids)
2718
+ walk(child, level + 1);
2719
+ };
2720
+ walk(fetchedRoot, 0);
2721
+ return count;
2722
+ }
2723
+ export function generateAnatomySection(node, fetchDepth = DOC_TREE_DEPTH) {
2347
2724
  const lines = ["", "## Component Anatomy", ""];
2348
2725
  // For COMPONENT_SET, list all variants first
2349
2726
  if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
@@ -2355,25 +2732,81 @@ function generateAnatomySection(node) {
2355
2732
  }
2356
2733
  lines.push("### Design Structure (Figma)");
2357
2734
  lines.push("");
2358
- const tree = buildAnatomyTree(node);
2359
- if (tree.includes("└── ") || tree.includes("├── ")) {
2360
- // Rich tree with children
2361
- lines.push("```");
2362
- lines.push(tree);
2363
- lines.push("```");
2735
+ // Variants can differ STRUCTURALLY (a scrollable variant wraps its list in
2736
+ // scroller frames and adds a fade). Showing one tree would silently drop
2737
+ // everything unique to the others — so: one tree per distinct structure.
2738
+ const MAX_STRUCTURES = 6;
2739
+ const structures = [];
2740
+ if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
2741
+ const bySignature = new Map();
2742
+ for (const variant of node.children) {
2743
+ const sig = structureSignature(variant);
2744
+ if (!bySignature.has(sig)) {
2745
+ bySignature.set(sig, structures.length);
2746
+ structures.push({ node: variant, names: [], props: [] });
2747
+ }
2748
+ const entry = structures[bySignature.get(sig)];
2749
+ entry.names.push(cleanVariantName(variant.name || "Unknown"));
2750
+ entry.props.push(parseVariantProperties(variant.name || ""));
2751
+ }
2752
+ }
2753
+ else {
2754
+ structures.push({ node, names: [], props: [] });
2755
+ }
2756
+ if (structures.length > 1) {
2757
+ // Which variant property decides the structure, if one does
2758
+ const explanation = explainByVariantProperty(structures.flatMap((st, i) => st.props.map((props) => ({ props, display: String(i) }))));
2759
+ lines.push(`_${node.children.length} variants share ${structures.length} distinct layer structures${explanation ? `, decided by **${explanation.property}**` : ""}._`);
2760
+ lines.push("");
2761
+ structures.slice(0, MAX_STRUCTURES).forEach((st, i) => {
2762
+ const values = explanation?.groups.filter(([, display]) => display === String(i)).map(([pv]) => pv) ?? [];
2763
+ const heading = values.length > 0
2764
+ ? `${explanation.property}=${values.join(" | ")}`
2765
+ : scopeLabel(st.names, -1);
2766
+ lines.push(`**${heading}** (${st.names.length} variant${st.names.length === 1 ? "" : "s"})`);
2767
+ lines.push("");
2768
+ lines.push("```");
2769
+ lines.push(buildAnatomyTree(st.node));
2770
+ lines.push("```");
2771
+ lines.push("");
2772
+ });
2773
+ if (structures.length > MAX_STRUCTURES) {
2774
+ lines.push(`_+${structures.length - MAX_STRUCTURES} more distinct structures not shown._`);
2775
+ lines.push("");
2776
+ }
2364
2777
  }
2365
2778
  else {
2366
- // Shallow tree (REST API depth limitation)
2367
2779
  lines.push("```");
2368
- lines.push(tree);
2780
+ lines.push(buildAnatomyTree(structures[0].node));
2369
2781
  lines.push("```");
2370
2782
  lines.push("");
2371
- lines.push("_Note: Tree depth may be limited by the Figma REST API. Use the Desktop Bridge plugin for full node-level anatomy._");
2372
2783
  }
2373
- lines.push("");
2784
+ // Say so when the fetch may have cut the tree off, rather than presenting an
2785
+ // incomplete document as a complete one.
2786
+ const truncated = countPossiblyTruncated(node, fetchDepth);
2787
+ if (truncated > 0) {
2788
+ lines.push(`_Note: ${truncated} layer${truncated === 1 ? "" : "s"} sit${truncated === 1 ? "s" : ""} at the depth limit of this extraction (${fetchDepth} levels) with no visible contents. Anything nested deeper is NOT reflected in this document — colors, typography and structure below that point may be incomplete._`);
2789
+ lines.push("");
2790
+ }
2374
2791
  return lines.join("\n");
2375
2792
  }
2376
- function generateTypographySection(node) {
2793
+ export function generateTypographySection(node) {
2794
+ if (node.type === "COMPONENT_SET" && node.children?.length > 1) {
2795
+ const rows = collectTypographyAcrossVariants(node);
2796
+ if (rows.length === 0)
2797
+ return "";
2798
+ const out = ["", "## Typography", ""];
2799
+ const scoped = rows.some((r) => r.scope);
2800
+ out.push(`_Compared across all ${node.children.length} variants${scoped ? " — the Applies to column shows where a style is specific to some of them" : "; every variant uses the same styles"}._`);
2801
+ out.push("");
2802
+ out.push(`| Element | Font | Weight | Size | Line Height | Letter Spacing |${scoped ? " Applies to |" : ""}`);
2803
+ out.push(`|---------|------|--------|------|-------------|----------------|${scoped ? "------------|" : ""}`);
2804
+ for (const { style: ts, scope } of rows) {
2805
+ out.push(`| ${ts.nodeName}${ts.hidden ? " _(hidden layer)_" : ""}${ts.mixed ? " _(mixed styles — base style shown)_" : ""} | ${ts.fontFamily} | ${ts.fontWeightName} (${ts.fontWeight}) | ${ts.fontSize}px | ${ts.lineHeight}px | ${ts.letterSpacing === 0 ? "0" : `${ts.letterSpacing}px`} |${scoped ? ` ${scope || "all variants"} |` : ""}`);
2806
+ }
2807
+ out.push("");
2808
+ return out.join("\n");
2809
+ }
2377
2810
  const textStyles = collectTypographyData(node);
2378
2811
  if (textStyles.length === 0)
2379
2812
  return "";
@@ -2383,11 +2816,11 @@ function generateTypographySection(node) {
2383
2816
  // Deduplicate by font properties
2384
2817
  const seen = new Set();
2385
2818
  for (const ts of textStyles) {
2386
- const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}`;
2819
+ const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}:${ts.hidden ? "h" : ""}:${ts.mixed ? "m" : ""}`;
2387
2820
  if (seen.has(key))
2388
2821
  continue;
2389
2822
  seen.add(key);
2390
- lines.push(`| ${ts.nodeName} | ${ts.fontFamily} | ${ts.fontWeightName} (${ts.fontWeight}) | ${ts.fontSize}px | ${ts.lineHeight}px | ${ts.letterSpacing === 0 ? "0" : `${ts.letterSpacing}px`} |`);
2823
+ lines.push(`| ${ts.nodeName}${ts.hidden ? " _(hidden layer)_" : ""}${ts.mixed ? " _(mixed styles — base style shown)_" : ""} | ${ts.fontFamily} | ${ts.fontWeightName} (${ts.fontWeight}) | ${ts.fontSize}px | ${ts.lineHeight}px | ${ts.letterSpacing === 0 ? "0" : `${ts.letterSpacing}px`} |`);
2391
2824
  }
2392
2825
  lines.push("");
2393
2826
  return lines.join("\n");
@@ -2412,59 +2845,63 @@ function generateContentGuidelinesSection(parsedDesc) {
2412
2845
  }
2413
2846
  return lines.join("\n");
2414
2847
  }
2415
- function generateParitySection(node, codeInfo) {
2848
+ export function generateParitySection(node, codeInfo) {
2416
2849
  const lines = ["", "## Design-Code Parity", ""];
2417
- // Variant coverage - compare Figma variants with code variants if available
2418
- // Use case-insensitive comparison: Figma uses "Default", code uses "default"
2419
- const figmaVariantsRaw = new Map(); // lowercase → original name
2850
+ // Variant coverage — compare each Figma variant PROPERTY with the code prop of
2851
+ // the same name, value by value. (This used to look only for a property
2852
+ // literally named "Variant" and otherwise captured the first property's NAME —
2853
+ // so a Size × State set was reported as having a Figma-only variant "Size".)
2854
+ const normalize = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
2855
+ const figmaProps = new Map();
2420
2856
  if (node.type === "COMPONENT_SET" && node.children) {
2421
2857
  for (const child of node.children) {
2422
- const match = child.name?.match(/Variant=([^,]+)/i) || child.name?.match(/^([^,=]+)/);
2423
- if (match) {
2424
- const raw = match[1].trim();
2425
- figmaVariantsRaw.set(raw.toLowerCase(), raw);
2426
- }
2427
- }
2428
- }
2429
- // Try to extract code variants from variant definition or props
2430
- const codeVariantsRaw = new Map(); // lowercase → original name
2431
- if (codeInfo.props) {
2432
- const variantProp = codeInfo.props.find((p) => p.name.toLowerCase() === "variant");
2433
- if (variantProp?.type) {
2434
- // Match both single and double quoted values: "default" or 'default'
2435
- const matches = variantProp.type.match(/["']([^"']+)["']/g);
2436
- if (matches) {
2437
- for (const m of matches) {
2438
- const raw = m.replace(/["']/g, "");
2439
- codeVariantsRaw.set(raw.toLowerCase(), raw);
2440
- }
2858
+ for (const [propName, value] of parseVariantProperties(child.name || "")) {
2859
+ const key = normalize(propName);
2860
+ if (!figmaProps.has(key))
2861
+ figmaProps.set(key, { name: propName, values: new Map() });
2862
+ figmaProps.get(key).values.set(value.toLowerCase(), value);
2441
2863
  }
2442
2864
  }
2443
2865
  }
2444
- if (figmaVariantsRaw.size > 0 || codeVariantsRaw.size > 0) {
2445
- // Merge by lowercase key
2446
- const allKeys = new Set([...figmaVariantsRaw.keys(), ...codeVariantsRaw.keys()]);
2866
+ // Code props whose type is a union of string literals: "sm" | "md" | 'lg'
2867
+ const codeProps = new Map();
2868
+ for (const prop of codeInfo.props ?? []) {
2869
+ const literals = prop.type?.match(/["']([^"']+)["']/g);
2870
+ if (!literals)
2871
+ continue;
2872
+ const values = new Map();
2873
+ for (const m of literals) {
2874
+ const raw = m.replace(/["']/g, "");
2875
+ values.set(raw.toLowerCase(), raw);
2876
+ }
2877
+ codeProps.set(normalize(prop.name), { name: prop.name, values });
2878
+ }
2879
+ // Compare properties present on BOTH sides, plus "variant" when either side has
2880
+ // it (the long-standing behavior). A property with no counterpart gets no
2881
+ // claim at all — a boolean in code and a True/False variant in Figma aren't comparable here.
2882
+ const compared = [...figmaProps.keys()].filter((k) => codeProps.has(k));
2883
+ if (!compared.includes("variant") && (figmaProps.has("variant") || codeProps.has("variant")))
2884
+ compared.push("variant");
2885
+ if (compared.length > 0) {
2886
+ const labelProperty = compared.length > 1 || compared[0] !== "variant";
2447
2887
  lines.push("### Variant Coverage");
2448
2888
  lines.push("");
2449
2889
  lines.push("| Variant | In Figma | In Code | Status |");
2450
2890
  lines.push("|---------|----------|---------|--------|");
2451
- for (const key of allKeys) {
2452
- const figmaName = figmaVariantsRaw.get(key);
2453
- const codeName = codeVariantsRaw.get(key);
2454
- const displayName = figmaName || codeName || key;
2455
- const inFigma = figmaVariantsRaw.has(key);
2456
- const inCode = codeVariantsRaw.has(key);
2457
- let status;
2458
- if (inFigma && inCode) {
2459
- status = "In sync";
2460
- }
2461
- else if (inFigma && !inCode) {
2462
- status = "Figma-only — needs code variant";
2891
+ for (const key of compared) {
2892
+ const figma = figmaProps.get(key);
2893
+ const code = codeProps.get(key);
2894
+ const propLabel = figma?.name || code?.name || key;
2895
+ const allValues = new Set([...(figma?.values.keys() ?? []), ...(code?.values.keys() ?? [])]);
2896
+ for (const valueKey of allValues) {
2897
+ const inFigma = figma?.values.has(valueKey) ?? false;
2898
+ const inCode = code?.values.has(valueKey) ?? false;
2899
+ const display = figma?.values.get(valueKey) || code?.values.get(valueKey) || valueKey;
2900
+ const status = inFigma && inCode
2901
+ ? "In sync"
2902
+ : inFigma ? "Figma-only — needs code variant" : "Code-only — needs Figma variant";
2903
+ lines.push(`| ${labelProperty ? `${propLabel}: ${display}` : display} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
2463
2904
  }
2464
- else {
2465
- status = "Code-only — needs Figma variant";
2466
- }
2467
- lines.push(`| ${displayName} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
2468
2905
  }
2469
2906
  lines.push("");
2470
2907
  }
@@ -2917,7 +3354,22 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
2917
3354
  logger.info({ fileKey, nodeId, enrich }, "Generating component documentation");
2918
3355
  const api = await getFigmaAPI();
2919
3356
  // Fetch component node with deeper depth for anatomy & per-variant data
2920
- const nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: 4 });
3357
+ // DOC_TREE_DEPTH, not 4: levels count from the requested node, so on a
3358
+ // COMPONENT_SET depth 4 left each variant only 3 — enough to silently drop
3359
+ // nested labels and a whole scroll mechanism from the generated document.
3360
+ let fetchDepth = DOC_TREE_DEPTH;
3361
+ let nodesResponse;
3362
+ try {
3363
+ nodesResponse = await withTimeout(api.getNodes(fileKey, [nodeId], { depth: DOC_TREE_DEPTH }), DEEP_FETCH_TIMEOUT_MS, "deep component fetch");
3364
+ }
3365
+ catch (deepErr) {
3366
+ // A huge set (hundreds of variants × nested instances) can make the deep
3367
+ // fetch too slow or too large. A shallower document that SAYS it is
3368
+ // shallow beats no document — the truncation note below reports it.
3369
+ fetchDepth = SHALLOW_FETCH_DEPTH;
3370
+ logger.warn({ err: deepErr instanceof Error ? deepErr.message : String(deepErr) }, "Deep fetch failed; retrying component doc fetch at reduced depth");
3371
+ nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: SHALLOW_FETCH_DEPTH });
3372
+ }
2921
3373
  const nodeData = nodesResponse?.nodes?.[nodeId];
2922
3374
  if (!nodeData?.document) {
2923
3375
  throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
@@ -3002,13 +3454,15 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3002
3454
  if (getDesktopConnector) {
3003
3455
  try {
3004
3456
  const connector = await getDesktopConnector();
3005
- 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);
3006
3462
  const varList = varsResult?.variables || varsResult?.result?.variables;
3007
3463
  if (Array.isArray(varList)) {
3008
- for (const v of varList) {
3009
- if (v?.id && v?.name)
3010
- varNameMap.set(v.id, v.name);
3011
- }
3464
+ for (const [id, name] of buildVariableNameMap(varList, varsResult?.variableCollections || varsResult?.result?.variableCollections))
3465
+ varNameMap.set(id, name);
3012
3466
  logger.info({ count: varList.length }, "Resolved variable names via Desktop Bridge for docs");
3013
3467
  }
3014
3468
  }
@@ -3033,7 +3487,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3033
3487
  if (getDesktopConnector) {
3034
3488
  try {
3035
3489
  const connector = await getDesktopConnector();
3036
- const bridgeResult = await connector.getComponentFromPluginUI(nodeId);
3490
+ const bridgeResult = await connector.getComponentFromPluginUI(nodeId, fileKey);
3037
3491
  if (bridgeResult.success && bridgeResult.component) {
3038
3492
  // Fetch description from bridge if REST API returned empty
3039
3493
  if (!description) {
@@ -3141,7 +3595,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3141
3595
  includedSections.push("overview");
3142
3596
  }
3143
3597
  if (s.anatomy) {
3144
- const anatomySection = generateAnatomySection(node);
3598
+ const anatomySection = generateAnatomySection(node, fetchDepth);
3145
3599
  if (anatomySection.trim()) {
3146
3600
  parts.push(anatomySection);
3147
3601
  includedSections.push("anatomy");