figma-console-mcp 1.40.2 → 1.40.3

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,50 @@ 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 }) : "";
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}` : ""}`;
382
+ }
383
+ if (effect.type === "LAYER_BLUR")
384
+ return `layer blur ${effect.radius ?? 0}px`;
385
+ if (effect.type === "BACKGROUND_BLUR")
386
+ return `background blur ${effect.radius ?? 0}px`;
387
+ return `${effect.type.toLowerCase().replace(/_/g, " ")} effect`;
388
+ }
305
389
  /** Largest dimension (px) an instance-swap slot can have and still be treated as an icon */
306
390
  const MAX_ICON_SLOT_SIZE = 64;
307
391
  /**
@@ -337,10 +421,9 @@ function detectIconInstance(node, lookup) {
337
421
  ...(swapProperty ? { swapProperty } : {}),
338
422
  };
339
423
  }
340
- /** True when the node has at least one visible solid fill */
424
+ /** True when the node has at least one visible paint (solid, gradient, image…) */
341
425
  function hasVisibleSolidFill(node) {
342
- return Array.isArray(node?.fills)
343
- && node.fills.some((f) => f.type === "SOLID" && f.color && f.visible !== false);
426
+ return Array.isArray(node?.fills) && node.fills.some((f) => describePaint(f) !== null);
344
427
  }
345
428
  /**
346
429
  * When a variant's root is transparent, some systems paint the surface with a
@@ -386,12 +469,13 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
386
469
  iconColors: [],
387
470
  strokes: [],
388
471
  textColors: [],
472
+ effects: [],
389
473
  icons: [],
390
474
  };
391
475
  const backgroundLayer = findBackgroundLayer(variant);
392
476
  if (backgroundLayer)
393
477
  data.backgroundLayer = backgroundLayer.name || "background layer";
394
- walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0, 5, null);
478
+ walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0, DOC_TREE_DEPTH, null);
395
479
  variants.push(data);
396
480
  }
397
481
  return variants;
@@ -399,26 +483,33 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
399
483
  /** Walk a single variant node tree to collect colors and icons */
400
484
  function walkVariantNode(node, data, ctx, depth, maxDepth,
401
485
  /** Label of the icon instance we're inside of, or null when outside any icon */
402
- insideIcon) {
486
+ insideIcon,
487
+ /** An ancestor is hidden, so nothing beneath it paints either */
488
+ ancestorHidden = false) {
403
489
  if (depth > maxDepth)
404
490
  return;
405
491
  const isText = node.type === "TEXT";
492
+ // Hidden layers are COLLECTED, not skipped: a hidden focus ring is real design
493
+ // intent. They are labeled so a reader can tell it from what actually renders.
494
+ const hidden = ancestorHidden || node.visible === false;
406
495
  // Icons are reported once, at the outermost instance — everything beneath it
407
496
  // (vectors, nested instances) is that icon's artwork, not a separate icon.
408
497
  if (insideIcon === null) {
409
498
  const icon = detectIconInstance(node, ctx.lookup);
410
499
  if (icon) {
411
- data.icons.push(icon);
500
+ data.icons.push(hidden ? { ...icon, hidden: true } : icon);
412
501
  insideIcon = icon.layerName || icon.name;
413
502
  }
414
503
  }
415
- const toEntry = (paint) => {
504
+ const toEntry = (paint, value) => {
416
505
  const varId = paint.boundVariables?.color?.id;
417
506
  return {
418
- hex: figmaRGBAToHex({ ...paint.color, a: paint.opacity ?? paint.color.a ?? 1 }),
419
- nodeName: node.name || "",
507
+ hex: value,
508
+ // The variant root's own name is "Size=lg, State=hover" — not a layer name
509
+ nodeName: depth === 0 ? "" : node.name || "",
420
510
  variableId: varId,
421
511
  variableName: varId ? ctx.varNameMap.get(varId) : undefined,
512
+ ...(hidden ? { hidden: true } : {}),
422
513
  };
423
514
  };
424
515
  // Collect fills. Only the variant root (or its full-bleed stand-in) is a
@@ -426,8 +517,9 @@ insideIcon) {
426
517
  // transparent variant gets documented with its icon's color as the surface.
427
518
  if (node.fills && Array.isArray(node.fills)) {
428
519
  for (const fill of node.fills) {
429
- if (fill.type === "SOLID" && fill.color && fill.visible !== false) {
430
- const entry = toEntry(fill);
520
+ const value = describePaint(fill);
521
+ if (value) {
522
+ const entry = toEntry(fill, value);
431
523
  if (isText) {
432
524
  data.textColors.push(entry);
433
525
  }
@@ -446,8 +538,9 @@ insideIcon) {
446
538
  // Collect strokes
447
539
  if (node.strokes && Array.isArray(node.strokes)) {
448
540
  for (const stroke of node.strokes) {
449
- if (stroke.type === "SOLID" && stroke.color && stroke.visible !== false) {
450
- const entry = toEntry(stroke);
541
+ const value = describePaint(stroke);
542
+ if (value) {
543
+ const entry = toEntry(stroke, value);
451
544
  if (insideIcon !== null) {
452
545
  data.iconColors.push({ ...entry, iconLabel: insideIcon });
453
546
  }
@@ -457,24 +550,44 @@ insideIcon) {
457
550
  }
458
551
  }
459
552
  }
553
+ // Effects and opacity — outside icon artwork, which has its own internals
554
+ if (insideIcon === null || depth === 0) {
555
+ if (Array.isArray(node.effects)) {
556
+ for (const effect of node.effects) {
557
+ const value = describeEffect(effect);
558
+ if (value)
559
+ data.effects.push(toEntry(effect, value));
560
+ }
561
+ }
562
+ if (typeof node.opacity === "number" && node.opacity < 1) {
563
+ data.effects.push({
564
+ ...toEntry({}, `opacity ${Math.round(node.opacity * 100)}%`),
565
+ variableId: node.boundVariables?.opacity?.id,
566
+ variableName: node.boundVariables?.opacity?.id ? ctx.varNameMap.get(node.boundVariables.opacity.id) : undefined,
567
+ });
568
+ }
569
+ }
460
570
  // Recurse into children
461
571
  if (node.children && Array.isArray(node.children)) {
462
572
  for (const child of node.children) {
463
- walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon);
573
+ walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden);
464
574
  }
465
575
  }
466
576
  }
467
577
  /**
468
578
  * Collect typography data from all text nodes in a component tree.
469
579
  */
470
- export function collectTypographyData(node, depth = 0, maxDepth = 5) {
580
+ export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false) {
471
581
  const results = [];
472
582
  if (depth > maxDepth)
473
583
  return results;
474
- // For COMPONENT_SET, walk the default (first) variant
584
+ // A COMPONENT_SET resolves to its default (first) variant HERE ONLY. Anything
585
+ // documenting a set must use collectTypographyAcrossVariants — variants
586
+ // routinely differ in weight (a selected tab is SemiBold, the rest Regular).
475
587
  if (node.type === "COMPONENT_SET" && node.children?.length > 0 && depth === 0) {
476
588
  return collectTypographyData(node.children[0], 0, maxDepth);
477
589
  }
590
+ const hidden = ancestorHidden || node.visible === false;
478
591
  if (node.type === "TEXT" && node.style) {
479
592
  const s = node.style;
480
593
  const weightNames = {
@@ -489,20 +602,101 @@ export function collectTypographyData(node, depth = 0, maxDepth = 5) {
489
602
  fontSize: s.fontSize || 14,
490
603
  lineHeight: s.lineHeightPx || s.fontSize || 14,
491
604
  letterSpacing: s.letterSpacing || 0,
605
+ ...(hidden ? { hidden: true } : {}),
606
+ // REST: characterStyleOverrides maps characters → styleOverrideTable ids; any
607
+ // non-zero entry means part of the text departs from `style`
608
+ ...(Array.isArray(node.characterStyleOverrides) && node.characterStyleOverrides.some((id) => id !== 0)
609
+ ? { mixed: true } : {}),
492
610
  });
493
611
  }
494
612
  if (node.children && Array.isArray(node.children)) {
495
613
  for (const child of node.children) {
496
- results.push(...collectTypographyData(child, depth + 1, maxDepth));
614
+ results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden));
497
615
  }
498
616
  }
499
617
  return results;
500
618
  }
619
+ // `hidden` and `mixed` belong in the signature: a label shown in one variant and
620
+ // hidden in another is NOT "the same in all variants".
621
+ const typographySignature = (ts) => `${ts.fontFamily}|${ts.fontWeight}|${ts.fontSize}|${ts.lineHeight}|${ts.letterSpacing}|${ts.hidden ? "hidden" : ""}|${ts.mixed ? "mixed" : ""}`;
622
+ /**
623
+ * Typography for EVERY variant of a set. Per text element: one row when all
624
+ * variants agree, otherwise one row per distinct style, scoped by the variant
625
+ * property that drives it ("Is Selected=True") or, failing that, by variant name.
626
+ */
627
+ export function collectTypographyAcrossVariants(setNode) {
628
+ const variants = Array.isArray(setNode?.children) ? setNode.children : [];
629
+ 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 };
639
+ });
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;
662
+ }
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
+ // 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;
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
@@ -572,10 +766,13 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
572
766
  if (parts.length > 0)
573
767
  sizingInfo = ` [${parts.join(", ")}]`;
574
768
  }
575
- lines.push(`${prefix}${connector}${label}${typeHint}${layoutInfo}${sizingInfo}`);
769
+ // Hidden layers stay in the tree, marked: the color table names them (a hidden
770
+ // focus ring is real design intent), so the tree must not pretend they don't exist.
771
+ const hiddenHint = node.visible === false ? " (hidden)" : "";
772
+ lines.push(`${prefix}${connector}${label}${typeHint}${hiddenHint}${layoutInfo}${sizingInfo}`);
576
773
  // Recurse into children
577
774
  if (node.children && Array.isArray(node.children)) {
578
- const visibleChildren = node.children.filter((c) => c.visible !== false);
775
+ const visibleChildren = node.children;
579
776
  for (let i = 0; i < visibleChildren.length; i++) {
580
777
  const isChildLast = i === visibleChildren.length - 1;
581
778
  buildAnatomyLines(visibleChildren[i], lines, prefix + childPrefix, isChildLast, depth + 1, maxDepth);
@@ -587,6 +784,7 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
587
784
  */
588
785
  function collectSpacingTokens(node, varNameMap = new Map()) {
589
786
  const tokens = [];
787
+ const autoLayout = !!node.layoutMode && node.layoutMode !== "NONE";
590
788
  const boundVars = node.boundVariables || {};
591
789
  const spacingProps = [
592
790
  { key: "paddingTop", label: "Padding top" },
@@ -598,7 +796,16 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
598
796
  { key: "strokeWeight", label: "Border width" },
599
797
  ];
600
798
  for (const { key, label } of spacingProps) {
601
- const value = node[key];
799
+ let value = node[key];
800
+ // Same rules as the cross-variant table: a border width only where a border
801
+ // is painted (and per-side weights win); zero padding is reported, not omitted.
802
+ if (key === "strokeWeight")
803
+ value = hasVisibleStroke(node) ? describeStrokeWeight(node) : undefined;
804
+ else if ((key.startsWith("padding") || key === "itemSpacing") && autoLayout)
805
+ value = value ?? 0;
806
+ else if (key === "cornerRadius" && (value === undefined || value === null) && Array.isArray(node.rectangleCornerRadii)) {
807
+ value = node.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
808
+ }
602
809
  if (value !== undefined && value !== null) {
603
810
  const varBinding = boundVars[key];
604
811
  // Bound spacing variables expose their id as boundVariables[key].id.
@@ -615,6 +822,35 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
615
822
  }
616
823
  return tokens;
617
824
  }
825
+ /** True when the node paints at least one stroke */
826
+ function hasVisibleStroke(node) {
827
+ return Array.isArray(node?.strokes) && node.strokes.some((st) => describePaint(st) !== null);
828
+ }
829
+ /**
830
+ * The border width that actually RENDERS. With per-side weights (underlines,
831
+ * dividers, accent bars) the scalar `strokeWeight` is a leftover from before the
832
+ * override and appears nowhere in the component — `individualStrokeWeights` wins.
833
+ */
834
+ export function describeStrokeWeight(node) {
835
+ if (!hasVisibleStroke(node))
836
+ return "none";
837
+ const sides = node.individualStrokeWeights;
838
+ if (sides && typeof sides === "object") {
839
+ const order = [
840
+ ["top", sides.top ?? 0], ["right", sides.right ?? 0],
841
+ ["bottom", sides.bottom ?? 0], ["left", sides.left ?? 0],
842
+ ];
843
+ const weights = order.map(([, w]) => w);
844
+ if (weights.every((w) => w === weights[0]))
845
+ return weights[0];
846
+ const painted = order.filter(([, w]) => w > 0);
847
+ // "4px bottom" reads better than "0 / 0 / 4 / 0" when few sides are in play
848
+ if (painted.length <= 2)
849
+ return painted.map(([side, w]) => `${w}px ${side}`).join(", ");
850
+ return `${weights.map((w) => `${w}px`).join(" / ")} (top / right / bottom / left)`;
851
+ }
852
+ return node.strokeWeight ?? 0;
853
+ }
618
854
  /** Parse "Size=lg, State=hover" into ordered [property, value] pairs */
619
855
  export function parseVariantProperties(rawName) {
620
856
  const pairs = [];
@@ -626,6 +862,38 @@ export function parseVariantProperties(rawName) {
626
862
  }
627
863
  return pairs;
628
864
  }
865
+ /**
866
+ * Find ONE variant property that fully accounts for how a value differs across
867
+ * variants: grouping by that property leaves a single value per group.
868
+ *
869
+ * The explanation must be non-vacuous — with one variant per group any values
870
+ * at all would "fit" (single-property sets), so that doesn't count.
871
+ * Shared by spacing, typography and anatomy so they explain variation alike.
872
+ */
873
+ export function explainByVariantProperty(samples) {
874
+ if (samples.length < 2)
875
+ return null;
876
+ for (const [propName] of samples[0].props) {
877
+ const groups = new Map();
878
+ let complete = true;
879
+ for (const x of samples) {
880
+ const propValue = x.props.find(([n]) => n === propName)?.[1];
881
+ if (propValue === undefined) {
882
+ complete = false;
883
+ break;
884
+ }
885
+ if (!groups.has(propValue))
886
+ groups.set(propValue, new Set());
887
+ groups.get(propValue).add(x.display);
888
+ }
889
+ if (!complete)
890
+ continue;
891
+ if (groups.size > 1 && groups.size < samples.length && [...groups.values()].every((vals) => vals.size === 1)) {
892
+ return { property: propName, groups: [...groups.entries()].map(([pv, vals]) => [pv, [...vals][0]]) };
893
+ }
894
+ }
895
+ return null;
896
+ }
629
897
  const SPACING_COMPARISON_PROPS = [
630
898
  { key: "paddingTop", label: "Padding top" },
631
899
  { key: "paddingRight", label: "Padding right" },
@@ -662,9 +930,17 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
662
930
  if (variants.length === 0)
663
931
  return result;
664
932
  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));
933
+ const isPadding = key.startsWith("padding") || key === "itemSpacing";
934
+ const isSet = (v) => (key === "strokeWeight"
935
+ // A border width only means something where a border is painted. REST
936
+ // reports strokeWeight (default 1) on every node, stroked or not.
937
+ ? hasVisibleStroke(v)
938
+ : (v[key] !== undefined && v[key] !== null)
939
+ // Per-corner rounding: REST drops the scalar and sends only the 4-tuple
940
+ || (key === "cornerRadius" && Array.isArray(v.rectangleCornerRadii))
941
+ // Auto-layout: REST omits zero-valued padding/gap, but 0 is a real,
942
+ // reportable value — leaving it out is indistinguishable from "unknown"
943
+ || (isPadding && !!v.layoutMode && v.layoutMode !== "NONE"));
668
944
  if (!variants.some(isSet))
669
945
  continue;
670
946
  const samples = variants.map((v) => {
@@ -673,6 +949,8 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
673
949
  if (key === "cornerRadius" && (v[key] === undefined || v[key] === null) && Array.isArray(v.rectangleCornerRadii)) {
674
950
  value = v.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
675
951
  }
952
+ if (key === "strokeWeight")
953
+ value = describeStrokeWeight(v);
676
954
  const varId = typeof v.boundVariables?.[key]?.id === "string"
677
955
  ? v.boundVariables[key].id
678
956
  : undefined;
@@ -714,31 +992,20 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
714
992
  : formatSpacingValue(x.value);
715
993
  }
716
994
  // 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
- }
995
+ const explanation = explainByVariantProperty(samples);
996
+ const explainedBy = explanation?.property ?? null;
997
+ const explainedGroups = explanation?.groups ?? [];
739
998
  let valueCell;
740
999
  if (explainedBy) {
741
- valueCell = `varies by **${explainedBy}**: ${explainedGroups.map(([pv, val]) => `${pv} ${val}`).join(" · ")}`;
1000
+ // Property values that land on the same result read better together:
1001
+ // "Active, Default, Hover 2px bottom · Focus 2px"
1002
+ const byResult = new Map();
1003
+ for (const [pv, val] of explainedGroups) {
1004
+ if (!byResult.has(val))
1005
+ byResult.set(val, []);
1006
+ byResult.get(val).push(pv);
1007
+ }
1008
+ valueCell = `varies by **${explainedBy}**: ${[...byResult.entries()].map(([val, pvs]) => `${pvs.join(", ")} ${val}`).join(" · ")}`;
742
1009
  }
743
1010
  else {
744
1011
  // No pattern — list each distinct value, most common first, naming the rare ones
@@ -1691,19 +1958,19 @@ async function detectAtomicLevel(api, fileKey, nodeId, setNodeId, _componentMeta
1691
1958
  return null;
1692
1959
  }
1693
1960
  }
1694
- function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
1961
+ export function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
1695
1962
  /**
1696
1963
  * Newest Figma version that touched this component, when design history was
1697
1964
  * pulled. Emitted as its own `figmaVersion` field rather than folded into
1698
1965
  * `version` — that one is the code-side semver and means something different.
1699
1966
  */
1700
1967
  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";
1968
+ // Only state what is actually known. "stable" / "1.0.0" used to be emitted for
1969
+ // every component and read as extracted facts.
1970
+ const status = componentMeta?.description?.toLowerCase().includes("deprecated")
1971
+ ? "deprecated"
1972
+ : codeInfo?.changelog?.[0] ? "stable" : null;
1973
+ const version = codeInfo?.changelog?.[0]?.version || null;
1707
1974
  const tags = [componentName.toLowerCase()];
1708
1975
  if (level)
1709
1976
  tags.push(level);
@@ -1714,9 +1981,9 @@ latestDesignVersion) {
1714
1981
  const lines = [
1715
1982
  "---",
1716
1983
  `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}`,
1984
+ ...((((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])}`] : []),
1985
+ ...(status ? [`status: ${status}`] : []),
1986
+ ...(version ? [`version: ${version}`] : []),
1720
1987
  `category: components`,
1721
1988
  ...(level ? [`level: ${level}`] : []),
1722
1989
  `tags: [${tags.join(", ")}]`,
@@ -1748,7 +2015,7 @@ latestDesignVersion) {
1748
2015
  lines.push("---");
1749
2016
  return lines.join("\n");
1750
2017
  }
1751
- function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
2018
+ export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
1752
2019
  const lines = [
1753
2020
  `# ${componentName}`,
1754
2021
  "",
@@ -1768,9 +2035,13 @@ function generateOverviewSection(componentName, description, fileUrl, parsedDesc
1768
2035
  lines.push("## Overview");
1769
2036
  lines.push("");
1770
2037
  // 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("");
2038
+ // No filler: libraries that deliberately keep descriptions empty would get a
2039
+ // sentence carrying no information on every page. Nothing says more than that.
2040
+ const overviewText = parsedDesc.overview || description?.split("\n")[0] || "";
2041
+ if (overviewText.trim()) {
2042
+ lines.push(overviewText);
2043
+ lines.push("");
2044
+ }
1774
2045
  // Base component attribution
1775
2046
  if (codeInfo?.baseComponent) {
1776
2047
  const baseLink = codeInfo.baseComponent.url
@@ -1805,10 +2076,19 @@ function generateOverviewSection(componentName, description, fileUrl, parsedDesc
1805
2076
  return lines.join("\n");
1806
2077
  }
1807
2078
  /** Icon cell text: the icon in the slot, flagged when it's only an instance-swap default */
1808
- function formatIconCell(icon) {
1809
- if (!icon)
2079
+ function formatIconCell(icons) {
2080
+ if (!icons || icons.length === 0)
1810
2081
  return "—";
1811
- return icon.swapProperty ? `${icon.name} _(default — swappable via **${icon.swapProperty}**)_` : icon.name;
2082
+ // A chip with a leading AND a trailing icon has two — show both
2083
+ return icons.map(formatOneIcon).join("; ");
2084
+ }
2085
+ function formatOneIcon(icon) {
2086
+ const notes = [];
2087
+ if (icon.swapProperty)
2088
+ notes.push(`default — swappable via **${icon.swapProperty}**`);
2089
+ if (icon.hidden)
2090
+ notes.push("hidden in this variant");
2091
+ return notes.length > 0 ? `${icon.name} _(${notes.join("; ")})_` : icon.name;
1812
2092
  }
1813
2093
  export function generateStatesAndVariantsSection(node, variantData, lookup = {}) {
1814
2094
  const props = node.componentPropertyDefinitions;
@@ -1819,6 +2099,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1819
2099
  const booleans = [];
1820
2100
  const textProps = [];
1821
2101
  const instanceSwaps = [];
2102
+ const slots = [];
1822
2103
  for (const [rawName, def] of Object.entries(props)) {
1823
2104
  // Strip Figma internal ID suffixes like "#17100:0" from property names
1824
2105
  const name = stripPropertyIdSuffix(rawName);
@@ -1843,6 +2124,14 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1843
2124
  preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
1844
2125
  });
1845
2126
  }
2127
+ else if (def.type === "SLOT") {
2128
+ // For a compositional component the slots ARE the API
2129
+ slots.push({
2130
+ name,
2131
+ preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
2132
+ description: typeof def.description === "string" && def.description.trim() ? def.description.trim() : null,
2133
+ });
2134
+ }
1846
2135
  }
1847
2136
  // Variant matrix with per-variant color data
1848
2137
  if (variants.length > 0 && variantData && variantData.length > 0) {
@@ -1866,19 +2155,21 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1866
2155
  lines.push("|" + separatorParts.join("|") + "|");
1867
2156
  for (const vd of variantData) {
1868
2157
  const displayName = cleanVariantName(vd.variantName);
2158
+ // A hidden layer never stands in for what renders
2159
+ const painted = (list) => list.find((c) => !c.hidden);
1869
2160
  // Get primary fill (background)
1870
- const bgFill = vd.fills[0];
2161
+ const bgFill = painted(vd.fills);
1871
2162
  const bgVal = bgFill
1872
2163
  ? (bgFill.variableName ? `\`${bgFill.variableName}\` (${bgFill.hex})` : bgFill.hex)
1873
2164
  : "—";
1874
2165
  // Get primary text/icon color
1875
- const textColor = vd.textColors[0] || vd.iconColors[0] || vd.strokes[0];
2166
+ const textColor = painted(vd.textColors) || painted(vd.iconColors) || painted(vd.strokes);
1876
2167
  const textVal = textColor
1877
2168
  ? (textColor.variableName ? `\`${textColor.variableName}\` (${textColor.hex})` : textColor.hex)
1878
2169
  : "—";
1879
2170
  const rowParts = [`**${displayName}**`, bgVal];
1880
2171
  if (hasIcons) {
1881
- rowParts.push(formatIconCell(vd.icons[0]));
2172
+ rowParts.push(formatIconCell(vd.icons));
1882
2173
  }
1883
2174
  rowParts.push(textVal);
1884
2175
  lines.push("| " + rowParts.join(" | ") + " |");
@@ -1893,7 +2184,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1893
2184
  lines.push("|---------|---------------------|");
1894
2185
  for (const vd of variantData) {
1895
2186
  const displayName = cleanVariantName(vd.variantName);
1896
- lines.push(`| ${displayName} | ${formatIconCell(vd.icons[0])} |`);
2187
+ lines.push(`| ${displayName} | ${formatIconCell(vd.icons)} |`);
1897
2188
  }
1898
2189
  lines.push("");
1899
2190
  }
@@ -1908,7 +2199,9 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1908
2199
  lines.push("");
1909
2200
  }
1910
2201
  // Configurable properties table (all property types)
1911
- if (booleans.length > 0 || textProps.length > 0 || instanceSwaps.length > 0) {
2202
+ // `variants` belongs in this guard: the table below renders them as its first
2203
+ // rows, so a set with only VARIANT properties used to get no table at all.
2204
+ if (variants.length > 0 || booleans.length > 0 || textProps.length > 0 || instanceSwaps.length > 0 || slots.length > 0) {
1912
2205
  lines.push("### Configurable Properties");
1913
2206
  lines.push("");
1914
2207
  lines.push("| Property | Type | Default | Description |");
@@ -1926,6 +2219,10 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
1926
2219
  const preferred = sw.preferredCount > 0 ? ` (${sw.preferredCount} preferred values)` : "";
1927
2220
  lines.push(`| **${sw.name}** | \`instance swap\` | ${sw.defaultName ? `\`${sw.defaultName}\`` : "—"} | Swaps the nested ${sw.name.toLowerCase()} instance${preferred} |`);
1928
2221
  }
2222
+ for (const slot of slots) {
2223
+ const preferred = slot.preferredCount > 0 ? ` (${slot.preferredCount} preferred values)` : "";
2224
+ lines.push(`| **${slot.name}** | \`slot\` | — | ${slot.description ?? `Accepts nested content for ${slot.name.toLowerCase()}`}${preferred} |`);
2225
+ }
1929
2226
  lines.push("");
1930
2227
  }
1931
2228
  return lines.join("\n");
@@ -2022,7 +2319,7 @@ setNode) {
2022
2319
  // Multi-path icons and repeated shapes would otherwise emit identical rows
2023
2320
  const emitted = new Set();
2024
2321
  const pushRow = (label, c) => {
2025
- const row = `| ${label} | ${tokenCell(c)} | ${c.hex} |`;
2322
+ const row = `| ${label}${c.hidden ? " _(hidden layer)_" : ""} | ${tokenCell(c)} | ${c.hex} |`;
2026
2323
  if (emitted.has(row))
2027
2324
  return;
2028
2325
  emitted.add(row);
@@ -2042,15 +2339,15 @@ setNode) {
2042
2339
  for (const c of vd.iconColors)
2043
2340
  pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
2044
2341
  // 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
- }
2342
+ for (const text of vd.textColors)
2343
+ pushRow(`Text (${text.nodeName})`, text);
2344
+ // Strokes — through the same writer, so a visible underline and a hidden
2345
+ // focus ring sharing one color don't print as two indistinguishable rows
2346
+ for (const stroke of vd.strokes)
2347
+ pushRow(stroke.nodeName ? `Stroke (${stroke.nodeName})` : "Stroke", stroke);
2348
+ // Shadows, blurs, opacity — dropping them would imply the component is flat
2349
+ for (const fx of vd.effects)
2350
+ pushRow(fx.nodeName ? `Effect (${fx.nodeName})` : "Effect", fx);
2054
2351
  }
2055
2352
  lines.push("");
2056
2353
  }
@@ -2113,7 +2410,7 @@ setNode) {
2113
2410
  lines.push("|----------|---------------|-------|");
2114
2411
  for (const token of spacingTokens) {
2115
2412
  const varDisplay = token.variableName ? `\`${token.variableName}\`` : "—";
2116
- lines.push(`| ${token.property} | ${varDisplay} | ${token.value}px |`);
2413
+ lines.push(`| ${token.property} | ${varDisplay} | ${formatSpacingValue(token.value)} |`);
2117
2414
  }
2118
2415
  lines.push("");
2119
2416
  }
@@ -2343,7 +2640,38 @@ function generateChangelogSection(codeInfo) {
2343
2640
  // ============================================================================
2344
2641
  // New Section Generators (Anatomy, Typography, Content Guidelines, Parity)
2345
2642
  // ============================================================================
2346
- function generateAnatomySection(node) {
2643
+ /** Layer-structure fingerprint: names, types and nesting — not styling */
2644
+ function structureSignature(node, depth = 0) {
2645
+ if (depth > DOC_TREE_DEPTH)
2646
+ return "";
2647
+ const kids = Array.isArray(node.children) ? node.children.map((c) => structureSignature(c, depth + 1)).join(",") : "";
2648
+ return `${node.type}:${depth === 0 ? "" : node.name}${node.visible === false ? "!" : ""}[${kids}]`;
2649
+ }
2650
+ const CONTAINER_TYPES = new Set(["FRAME", "GROUP", "INSTANCE", "COMPONENT", "COMPONENT_SET", "SECTION", "BOOLEAN_OPERATION"]);
2651
+ /**
2652
+ * Count containers sitting exactly at the fetch-depth limit with no children.
2653
+ * REST cuts a tree off by returning `children: []`, which looks identical to an
2654
+ * empty frame — so at that level an empty container MAY be hiding real content.
2655
+ * `fetchedRoot` is the node the REST call requested (levels count from it).
2656
+ */
2657
+ export function countPossiblyTruncated(fetchedRoot, fetchDepth = DOC_TREE_DEPTH) {
2658
+ let count = 0;
2659
+ const walk = (node, level) => {
2660
+ if (!node)
2661
+ return;
2662
+ const kids = Array.isArray(node.children) ? node.children : [];
2663
+ if (level >= fetchDepth) {
2664
+ if (CONTAINER_TYPES.has(node.type) && kids.length === 0)
2665
+ count++;
2666
+ return;
2667
+ }
2668
+ for (const child of kids)
2669
+ walk(child, level + 1);
2670
+ };
2671
+ walk(fetchedRoot, 0);
2672
+ return count;
2673
+ }
2674
+ export function generateAnatomySection(node, fetchDepth = DOC_TREE_DEPTH) {
2347
2675
  const lines = ["", "## Component Anatomy", ""];
2348
2676
  // For COMPONENT_SET, list all variants first
2349
2677
  if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
@@ -2355,25 +2683,81 @@ function generateAnatomySection(node) {
2355
2683
  }
2356
2684
  lines.push("### Design Structure (Figma)");
2357
2685
  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("```");
2686
+ // Variants can differ STRUCTURALLY (a scrollable variant wraps its list in
2687
+ // scroller frames and adds a fade). Showing one tree would silently drop
2688
+ // everything unique to the others — so: one tree per distinct structure.
2689
+ const MAX_STRUCTURES = 6;
2690
+ const structures = [];
2691
+ if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
2692
+ const bySignature = new Map();
2693
+ for (const variant of node.children) {
2694
+ const sig = structureSignature(variant);
2695
+ if (!bySignature.has(sig)) {
2696
+ bySignature.set(sig, structures.length);
2697
+ structures.push({ node: variant, names: [], props: [] });
2698
+ }
2699
+ const entry = structures[bySignature.get(sig)];
2700
+ entry.names.push(cleanVariantName(variant.name || "Unknown"));
2701
+ entry.props.push(parseVariantProperties(variant.name || ""));
2702
+ }
2703
+ }
2704
+ else {
2705
+ structures.push({ node, names: [], props: [] });
2706
+ }
2707
+ if (structures.length > 1) {
2708
+ // Which variant property decides the structure, if one does
2709
+ const explanation = explainByVariantProperty(structures.flatMap((st, i) => st.props.map((props) => ({ props, display: String(i) }))));
2710
+ lines.push(`_${node.children.length} variants share ${structures.length} distinct layer structures${explanation ? `, decided by **${explanation.property}**` : ""}._`);
2711
+ lines.push("");
2712
+ structures.slice(0, MAX_STRUCTURES).forEach((st, i) => {
2713
+ const values = explanation?.groups.filter(([, display]) => display === String(i)).map(([pv]) => pv) ?? [];
2714
+ const heading = values.length > 0
2715
+ ? `${explanation.property}=${values.join(" | ")}`
2716
+ : scopeLabel(st.names, -1);
2717
+ lines.push(`**${heading}** (${st.names.length} variant${st.names.length === 1 ? "" : "s"})`);
2718
+ lines.push("");
2719
+ lines.push("```");
2720
+ lines.push(buildAnatomyTree(st.node));
2721
+ lines.push("```");
2722
+ lines.push("");
2723
+ });
2724
+ if (structures.length > MAX_STRUCTURES) {
2725
+ lines.push(`_+${structures.length - MAX_STRUCTURES} more distinct structures not shown._`);
2726
+ lines.push("");
2727
+ }
2364
2728
  }
2365
2729
  else {
2366
- // Shallow tree (REST API depth limitation)
2367
2730
  lines.push("```");
2368
- lines.push(tree);
2731
+ lines.push(buildAnatomyTree(structures[0].node));
2369
2732
  lines.push("```");
2370
2733
  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
2734
  }
2373
- lines.push("");
2735
+ // Say so when the fetch may have cut the tree off, rather than presenting an
2736
+ // incomplete document as a complete one.
2737
+ const truncated = countPossiblyTruncated(node, fetchDepth);
2738
+ if (truncated > 0) {
2739
+ 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._`);
2740
+ lines.push("");
2741
+ }
2374
2742
  return lines.join("\n");
2375
2743
  }
2376
- function generateTypographySection(node) {
2744
+ export function generateTypographySection(node) {
2745
+ if (node.type === "COMPONENT_SET" && node.children?.length > 1) {
2746
+ const rows = collectTypographyAcrossVariants(node);
2747
+ if (rows.length === 0)
2748
+ return "";
2749
+ const out = ["", "## Typography", ""];
2750
+ const scoped = rows.some((r) => r.scope);
2751
+ 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"}._`);
2752
+ out.push("");
2753
+ out.push(`| Element | Font | Weight | Size | Line Height | Letter Spacing |${scoped ? " Applies to |" : ""}`);
2754
+ out.push(`|---------|------|--------|------|-------------|----------------|${scoped ? "------------|" : ""}`);
2755
+ for (const { style: ts, scope } of rows) {
2756
+ 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"} |` : ""}`);
2757
+ }
2758
+ out.push("");
2759
+ return out.join("\n");
2760
+ }
2377
2761
  const textStyles = collectTypographyData(node);
2378
2762
  if (textStyles.length === 0)
2379
2763
  return "";
@@ -2383,11 +2767,11 @@ function generateTypographySection(node) {
2383
2767
  // Deduplicate by font properties
2384
2768
  const seen = new Set();
2385
2769
  for (const ts of textStyles) {
2386
- const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}`;
2770
+ const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}:${ts.hidden ? "h" : ""}:${ts.mixed ? "m" : ""}`;
2387
2771
  if (seen.has(key))
2388
2772
  continue;
2389
2773
  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`} |`);
2774
+ 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
2775
  }
2392
2776
  lines.push("");
2393
2777
  return lines.join("\n");
@@ -2412,59 +2796,63 @@ function generateContentGuidelinesSection(parsedDesc) {
2412
2796
  }
2413
2797
  return lines.join("\n");
2414
2798
  }
2415
- function generateParitySection(node, codeInfo) {
2799
+ export function generateParitySection(node, codeInfo) {
2416
2800
  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
2801
+ // Variant coverage — compare each Figma variant PROPERTY with the code prop of
2802
+ // the same name, value by value. (This used to look only for a property
2803
+ // literally named "Variant" and otherwise captured the first property's NAME —
2804
+ // so a Size × State set was reported as having a Figma-only variant "Size".)
2805
+ const normalize = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
2806
+ const figmaProps = new Map();
2420
2807
  if (node.type === "COMPONENT_SET" && node.children) {
2421
2808
  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
- }
2809
+ for (const [propName, value] of parseVariantProperties(child.name || "")) {
2810
+ const key = normalize(propName);
2811
+ if (!figmaProps.has(key))
2812
+ figmaProps.set(key, { name: propName, values: new Map() });
2813
+ figmaProps.get(key).values.set(value.toLowerCase(), value);
2441
2814
  }
2442
2815
  }
2443
2816
  }
2444
- if (figmaVariantsRaw.size > 0 || codeVariantsRaw.size > 0) {
2445
- // Merge by lowercase key
2446
- const allKeys = new Set([...figmaVariantsRaw.keys(), ...codeVariantsRaw.keys()]);
2817
+ // Code props whose type is a union of string literals: "sm" | "md" | 'lg'
2818
+ const codeProps = new Map();
2819
+ for (const prop of codeInfo.props ?? []) {
2820
+ const literals = prop.type?.match(/["']([^"']+)["']/g);
2821
+ if (!literals)
2822
+ continue;
2823
+ const values = new Map();
2824
+ for (const m of literals) {
2825
+ const raw = m.replace(/["']/g, "");
2826
+ values.set(raw.toLowerCase(), raw);
2827
+ }
2828
+ codeProps.set(normalize(prop.name), { name: prop.name, values });
2829
+ }
2830
+ // Compare properties present on BOTH sides, plus "variant" when either side has
2831
+ // it (the long-standing behavior). A property with no counterpart gets no
2832
+ // claim at all — a boolean in code and a True/False variant in Figma aren't comparable here.
2833
+ const compared = [...figmaProps.keys()].filter((k) => codeProps.has(k));
2834
+ if (!compared.includes("variant") && (figmaProps.has("variant") || codeProps.has("variant")))
2835
+ compared.push("variant");
2836
+ if (compared.length > 0) {
2837
+ const labelProperty = compared.length > 1 || compared[0] !== "variant";
2447
2838
  lines.push("### Variant Coverage");
2448
2839
  lines.push("");
2449
2840
  lines.push("| Variant | In Figma | In Code | Status |");
2450
2841
  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";
2842
+ for (const key of compared) {
2843
+ const figma = figmaProps.get(key);
2844
+ const code = codeProps.get(key);
2845
+ const propLabel = figma?.name || code?.name || key;
2846
+ const allValues = new Set([...(figma?.values.keys() ?? []), ...(code?.values.keys() ?? [])]);
2847
+ for (const valueKey of allValues) {
2848
+ const inFigma = figma?.values.has(valueKey) ?? false;
2849
+ const inCode = code?.values.has(valueKey) ?? false;
2850
+ const display = figma?.values.get(valueKey) || code?.values.get(valueKey) || valueKey;
2851
+ const status = inFigma && inCode
2852
+ ? "In sync"
2853
+ : inFigma ? "Figma-only — needs code variant" : "Code-only — needs Figma variant";
2854
+ lines.push(`| ${labelProperty ? `${propLabel}: ${display}` : display} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
2463
2855
  }
2464
- else {
2465
- status = "Code-only — needs Figma variant";
2466
- }
2467
- lines.push(`| ${displayName} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
2468
2856
  }
2469
2857
  lines.push("");
2470
2858
  }
@@ -2917,7 +3305,22 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
2917
3305
  logger.info({ fileKey, nodeId, enrich }, "Generating component documentation");
2918
3306
  const api = await getFigmaAPI();
2919
3307
  // Fetch component node with deeper depth for anatomy & per-variant data
2920
- const nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: 4 });
3308
+ // DOC_TREE_DEPTH, not 4: levels count from the requested node, so on a
3309
+ // COMPONENT_SET depth 4 left each variant only 3 — enough to silently drop
3310
+ // nested labels and a whole scroll mechanism from the generated document.
3311
+ let fetchDepth = DOC_TREE_DEPTH;
3312
+ let nodesResponse;
3313
+ try {
3314
+ nodesResponse = await withTimeout(api.getNodes(fileKey, [nodeId], { depth: DOC_TREE_DEPTH }), DEEP_FETCH_TIMEOUT_MS, "deep component fetch");
3315
+ }
3316
+ catch (deepErr) {
3317
+ // A huge set (hundreds of variants × nested instances) can make the deep
3318
+ // fetch too slow or too large. A shallower document that SAYS it is
3319
+ // shallow beats no document — the truncation note below reports it.
3320
+ fetchDepth = SHALLOW_FETCH_DEPTH;
3321
+ logger.warn({ err: deepErr instanceof Error ? deepErr.message : String(deepErr) }, "Deep fetch failed; retrying component doc fetch at reduced depth");
3322
+ nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: SHALLOW_FETCH_DEPTH });
3323
+ }
2921
3324
  const nodeData = nodesResponse?.nodes?.[nodeId];
2922
3325
  if (!nodeData?.document) {
2923
3326
  throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
@@ -3005,10 +3408,8 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3005
3408
  const varsResult = await connector.getVariables();
3006
3409
  const varList = varsResult?.variables || varsResult?.result?.variables;
3007
3410
  if (Array.isArray(varList)) {
3008
- for (const v of varList) {
3009
- if (v?.id && v?.name)
3010
- varNameMap.set(v.id, v.name);
3011
- }
3411
+ for (const [id, name] of buildVariableNameMap(varList, varsResult?.variableCollections || varsResult?.result?.variableCollections))
3412
+ varNameMap.set(id, name);
3012
3413
  logger.info({ count: varList.length }, "Resolved variable names via Desktop Bridge for docs");
3013
3414
  }
3014
3415
  }
@@ -3141,7 +3542,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3141
3542
  includedSections.push("overview");
3142
3543
  }
3143
3544
  if (s.anatomy) {
3144
- const anatomySection = generateAnatomySection(node);
3545
+ const anatomySection = generateAnatomySection(node, fetchDepth);
3145
3546
  if (anatomySection.trim()) {
3146
3547
  parts.push(anatomySection);
3147
3548
  includedSections.push("anatomy");