figma-console-mcp 1.40.4 → 1.40.6

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.
Files changed (139) hide show
  1. package/README.md +4 -2
  2. package/dist/core/design-code-tools.d.ts +49 -3
  3. package/dist/core/design-code-tools.d.ts.map +1 -1
  4. package/dist/core/design-code-tools.js +357 -83
  5. package/dist/core/design-code-tools.js.map +1 -1
  6. package/dist/core/extended-collections.d.ts +103 -0
  7. package/dist/core/extended-collections.d.ts.map +1 -0
  8. package/dist/core/extended-collections.js +211 -0
  9. package/dist/core/extended-collections.js.map +1 -0
  10. package/dist/core/figma-api.d.ts.map +1 -1
  11. package/dist/core/figma-api.js +10 -0
  12. package/dist/core/figma-api.js.map +1 -1
  13. package/dist/core/figma-tools.d.ts.map +1 -1
  14. package/dist/core/figma-tools.js +50 -5
  15. package/dist/core/figma-tools.js.map +1 -1
  16. package/dist/core/history/git-history.d.ts +35 -0
  17. package/dist/core/history/git-history.d.ts.map +1 -1
  18. package/dist/core/history/git-history.js +104 -5
  19. package/dist/core/history/git-history.js.map +1 -1
  20. package/dist/core/history/history-formatter.d.ts +4 -0
  21. package/dist/core/history/history-formatter.d.ts.map +1 -1
  22. package/dist/core/history/history-formatter.js +4 -4
  23. package/dist/core/history/history-formatter.js.map +1 -1
  24. package/dist/core/tokens-tools.d.ts.map +1 -1
  25. package/dist/core/tokens-tools.js +34 -2
  26. package/dist/core/tokens-tools.js.map +1 -1
  27. package/dist/core/types/design-code.d.ts +2 -0
  28. package/dist/core/types/design-code.d.ts.map +1 -1
  29. package/dist/core/websocket-server.d.ts +2 -0
  30. package/dist/core/websocket-server.d.ts.map +1 -1
  31. package/dist/core/websocket-server.js +4 -0
  32. package/dist/core/websocket-server.js.map +1 -1
  33. package/dist/local.d.ts.map +1 -1
  34. package/dist/local.js +3 -2
  35. package/dist/local.js.map +1 -1
  36. package/package.json +4 -3
  37. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +0 -408
  38. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +0 -366
  39. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +0 -407
  40. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +0 -233
  41. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +0 -92
  42. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +0 -342
  43. package/dist/cloudflare/apps/design-system-dashboard/scoring/remediation.js +0 -168
  44. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +0 -412
  45. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +0 -40
  46. package/dist/cloudflare/apps/design-system-dashboard/server.js +0 -346
  47. package/dist/cloudflare/apps/token-browser/server.js +0 -136
  48. package/dist/cloudflare/browser-manager.js +0 -157
  49. package/dist/cloudflare/core/accessibility-tools.js +0 -361
  50. package/dist/cloudflare/core/annotation-tools.js +0 -230
  51. package/dist/cloudflare/core/cloud-websocket-connector.js +0 -432
  52. package/dist/cloudflare/core/cloud-websocket-relay.js +0 -198
  53. package/dist/cloudflare/core/comment-tools.js +0 -292
  54. package/dist/cloudflare/core/config.js +0 -153
  55. package/dist/cloudflare/core/console-monitor.js +0 -427
  56. package/dist/cloudflare/core/deep-component-tools.js +0 -128
  57. package/dist/cloudflare/core/design-code-tools.js +0 -3755
  58. package/dist/cloudflare/core/design-system-extraction-tools.js +0 -611
  59. package/dist/cloudflare/core/design-system-manifest.js +0 -283
  60. package/dist/cloudflare/core/design-system-tools.js +0 -882
  61. package/dist/cloudflare/core/diagnose-tool.js +0 -100
  62. package/dist/cloudflare/core/diff/changelog-formatter.js +0 -275
  63. package/dist/cloudflare/core/diff/diff-engine.js +0 -334
  64. package/dist/cloudflare/core/diff/property-compare.js +0 -36
  65. package/dist/cloudflare/core/diff/version-cache.js +0 -74
  66. package/dist/cloudflare/core/enrichment/enrichment-service.js +0 -374
  67. package/dist/cloudflare/core/enrichment/index.js +0 -7
  68. package/dist/cloudflare/core/enrichment/relationship-mapper.js +0 -351
  69. package/dist/cloudflare/core/enrichment/style-resolver.js +0 -346
  70. package/dist/cloudflare/core/extraction/analyzer.js +0 -161
  71. package/dist/cloudflare/core/extraction/architecture.js +0 -159
  72. package/dist/cloudflare/core/extraction/component-inventory.js +0 -485
  73. package/dist/cloudflare/core/extraction/detectors.js +0 -148
  74. package/dist/cloudflare/core/extraction/scaffolder.js +0 -614
  75. package/dist/cloudflare/core/extraction/storybook-preset.js +0 -349
  76. package/dist/cloudflare/core/extraction/token-extractor.js +0 -759
  77. package/dist/cloudflare/core/extraction/types.js +0 -14
  78. package/dist/cloudflare/core/extraction/verifier.js +0 -137
  79. package/dist/cloudflare/core/extraction/walker.js +0 -118
  80. package/dist/cloudflare/core/figjam-tools.js +0 -547
  81. package/dist/cloudflare/core/figma-api.js +0 -509
  82. package/dist/cloudflare/core/figma-connector.js +0 -7
  83. package/dist/cloudflare/core/figma-reconstruction-spec.js +0 -402
  84. package/dist/cloudflare/core/figma-style-extractor.js +0 -311
  85. package/dist/cloudflare/core/figma-tools.js +0 -3304
  86. package/dist/cloudflare/core/history/component-history.js +0 -388
  87. package/dist/cloudflare/core/history/git-history.js +0 -161
  88. package/dist/cloudflare/core/history/history-formatter.js +0 -116
  89. package/dist/cloudflare/core/identity.js +0 -143
  90. package/dist/cloudflare/core/library-tools.js +0 -629
  91. package/dist/cloudflare/core/logger.js +0 -53
  92. package/dist/cloudflare/core/multi-file-tools.js +0 -161
  93. package/dist/cloudflare/core/port-discovery.js +0 -1016
  94. package/dist/cloudflare/core/resolve-package-root.js +0 -11
  95. package/dist/cloudflare/core/slides-tools.js +0 -714
  96. package/dist/cloudflare/core/slot-tools.js +0 -334
  97. package/dist/cloudflare/core/snippet-injector.js +0 -96
  98. package/dist/cloudflare/core/tokens/alias-resolver.js +0 -205
  99. package/dist/cloudflare/core/tokens/config.js +0 -294
  100. package/dist/cloudflare/core/tokens/dialect.js +0 -232
  101. package/dist/cloudflare/core/tokens/figma-converter.js +0 -323
  102. package/dist/cloudflare/core/tokens/formatters/css-vars.js +0 -345
  103. package/dist/cloudflare/core/tokens/formatters/dtcg.js +0 -376
  104. package/dist/cloudflare/core/tokens/formatters/index.js +0 -45
  105. package/dist/cloudflare/core/tokens/formatters/json.js +0 -205
  106. package/dist/cloudflare/core/tokens/formatters/less.js +0 -4
  107. package/dist/cloudflare/core/tokens/formatters/scss.js +0 -264
  108. package/dist/cloudflare/core/tokens/formatters/stubs.js +0 -13
  109. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +0 -213
  110. package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +0 -237
  111. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +0 -341
  112. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +0 -256
  113. package/dist/cloudflare/core/tokens/formatters/ts-module.js +0 -198
  114. package/dist/cloudflare/core/tokens/index.js +0 -16
  115. package/dist/cloudflare/core/tokens/parsers/css-vars.js +0 -4
  116. package/dist/cloudflare/core/tokens/parsers/dtcg.js +0 -280
  117. package/dist/cloudflare/core/tokens/parsers/index.js +0 -138
  118. package/dist/cloudflare/core/tokens/parsers/json.js +0 -7
  119. package/dist/cloudflare/core/tokens/parsers/scss.js +0 -4
  120. package/dist/cloudflare/core/tokens/parsers/stubs.js +0 -20
  121. package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +0 -4
  122. package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +0 -4
  123. package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +0 -4
  124. package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +0 -4
  125. package/dist/cloudflare/core/tokens/schemas.js +0 -152
  126. package/dist/cloudflare/core/tokens/transforms/color.js +0 -12
  127. package/dist/cloudflare/core/tokens/transforms/index.js +0 -29
  128. package/dist/cloudflare/core/tokens/transforms/size.js +0 -7
  129. package/dist/cloudflare/core/tokens/types.js +0 -18
  130. package/dist/cloudflare/core/tokens-tools.js +0 -2238
  131. package/dist/cloudflare/core/types/design-code.js +0 -4
  132. package/dist/cloudflare/core/types/enriched.js +0 -5
  133. package/dist/cloudflare/core/types/index.js +0 -4
  134. package/dist/cloudflare/core/variable-resolver.js +0 -85
  135. package/dist/cloudflare/core/version-tools.js +0 -1199
  136. package/dist/cloudflare/core/websocket-connector.js +0 -439
  137. package/dist/cloudflare/core/websocket-server.js +0 -1050
  138. package/dist/cloudflare/core/write-tools.js +0 -2505
  139. package/dist/cloudflare/index.js +0 -3010
@@ -4,11 +4,12 @@
4
4
  * and generating platform-agnostic component documentation.
5
5
  */
6
6
  import { z } from "zod";
7
+ import { basename, dirname, isAbsolute, relative } from "node:path";
7
8
  import { extractFileKey, withTimeout } from "./figma-api.js";
8
9
  import { createChildLogger } from "./logger.js";
9
10
  import { EnrichmentService } from "./enrichment/index.js";
10
11
  import { buildDesignHistory, DEFAULT_HISTORY_VERSIONS, MAX_HISTORY_VERSIONS, } from "./history/component-history.js";
11
- import { buildGitHistory, DEFAULT_GIT_LIMIT, MAX_GIT_LIMIT, } from "./history/git-history.js";
12
+ import { blobUrl, buildGitHistory, commitUrl, DEFAULT_GIT_LIMIT, MAX_GIT_LIMIT, resolveSourceRevision, } from "./history/git-history.js";
12
13
  import { formatHistorySection } from "./history/history-formatter.js";
13
14
  const logger = createChildLogger({ component: "design-code-tools" });
14
15
  const enrichmentService = new EnrichmentService(logger);
@@ -323,6 +324,44 @@ export function buildVariableNameMap(variables, collections) {
323
324
  }
324
325
  return out;
325
326
  }
327
+ /**
328
+ * A code path as it may appear in published docs: repo-relative paths as given;
329
+ * absolute local paths made relative to `root` when inside it, else reduced to
330
+ * the file name — never the author's home directory layout.
331
+ */
332
+ export function displayPath(path, root) {
333
+ if (!isAbsolute(path))
334
+ return path;
335
+ if (root) {
336
+ const rel = relative(root, path);
337
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel))
338
+ return rel;
339
+ }
340
+ return basename(path);
341
+ }
342
+ /** The boolean component property that controls this layer's visibility, if any */
343
+ export function visibilityProperty(node) {
344
+ const ref = node?.componentPropertyReferences?.visible;
345
+ return typeof ref === "string" ? stripPropertyIdSuffix(ref) : null;
346
+ }
347
+ /**
348
+ * How to label a layer's visibility: "(hidden — shown when Is Focused = true)"
349
+ * for a hidden layer a boolean property reveals, "(toggled by Show Icon)" for a
350
+ * visible one it can hide, "(hidden)" otherwise. Empty for ordinary layers.
351
+ */
352
+ export function visibilityNote(node) {
353
+ const prop = visibilityProperty(node);
354
+ if (node?.visible === false)
355
+ return prop ? `hidden — shown when ${prop} = true` : "hidden";
356
+ return prop ? `toggled by ${prop}` : "";
357
+ }
358
+ /** "Tab Item (Is Selected=True)" — an instance, qualified by its variant */
359
+ export function describeInstance(node) {
360
+ const variantProps = Object.entries(node?.componentProperties ?? {})
361
+ .filter(([, p]) => p?.type === "VARIANT")
362
+ .map(([k, p]) => `${stripPropertyIdSuffix(k)}=${p.value}`);
363
+ return variantProps.length > 0 ? `${node.name} (${variantProps.join(", ")})` : node?.name ?? "instance";
364
+ }
326
365
  /** Strip Figma's internal "#123:4" id suffix from a component property name */
327
366
  function stripPropertyIdSuffix(rawName) {
328
367
  return rawName.replace(/#\d+:\d+$/, "").trim();
@@ -506,13 +545,20 @@ function walkVariantNode(node, data, ctx, depth, maxDepth,
506
545
  /** Label of the icon instance we're inside of, or null when outside any icon */
507
546
  insideIcon,
508
547
  /** An ancestor is hidden, so nothing beneath it paints either */
509
- ancestorHidden = false) {
548
+ ancestorHidden = false,
549
+ /** Property that shows the nearest hidden ancestor, when one does */
550
+ ancestorShownWhen = null,
551
+ /** Nearest nested instance ("Tab Item (Is Selected=True)") */
552
+ owner = null) {
510
553
  if (depth > maxDepth)
511
554
  return;
512
555
  const isText = node.type === "TEXT";
513
556
  // 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;
557
+ // intent. They are labeled so a reader can tell it from what actually renders —
558
+ // and, when a boolean property reveals them, with the property that does.
559
+ const hiddenHere = node.visible === false;
560
+ const hidden = ancestorHidden || hiddenHere;
561
+ const shownWhen = hiddenHere ? visibilityProperty(node) : ancestorShownWhen;
516
562
  // Icons are reported once, at the outermost instance — everything beneath it
517
563
  // (vectors, nested instances) is that icon's artwork, not a separate icon.
518
564
  if (insideIcon === null) {
@@ -531,6 +577,8 @@ ancestorHidden = false) {
531
577
  variableId: varId,
532
578
  variableName: varId ? ctx.varNameMap.get(varId) : undefined,
533
579
  ...(hidden ? { hidden: true } : {}),
580
+ ...(hidden && shownWhen ? { shownWhen } : {}),
581
+ ...(owner ? { owner } : {}),
534
582
  };
535
583
  };
536
584
  // Collect fills. Only the variant root (or its full-bleed stand-in) is a
@@ -591,14 +639,14 @@ ancestorHidden = false) {
591
639
  // Recurse into children
592
640
  if (node.children && Array.isArray(node.children)) {
593
641
  for (const child of node.children) {
594
- walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden);
642
+ walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden, shownWhen, node.type === "INSTANCE" && depth > 0 ? describeInstance(node) : owner);
595
643
  }
596
644
  }
597
645
  }
598
646
  /**
599
647
  * Collect typography data from all text nodes in a component tree.
600
648
  */
601
- export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false) {
649
+ export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false, owner = null) {
602
650
  const results = [];
603
651
  if (depth > maxDepth)
604
652
  return results;
@@ -624,6 +672,7 @@ export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH
624
672
  lineHeight: s.lineHeightPx || s.fontSize || 14,
625
673
  letterSpacing: s.letterSpacing || 0,
626
674
  ...(hidden ? { hidden: true } : {}),
675
+ ...(owner ? { owner } : {}),
627
676
  // REST: characterStyleOverrides maps characters → styleOverrideTable ids; any
628
677
  // non-zero entry means part of the text departs from `style`
629
678
  ...(Array.isArray(node.characterStyleOverrides) && node.characterStyleOverrides.some((id) => id !== 0)
@@ -632,7 +681,7 @@ export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH
632
681
  }
633
682
  if (node.children && Array.isArray(node.children)) {
634
683
  for (const child of node.children) {
635
- results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden));
684
+ results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden, node.type === "INSTANCE" && depth > 0 ? describeInstance(node) : owner));
636
685
  }
637
686
  }
638
687
  return results;
@@ -668,6 +717,13 @@ export function collectTypographyAcrossVariants(setNode) {
668
717
  }
669
718
  }
670
719
  }
720
+ // An element name shared by rows with DIFFERENT styles (the selected tab's
721
+ // SemiBold Label vs the others' Regular Label) is qualified by its instance.
722
+ const stylesPerName = new Map();
723
+ for (const key of order) {
724
+ const n = styles.get(key).nodeName;
725
+ stylesPerName.set(n, (stylesPerName.get(n) ?? 0) + 1);
726
+ }
671
727
  return order.map((key) => {
672
728
  const style = styles.get(key);
673
729
  const having = perVariant.filter((pv) => pv.keys.has(key));
@@ -681,7 +737,8 @@ export function collectTypographyAcrossVariants(setNode) {
681
737
  : scopeLabel(having.map((pv) => pv.name), perVariant.length);
682
738
  }
683
739
  // 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;
740
+ const base = style.nodeName.length > 40 ? `${style.nodeName.slice(0, 40)}…` : style.nodeName;
741
+ const label = (stylesPerName.get(style.nodeName) ?? 0) > 1 && style.owner ? `${base} in ${style.owner}` : base;
685
742
  return { style: { ...style, nodeName: label }, scope };
686
743
  });
687
744
  }
@@ -774,7 +831,8 @@ suffix = "") {
774
831
  }
775
832
  // Hidden layers stay in the tree, marked: the color table names them (a hidden
776
833
  // focus ring is real design intent), so the tree must not pretend they don't exist.
777
- const hiddenHint = node.visible === false ? " (hidden)" : "";
834
+ const note = visibilityNote(node);
835
+ const hiddenHint = note ? ` (${note})` : "";
778
836
  lines.push(`${prefix}${connector}${label}${typeHint}${suffix}${hiddenHint}${layoutInfo}${sizingInfo}`);
779
837
  // Recurse into children, collapsing RUNS of siblings that would print
780
838
  // identically (ten nav items, five list rows) into one entry marked ×N. Compared
@@ -800,18 +858,89 @@ suffix = "") {
800
858
  }
801
859
  }
802
860
  /**
803
- * Collect spacing tokens with their bound variable names.
861
+ * The variable a spacing-table property is bound to, read from where Figma
862
+ * actually stores the binding. Radius and per-side stroke weights are bound PER
863
+ * CORNER / PER SIDE (`topLeftRadius`, `strokeBottomWeight`, …) — there is never a
864
+ * `cornerRadius` key in `boundVariables`, even when all four corners share one
865
+ * token — so a lookup by the row's own key reported every radius token as
866
+ * hardcoded.
867
+ *
868
+ * `name` is the display text for the Figma Variable column (undefined = unbound);
869
+ * `signature` identifies the binding for cross-variant comparison.
804
870
  */
871
+ export function resolvePropertyBinding(node, key, varNameMap = new Map()) {
872
+ const bound = node?.boundVariables ?? {};
873
+ // The same binding has two shapes. Plugin API: flat keys (`topLeftRadius`,
874
+ // `strokeBottomWeight`). REST — which this tool reads — nests them:
875
+ // `rectangleCornerRadii.RECTANGLE_TOP_LEFT_CORNER_RADIUS`,
876
+ // `individualStrokeWeights.BORDER_BOTTOM_WEIGHT` (verified against JSON_REST_V1).
877
+ const REST_NESTED = {
878
+ topLeftRadius: ["rectangleCornerRadii", "RECTANGLE_TOP_LEFT_CORNER_RADIUS"],
879
+ topRightRadius: ["rectangleCornerRadii", "RECTANGLE_TOP_RIGHT_CORNER_RADIUS"],
880
+ bottomRightRadius: ["rectangleCornerRadii", "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS"],
881
+ bottomLeftRadius: ["rectangleCornerRadii", "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS"],
882
+ strokeTopWeight: ["individualStrokeWeights", "BORDER_TOP_WEIGHT"],
883
+ strokeRightWeight: ["individualStrokeWeights", "BORDER_RIGHT_WEIGHT"],
884
+ strokeBottomWeight: ["individualStrokeWeights", "BORDER_BOTTOM_WEIGHT"],
885
+ strokeLeftWeight: ["individualStrokeWeights", "BORDER_LEFT_WEIGHT"],
886
+ };
887
+ const idOf = (k) => {
888
+ let b = bound[k];
889
+ if (b === undefined && REST_NESTED[k])
890
+ b = bound[REST_NESTED[k][0]]?.[REST_NESTED[k][1]];
891
+ const id = Array.isArray(b) ? b[0]?.id : b?.id;
892
+ return typeof id === "string" ? id : undefined;
893
+ };
894
+ const nameOf = (id) => varNameMap.get(id) || id;
895
+ let parts = null;
896
+ if (key === "cornerRadius") {
897
+ if (idOf("cornerRadius"))
898
+ parts = null;
899
+ else
900
+ parts = [
901
+ ["top-left", idOf("topLeftRadius")], ["top-right", idOf("topRightRadius")],
902
+ ["bottom-right", idOf("bottomRightRadius")], ["bottom-left", idOf("bottomLeftRadius")],
903
+ ];
904
+ }
905
+ else if (key === "strokeWeight" && !idOf("strokeWeight")) {
906
+ const sides = node?.individualStrokeWeights;
907
+ const all = [
908
+ ["top", "strokeTopWeight", sides?.top ?? 1], ["right", "strokeRightWeight", sides?.right ?? 1],
909
+ ["bottom", "strokeBottomWeight", sides?.bottom ?? 1], ["left", "strokeLeftWeight", sides?.left ?? 1],
910
+ ];
911
+ // Only sides that actually paint a border say anything about it
912
+ parts = all.filter(([, , w]) => w > 0).map(([side, k]) => [side, idOf(k)]);
913
+ }
914
+ if (!parts) {
915
+ const id = idOf(key);
916
+ return id ? { name: nameOf(id), signature: id } : { signature: "" };
917
+ }
918
+ const ids = parts.map(([, id]) => id);
919
+ const boundIds = ids.filter((id) => !!id);
920
+ if (boundIds.length === 0)
921
+ return { signature: "" };
922
+ const signature = parts.map(([p, id]) => `${p}:${id ?? ""}`).join(",");
923
+ if (boundIds.length === ids.length && new Set(boundIds).size === 1) {
924
+ return { name: nameOf(boundIds[0]), signature: boundIds[0] };
925
+ }
926
+ if (new Set(boundIds).size === 1) {
927
+ const on = parts.filter(([, id]) => id).map(([p]) => p);
928
+ return { name: `${nameOf(boundIds[0])} (${on.join(", ")} only)`, signature };
929
+ }
930
+ return { name: parts.map(([p, id]) => `${p}: ${id ? nameOf(id) : "unbound"}`).join(", "), signature };
931
+ }
932
+ /** Collect spacing tokens with their bound variable names. */
805
933
  function collectSpacingTokens(node, varNameMap = new Map()) {
806
934
  const tokens = [];
807
935
  const autoLayout = !!node.layoutMode && node.layoutMode !== "NONE";
808
- const boundVars = node.boundVariables || {};
809
936
  const spacingProps = [
810
937
  { key: "paddingTop", label: "Padding top" },
811
938
  { key: "paddingRight", label: "Padding right" },
812
939
  { key: "paddingBottom", label: "Padding bottom" },
813
940
  { key: "paddingLeft", label: "Padding left" },
814
941
  { key: "itemSpacing", label: "Gap" },
942
+ // Cross-axis gap between wrapped rows (auto-layout WRAP); bound as its own key
943
+ { key: "counterAxisSpacing", label: "Row gap (wrap)" },
815
944
  { key: "cornerRadius", label: "Border radius" },
816
945
  { key: "strokeWeight", label: "Border width" },
817
946
  ];
@@ -827,16 +956,10 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
827
956
  value = node.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
828
957
  }
829
958
  if (value !== undefined && value !== null) {
830
- const varBinding = boundVars[key];
831
- // Bound spacing variables expose their id as boundVariables[key].id.
832
- // Resolve it to a friendly token name (e.g. `spacing/1`) when the caller
833
- // supplied a name map; fall back to the raw id so the binding stays visible.
834
- const varId = typeof varBinding?.id === "string" ? varBinding.id : undefined;
835
- const varName = varId ? varNameMap.get(varId) || varId : undefined;
836
959
  tokens.push({
837
960
  property: label,
838
961
  value,
839
- variableName: typeof varName === "string" ? varName : undefined,
962
+ variableName: resolvePropertyBinding(node, key, varNameMap).name,
840
963
  });
841
964
  }
842
965
  }
@@ -920,6 +1043,8 @@ const SPACING_COMPARISON_PROPS = [
920
1043
  { key: "paddingBottom", label: "Padding bottom" },
921
1044
  { key: "paddingLeft", label: "Padding left" },
922
1045
  { key: "itemSpacing", label: "Gap" },
1046
+ // Cross-axis gap between wrapped rows (auto-layout WRAP); bound as its own key
1047
+ { key: "counterAxisSpacing", label: "Row gap (wrap)" },
923
1048
  { key: "cornerRadius", label: "Border radius" },
924
1049
  { key: "strokeWeight", label: "Border width" },
925
1050
  ];
@@ -971,15 +1096,13 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
971
1096
  }
972
1097
  if (key === "strokeWeight")
973
1098
  value = describeStrokeWeight(v);
974
- const varId = typeof v.boundVariables?.[key]?.id === "string"
975
- ? v.boundVariables[key].id
976
- : undefined;
1099
+ const binding = resolvePropertyBinding(v, key, varNameMap);
977
1100
  return {
978
1101
  displayName: cleanVariantName(v.name || "Unknown"),
979
1102
  props: parseVariantProperties(v.name || ""),
980
1103
  value,
981
- variableName: varId ? varNameMap.get(varId) || varId : undefined,
982
- signature: `${value}|${varId ?? ""}`,
1104
+ variableName: binding.name,
1105
+ signature: `${value}|${binding.signature}`,
983
1106
  display: "",
984
1107
  };
985
1108
  });
@@ -1984,7 +2107,11 @@ export function generateFrontmatter(componentName, description, node, componentM
1984
2107
  * pulled. Emitted as its own `figmaVersion` field rather than folded into
1985
2108
  * `version` — that one is the code-side semver and means something different.
1986
2109
  */
1987
- latestDesignVersion) {
2110
+ latestDesignVersion,
2111
+ /** The commit the code-side information was read at (local mode, git repos only) */
2112
+ sourceRevision,
2113
+ /** Directory absolute code paths are shown relative to */
2114
+ sourceRoot) {
1988
2115
  // Only state what is actually known. "stable" / "1.0.0" used to be emitted for
1989
2116
  // every component and read as extracted facts.
1990
2117
  const status = componentMeta?.description?.toLowerCase().includes("deprecated")
@@ -2010,7 +2137,7 @@ latestDesignVersion) {
2010
2137
  `figma: ${fileUrl}`,
2011
2138
  ];
2012
2139
  if (codeInfo?.filePath) {
2013
- lines.push(`source: ${codeInfo.filePath}`);
2140
+ lines.push(`source: ${displayPath(codeInfo.filePath, sourceRoot)}`);
2014
2141
  }
2015
2142
  if (codeInfo?.packageName) {
2016
2143
  lines.push(`package: ${codeInfo.packageName}`);
@@ -2018,6 +2145,15 @@ latestDesignVersion) {
2018
2145
  if (canonicalSource) {
2019
2146
  lines.push(`canonical: ${canonicalSource}`);
2020
2147
  }
2148
+ if (sourceRevision) {
2149
+ // Pins the code side of this page: without it nothing says whether the
2150
+ // code it describes is still current.
2151
+ lines.push(`sourceCommit: ${sourceRevision.commit}`);
2152
+ if (sourceRevision.dirty)
2153
+ lines.push("sourceDirty: true # documented files had uncommitted changes");
2154
+ if (sourceRevision.webBase)
2155
+ lines.push(`sourceRepository: ${sourceRevision.webBase}`);
2156
+ }
2021
2157
  if (latestDesignVersion) {
2022
2158
  // Quote the label — it's user-authored and may contain YAML-significant
2023
2159
  // characters (a colon in "v2: buttons" would otherwise break parsing).
@@ -2035,20 +2171,34 @@ latestDesignVersion) {
2035
2171
  lines.push("---");
2036
2172
  return lines.join("\n");
2037
2173
  }
2038
- export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
2174
+ export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo,
2175
+ /** Stable web URL for a code path (pinned to a commit), when one can be resolved */
2176
+ sourceUrl,
2177
+ /** Directory absolute paths are shown relative to */
2178
+ sourceRoot) {
2039
2179
  const lines = [
2040
2180
  `# ${componentName}`,
2041
2181
  "",
2042
2182
  ];
2043
- // Build links line
2183
+ // Never publish an absolute local path (it discloses the author's username and
2184
+ // directory layout) — show it relative to the repo when known, else the file name.
2185
+ const shown = (p) => displayPath(p, sourceRoot);
2186
+ // Build links line. A raw repo-relative path is NOT a link — it resolves to
2187
+ // nothing once the page is published anywhere else — so link only what
2188
+ // resolves to a stable URL, and show anything else as a path.
2044
2189
  const links = [`**[Open in Figma](${fileUrl})**`];
2045
2190
  if (codeInfo?.filePath) {
2046
- links.push(`**[View Source](${codeInfo.filePath})**`);
2191
+ const url = sourceUrl?.(codeInfo.filePath);
2192
+ links.push(url ? `**[View Source](${url})**` : `Source: \`${shown(codeInfo.filePath)}\``);
2047
2193
  }
2048
- // Add Storybook link if stories file exists in sourceFiles
2194
+ if (codeInfo?.storybookUrl) {
2195
+ links.push(`**[Storybook](${codeInfo.storybookUrl})**`);
2196
+ }
2197
+ // A stories FILE is the stories' source, not a running Storybook — say so
2049
2198
  const storiesFile = codeInfo?.sourceFiles?.find((f) => f.role.toLowerCase().includes("storybook") || f.role.toLowerCase().includes("stories") || f.path.includes(".stories."));
2050
2199
  if (storiesFile) {
2051
- links.push(`**[Storybook](${storiesFile.path})**`);
2200
+ const url = sourceUrl?.(storiesFile.path);
2201
+ links.push(url ? `**[Stories source](${url})**` : `Stories source: \`${shown(storiesFile.path)}\``);
2052
2202
  }
2053
2203
  lines.push(links.join(" | "));
2054
2204
  lines.push("");
@@ -2144,6 +2294,24 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
2144
2294
  const lines = ["", "## Variants", ""];
2145
2295
  const variants = [];
2146
2296
  const booleans = [];
2297
+ // Which layers each boolean property shows/hides — read from the layers' own
2298
+ // componentPropertyReferences.visible. Nested instances are not descended
2299
+ // into: their references point at THEIR component's properties, not ours.
2300
+ const layersByBoolean = new Map();
2301
+ const collectVisibilityRefs = (n, isRoot) => {
2302
+ const ref = n?.componentPropertyReferences?.visible;
2303
+ if (!isRoot && typeof ref === "string") {
2304
+ if (!layersByBoolean.has(ref))
2305
+ layersByBoolean.set(ref, new Set());
2306
+ layersByBoolean.get(ref).add(n.name);
2307
+ }
2308
+ if (!isRoot && n?.type === "INSTANCE")
2309
+ return;
2310
+ for (const c of n?.children ?? [])
2311
+ collectVisibilityRefs(c, false);
2312
+ };
2313
+ for (const root of node.type === "COMPONENT_SET" ? node.children ?? [] : [node])
2314
+ collectVisibilityRefs(root, true);
2147
2315
  const textProps = [];
2148
2316
  const instanceSwaps = [];
2149
2317
  const slots = [];
@@ -2158,7 +2326,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
2158
2326
  });
2159
2327
  }
2160
2328
  else if (def.type === "BOOLEAN") {
2161
- booleans.push({ name, defaultValue: def.defaultValue ?? true });
2329
+ booleans.push({ name, defaultValue: def.defaultValue ?? true, controls: [...(layersByBoolean.get(rawName) ?? [])] });
2162
2330
  }
2163
2331
  else if (def.type === "TEXT") {
2164
2332
  textProps.push({ name, defaultValue: def.defaultValue || "" });
@@ -2257,7 +2425,12 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
2257
2425
  lines.push(`| **${v.name}** | \`${v.values.map((val) => `"${val}"`).join(" \\| ")}\` | \`"${v.defaultValue}"\` | Changes visual treatment |`);
2258
2426
  }
2259
2427
  for (const b of booleans) {
2260
- lines.push(`| **${b.name}** | \`boolean\` | \`${b.defaultValue}\` | Shows/hides ${b.name.toLowerCase()} element |`);
2428
+ // Describe the property by the layer it actually controls; never invent an
2429
+ // element from the property's name ("Is Focused" → "is focused element")
2430
+ const what = b.controls.length > 0
2431
+ ? `Shows/hides ${b.controls.map((l) => `**${l}**`).join(", ")}`
2432
+ : "Boolean toggle";
2433
+ lines.push(`| **${b.name}** | \`boolean\` | \`${b.defaultValue}\` | ${what} |`);
2261
2434
  }
2262
2435
  for (const t of textProps) {
2263
2436
  lines.push(`| **${t.name}** | \`string\` | \`"${t.defaultValue}"\` | Sets ${t.name.toLowerCase()} content |`);
@@ -2368,7 +2541,8 @@ setNode) {
2368
2541
  // Text layers are often auto-named after their content ("Lorem ipsum dolor…")
2369
2542
  const short = (name) => (name.length > 40 ? `${name.slice(0, 40)}…` : name);
2370
2543
  const pushRow = (label, c) => {
2371
- const row = `| ${label}${c.hidden ? " _(hidden layer)_" : ""} | ${tokenCell(c)} | ${c.hex} |`;
2544
+ const visibility = !c.hidden ? "" : c.shownWhen ? ` _(hidden — shown when ${c.shownWhen} = true)_` : " _(hidden layer)_";
2545
+ const row = `| ${label}${visibility} | ${tokenCell(c)} | ${c.hex} |`;
2372
2546
  if (emitted.has(row))
2373
2547
  return;
2374
2548
  emitted.add(row);
@@ -2388,8 +2562,18 @@ setNode) {
2388
2562
  for (const c of vd.iconColors)
2389
2563
  pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
2390
2564
  // Text colors
2391
- for (const text of vd.textColors)
2392
- pushRow(`Text (${short(text.nodeName)})`, text);
2565
+ // Same-named text layers with DIFFERENT colors (a selected and an unselected
2566
+ // tab's Label) are told apart by the nested instance they live in
2567
+ const colorsByName = new Map();
2568
+ for (const t of vd.textColors) {
2569
+ if (!colorsByName.has(t.nodeName))
2570
+ colorsByName.set(t.nodeName, new Set());
2571
+ colorsByName.get(t.nodeName).add(`${t.hex}|${t.variableName ?? ""}`);
2572
+ }
2573
+ for (const text of vd.textColors) {
2574
+ const ambiguous = (colorsByName.get(text.nodeName)?.size ?? 0) > 1 && text.owner;
2575
+ pushRow(`Text (${short(text.nodeName)}${ambiguous ? ` in ${text.owner}` : ""})`, text);
2576
+ }
2393
2577
  // Strokes — through the same writer, so a visible underline and a hidden
2394
2578
  // focus ring sharing one color don't print as two indistinguishable rows
2395
2579
  for (const stroke of vd.strokes)
@@ -2492,7 +2676,7 @@ setNode) {
2492
2676
  }
2493
2677
  return lines.join("\n");
2494
2678
  }
2495
- function generateImplementationSection(codeInfo) {
2679
+ function generateImplementationSection(codeInfo, sourceRoot) {
2496
2680
  if (!codeInfo)
2497
2681
  return "";
2498
2682
  const lines = ["", "## Implementation", ""];
@@ -2503,7 +2687,7 @@ function generateImplementationSection(codeInfo) {
2503
2687
  lines.push("| File | Role | Variants |");
2504
2688
  lines.push("|------|------|----------|");
2505
2689
  for (const sf of codeInfo.sourceFiles) {
2506
- lines.push(`| \`${sf.path}\` | ${sf.role} | ${sf.variants ?? "—"} |`);
2690
+ lines.push(`| \`${displayPath(sf.path, sourceRoot)}\` | ${sf.role} | ${sf.variants ?? "—"} |`);
2507
2691
  }
2508
2692
  lines.push("");
2509
2693
  }
@@ -2820,6 +3004,9 @@ export function generateTypographySection(node) {
2820
3004
  if (seen.has(key))
2821
3005
  continue;
2822
3006
  seen.add(key);
3007
+ const sameName = textStyles.filter((o) => o.nodeName === ts.nodeName && `${o.fontFamily}:${o.fontWeight}:${o.fontSize}:${o.lineHeight}` !== `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}`);
3008
+ if (sameName.length > 0 && ts.owner)
3009
+ ts.nodeName = `${ts.nodeName} in ${ts.owner}`;
2823
3010
  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`} |`);
2824
3011
  }
2825
3012
  lines.push("");
@@ -2846,63 +3033,122 @@ function generateContentGuidelinesSection(parsedDesc) {
2846
3033
  return lines.join("\n");
2847
3034
  }
2848
3035
  export function generateParitySection(node, codeInfo) {
2849
- const lines = ["", "## Design-Code Parity", ""];
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".)
3036
+ // Names match ignoring case/punctuation and a leading is/has, so Figma's
3037
+ // "Is Selected" meets code's `isSelected` / `selected`.
2854
3038
  const normalize = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
3039
+ const keysFor = (name) => {
3040
+ const n = normalize(name);
3041
+ const stripped = n.replace(/^(is|has)(?=[a-z0-9])/, "");
3042
+ return stripped && stripped !== n ? [n, stripped] : [n];
3043
+ };
3044
+ const BOOL = /^(true|false)$/i;
2855
3045
  const figmaProps = new Map();
2856
3046
  if (node.type === "COMPONENT_SET" && node.children) {
2857
3047
  for (const child of node.children) {
2858
3048
  for (const [propName, value] of parseVariantProperties(child.name || "")) {
2859
3049
  const key = normalize(propName);
2860
3050
  if (!figmaProps.has(key))
2861
- figmaProps.set(key, { name: propName, values: new Map() });
3051
+ figmaProps.set(key, { name: propName, kind: "values", values: new Map() });
2862
3052
  figmaProps.get(key).values.set(value.toLowerCase(), value);
2863
3053
  }
2864
3054
  }
2865
3055
  }
2866
- // Code props whose type is a union of string literals: "sm" | "md" | 'lg'
2867
- const codeProps = new Map();
3056
+ // A variant property whose only values are True/False is a boolean in all but name
3057
+ for (const p of figmaProps.values()) {
3058
+ if (p.values.size > 0 && [...p.values.keys()].every((v) => BOOL.test(v)))
3059
+ p.kind = "boolean";
3060
+ }
3061
+ // BOOLEAN component properties (show/hide toggles) are booleans too
3062
+ for (const [rawName, def] of Object.entries(node.componentPropertyDefinitions ?? {})) {
3063
+ if (def?.type !== "BOOLEAN")
3064
+ continue;
3065
+ const name = stripPropertyIdSuffix(rawName);
3066
+ if (!figmaProps.has(normalize(name)))
3067
+ figmaProps.set(normalize(name), { name, kind: "boolean", values: new Map() });
3068
+ }
3069
+ const codeProps = [];
2868
3070
  for (const prop of codeInfo.props ?? []) {
2869
- const literals = prop.type?.match(/["']([^"']+)["']/g);
2870
- if (!literals)
3071
+ const type = prop.type ?? "";
3072
+ const literals = type.match(/["']([^"']+)["']/g);
3073
+ const isBoolean = /^\s*boolean\s*(\|\s*(undefined|null)\s*)*$/i.test(type)
3074
+ || /^\s*(true\s*\|\s*false|false\s*\|\s*true)\s*(\|\s*(undefined|null)\s*)*$/i.test(type);
3075
+ if (isBoolean) {
3076
+ codeProps.push({ name: prop.name, kind: "boolean", values: new Map() });
3077
+ }
3078
+ else if (literals) {
3079
+ const values = new Map();
3080
+ for (const m of literals) {
3081
+ const raw = m.replace(/["']/g, "");
3082
+ values.set(raw.toLowerCase(), raw);
3083
+ }
3084
+ codeProps.push({ name: prop.name, kind: "values", values });
3085
+ }
3086
+ }
3087
+ // Pair each Figma property with at most one code prop, exact name first
3088
+ const pairs = [];
3089
+ const usedCode = new Set();
3090
+ for (const fp of figmaProps.values()) {
3091
+ const fKeys = keysFor(fp.name);
3092
+ const match = codeProps.find((cp) => !usedCode.has(cp) && normalize(cp.name) === fKeys[0])
3093
+ ?? codeProps.find((cp) => !usedCode.has(cp) && keysFor(cp.name).some((k) => fKeys.includes(k)));
3094
+ if (match) {
3095
+ usedCode.add(match);
3096
+ pairs.push({ figma: fp, code: match });
3097
+ }
3098
+ }
3099
+ const unmatchedFigma = [...figmaProps.values()].filter((fp) => !pairs.some((p) => p.figma === fp));
3100
+ const unmatchedCode = codeProps.filter((cp) => !usedCode.has(cp));
3101
+ // Long-standing behavior: a "variant" prop on one side is compared even with no counterpart
3102
+ for (const fp of unmatchedFigma.filter((p) => normalize(p.name) === "variant" && p.kind === "values"))
3103
+ pairs.push({ figma: fp });
3104
+ for (const cp of unmatchedCode.filter((p) => normalize(p.name) === "variant" && p.kind === "values"))
3105
+ pairs.push({ code: cp });
3106
+ const rows = [];
3107
+ const labelProperty = pairs.length > 1 || (pairs[0] && normalize((pairs[0].figma ?? pairs[0].code).name) !== "variant");
3108
+ for (const { figma, code } of pairs) {
3109
+ const propLabel = figma?.name || code?.name || "";
3110
+ const bothBoolean = figma?.kind === "boolean" && code?.kind === "boolean";
3111
+ if (bothBoolean) {
3112
+ rows.push(`| ${figma.name} ↔ \`${code.name}\` | Yes | Yes | In sync (boolean) |`);
2871
3113
  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";
2887
- lines.push("### Variant Coverage");
2888
- lines.push("");
3114
+ }
3115
+ if (figma && code && figma.kind !== code.kind && !(figma.values.size > 0 && code.values.size > 0)) {
3116
+ rows.push(`| ${figma.name} ↔ \`${code.name}\` | Yes | Yes | **Type mismatch** — Figma ${figma.kind === "boolean" ? "True/False" : "named values"}, code \`${codeInfo.props?.find((p) => p.name === code.name)?.type ?? code.kind}\` |`);
3117
+ continue;
3118
+ }
3119
+ const allValues = new Set([...(figma?.values.keys() ?? []), ...(code?.values.keys() ?? [])]);
3120
+ for (const valueKey of allValues) {
3121
+ const inFigma = figma?.values.has(valueKey) ?? false;
3122
+ const inCode = code?.values.has(valueKey) ?? false;
3123
+ const display = figma?.values.get(valueKey) || code?.values.get(valueKey) || valueKey;
3124
+ const status = inFigma && inCode
3125
+ ? "In sync"
3126
+ : inFigma ? "Figma-only — needs code variant" : "Code-only — needs Figma variant";
3127
+ rows.push(`| ${labelProperty ? `${propLabel}: ${display}` : display} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
3128
+ }
3129
+ }
3130
+ const leftoverFigma = unmatchedFigma.filter((p) => !pairs.some((x) => x.figma === p));
3131
+ const leftoverCode = unmatchedCode.filter((p) => !pairs.some((x) => x.code === p));
3132
+ // Nothing on either side that could be compared: leave the section out rather
3133
+ // than print a heading with nothing under it.
3134
+ if (rows.length === 0 && leftoverFigma.length === 0 && leftoverCode.length === 0)
3135
+ return "";
3136
+ const lines = ["", "## Design-Code Parity", ""];
3137
+ if (rows.length > 0) {
3138
+ lines.push("### Variant Coverage", "");
2889
3139
  lines.push("| Variant | In Figma | In Code | Status |");
2890
3140
  lines.push("|---------|----------|---------|--------|");
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} |`);
2904
- }
2905
- }
3141
+ lines.push(...rows, "");
3142
+ }
3143
+ else {
3144
+ lines.push("_No Figma property matched a code prop by name and type, so nothing was compared._", "");
3145
+ }
3146
+ if (leftoverFigma.length > 0 || leftoverCode.length > 0) {
3147
+ lines.push("**Not compared** — no counterpart on the other side:", "");
3148
+ if (leftoverFigma.length > 0)
3149
+ lines.push(`- Figma: ${leftoverFigma.map((p) => `${p.name} (${p.kind === "boolean" ? "True/False" : [...p.values.values()].join(" | ")})`).join(", ")}`);
3150
+ if (leftoverCode.length > 0)
3151
+ lines.push(`- Code: ${leftoverCode.map((p) => `\`${p.name}\``).join(", ")}`);
2906
3152
  lines.push("");
2907
3153
  }
2908
3154
  return lines.join("\n");
@@ -3042,6 +3288,7 @@ const codeDocInfoSchema = z.object({
3042
3288
  changes: z.string(),
3043
3289
  })).optional().describe("Changelog entries"),
3044
3290
  filePath: z.string().optional().describe("Component file path"),
3291
+ storybookUrl: z.string().optional().describe("URL of a running Storybook (or the component's story) to link from the doc. Without it, a stories file in sourceFiles is linked as \"Stories source\", not \"Storybook\"."),
3045
3292
  packageName: z.string().optional().describe("Package name"),
3046
3293
  variantDefinition: z.string().optional().describe("CVA or variant definition code block"),
3047
3294
  subComponents: z.array(z.object({
@@ -3566,6 +3813,30 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3566
3813
  // in frontmatter as design provenance — deliberately NOT merged into
3567
3814
  // the code-side semver `version` field, which means something else.
3568
3815
  const latestDesignVersion = designHistory?.entries[0] ?? null;
3816
+ // Pin the code side to a commit and build stable links (local mode only —
3817
+ // the Worker has no filesystem). Only files git actually TRACKS get links:
3818
+ // code read from an installed package (node_modules) or an unrelated
3819
+ // working directory is shown as a path, never as a guessed URL.
3820
+ let sourceRevision = null;
3821
+ const codePaths = [
3822
+ ...(codeInfo?.filePath ? [codeInfo.filePath] : []),
3823
+ ...(codeInfo?.sourceFiles?.map((f) => f.path) ?? []),
3824
+ ];
3825
+ // Which repo? An explicit history.repoPath, or the repo that CONTAINS an
3826
+ // absolute path. Never the server's own working directory: relative paths
3827
+ // resolved there could match a same-named file in an unrelated project
3828
+ // and pin the page to the wrong history.
3829
+ const firstAbsolute = codePaths.find((p) => isAbsolute(p));
3830
+ const repoPathForLinks = history?.repoPath ?? (firstAbsolute ? dirname(firstAbsolute) : undefined);
3831
+ if (!isRemoteMode && codePaths.length > 0 && repoPathForLinks) {
3832
+ sourceRevision = await resolveSourceRevision({ paths: codePaths, repoPath: repoPathForLinks }).catch(() => null);
3833
+ if (sourceRevision && sourceRevision.tracked.size === 0)
3834
+ sourceRevision = null;
3835
+ }
3836
+ const sourceUrl = (path) => {
3837
+ const rel = sourceRevision?.tracked.get(path);
3838
+ return rel && sourceRevision ? blobUrl(sourceRevision, rel) : null;
3839
+ };
3569
3840
  // Resolve sections with defaults
3570
3841
  const s = {
3571
3842
  overview: true,
@@ -3587,11 +3858,11 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3587
3858
  const includedSections = [];
3588
3859
  if (includeFrontmatter) {
3589
3860
  const atomicLevel = await detectAtomicLevel(api, fileKey, nodeId, setInfo.setNodeId, componentMeta, allComponentsMeta);
3590
- parts.push(generateFrontmatter(componentName, description, node, componentMeta, fileUrl_, codeInfo, canonicalSource, atomicLevel, latestDesignVersion));
3861
+ parts.push(generateFrontmatter(componentName, description, node, componentMeta, fileUrl_, codeInfo, canonicalSource, atomicLevel, latestDesignVersion, sourceRevision, repoPathForLinks));
3591
3862
  parts.push("");
3592
3863
  }
3593
3864
  if (s.overview) {
3594
- parts.push(generateOverviewSection(componentName, description, fileUrl_, parsedDesc, codeInfo));
3865
+ parts.push(generateOverviewSection(componentName, description, fileUrl_, parsedDesc, codeInfo, sourceUrl, repoPathForLinks));
3595
3866
  includedSections.push("overview");
3596
3867
  }
3597
3868
  if (s.anatomy) {
@@ -3627,7 +3898,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3627
3898
  }
3628
3899
  }
3629
3900
  if (s.implementation && codeInfo) {
3630
- parts.push(generateImplementationSection(codeInfo));
3901
+ parts.push(generateImplementationSection(codeInfo, repoPathForLinks ?? history?.repoPath));
3631
3902
  includedSections.push("implementation");
3632
3903
  }
3633
3904
  if (s.accessibility) {
@@ -3640,7 +3911,8 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3640
3911
  includedSections.push("designAnnotations");
3641
3912
  }
3642
3913
  if (s.parity && hasCodeInfo && hasFigmaData && codeInfo) {
3643
- const paritySection = generateParitySection(node, codeInfo);
3914
+ // nodeForVariants carries the SET's property definitions when a single variant was requested
3915
+ const paritySection = generateParitySection(nodeForVariants, codeInfo);
3644
3916
  if (paritySection) {
3645
3917
  parts.push(paritySection);
3646
3918
  includedSections.push("parity");
@@ -3656,6 +3928,8 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
3656
3928
  componentName,
3657
3929
  design: designHistory,
3658
3930
  git: gitHistory,
3931
+ commitLink: (sha) => (sourceRevision ? commitUrl(sourceRevision, sha) : null),
3932
+ showPath: (p) => displayPath(p, gitHistory?._meta.repo_root ?? repoPathForLinks),
3659
3933
  manual: codeInfo?.changelog ?? null,
3660
3934
  });
3661
3935
  if (historySection.trim()) {