inkbridge 0.1.0-beta.2 → 0.1.0-beta.21

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 (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -0,0 +1,1322 @@
1
+ import { resolvePortalAwareStoryTree, jsxTreeIsPortalTriggerOnly } from './portal-handling';
2
+ import { getThemeContext, BOARD_LAYOUT } from './theme-context';
3
+ import { getNonCvaSymbolFallbackReason } from './symbol-fallback';
4
+ import {
5
+ removeDirectTextChildren,
6
+ removeDuplicateChildrenByName,
7
+ hasNodeChildren,
8
+ } from './frame-utils';
9
+ import {
10
+ ENABLE_SYMBOL_MASTERS,
11
+ findExistingComponentLibraryRoot,
12
+ shouldCreateNonCvaMaster,
13
+ } from './master-shared';
14
+ import {
15
+ shouldCreateStateMaster,
16
+ } from './state-master';
17
+ import { shouldCreateCvaMaster } from './cva-master';
18
+ import {
19
+ normalizeComponentName,
20
+ } from './story-tree-search';
21
+ import { warmSymbolMasters } from './non-cva-master';
22
+ import {
23
+ componentInstanceBackend,
24
+ createCVAStoryInstance,
25
+ createStateStoryInstance,
26
+ createSimpleStoryInstance,
27
+ } from './story-instance';
28
+ // External modules.
29
+ import {
30
+ COMPONENT_DEFS,
31
+ TOKENS,
32
+ debug,
33
+ extractTextColorToken,
34
+ getThemeNames,
35
+ isMultiModeEnabled,
36
+ resolveTextColorValue,
37
+ setThemeMode,
38
+ } from '../tokens';
39
+ import {
40
+ applyAspectRatioIfPossible,
41
+ applyFullWidthIfPossible,
42
+ applyRingIfPossible,
43
+ applyVerticalFlexGrowIfPossible,
44
+ enforceGrowChildPrimaryFixed,
45
+ extractGridColumns,
46
+ extractMaxWidth,
47
+ reapplyRightPositionedChildren,
48
+ reflowDeferredAbsolutePositioningTree,
49
+ shouldSkipFullWidthForClasses,
50
+ } from '../layout';
51
+ import {
52
+ extractRootGridColsFromTree,
53
+ propagateChildSelectorClasses,
54
+ splitClassName,
55
+ tailwindClassesToStyle,
56
+ type JsxElement,
57
+ } from '../tailwind';
58
+ import {
59
+ createCompoundComponent,
60
+ tryCreateNonCvaComponentInstance as tryCreateNonCvaComponentInstanceShared,
61
+ type ComponentDef,
62
+ type ComponentStory,
63
+ type LayoutInfo,
64
+ } from '../components';
65
+ import { createTextNode } from '../text';
66
+ import { findChildByName, findChildIndexByName, getFrameHash, hashDef, hashString, setFrameHash } from '../cache';
67
+ import { RENDER_ENGINE_VERSION } from '../render-engine-version';
68
+ import { getActivePack } from '../plugin';
69
+
70
+ // Sibling design-system modules.
71
+ import { normalizeLayoutClasses } from './story-layout';
72
+ import { isGeneratedDesignSystemNode, setGeneratedFallbackReason, tagGeneratedNode } from './generated-node';
73
+ import { type StoryBuilderContext, type StoryRenderContext } from './story-builder-context';
74
+ import { createStatePreviewBlock, createResponsivePreviewBlock, shouldRenderStatesForStory } from './preview-builder';
75
+ import {
76
+ getComponentSectionName,
77
+ groupComponentDefs,
78
+ inferComponentSection,
79
+ } from './component-sections';
80
+ import {
81
+ blockHashMatchesCurrent,
82
+ findComponentBlocksInPage,
83
+ normalizeForPreflight,
84
+ } from './block-cache';
85
+ import {
86
+ resolveStoryLayoutHeight,
87
+ resolveStoryLayoutWidth,
88
+ } from './story-dimensioning';
89
+ import {
90
+ INSTANCE_FALLBACK_COMPONENTS,
91
+ shouldPreferInstanceRendering,
92
+ shouldSkipStoryJsxTree,
93
+ } from './story-render-strategy';
94
+ import { setStoryRenderDiagnostics } from './story-diagnostics';
95
+ import { type StatusUpdate } from './design-system';
96
+ import {
97
+ appendResolvedInstance,
98
+ renderGuardedPortalStoryInstances,
99
+ renderStoryInstances,
100
+ } from './instance-rendering';
101
+
102
+ // Per-component + per-phase build timings to the Figma plugin console.
103
+ // Off by default for end users — flip to `true` locally when you need to
104
+ // see where build time goes during a "Generate Design System Page" run.
105
+ // The loading-panel status messages are always emitted via `onStatus`;
106
+ // this flag only gates the extra `console.log` lines that include
107
+ // elapsed-seconds + per-component millisecond timings.
108
+ const INKBRIDGE_PERF_LOGS = false;
109
+
110
+ function ensureHeaderBlock(
111
+ parent: FrameNode,
112
+ frameName: string,
113
+ title: string,
114
+ description: string | null,
115
+ opts?: { titleSize?: number; titleLineHeight?: number; descriptionSize?: number }
116
+ ): FrameNode {
117
+ removeDuplicateChildrenByName(parent, frameName, 'FRAME');
118
+ let frame = findChildByName(parent, frameName);
119
+ if (!frame) {
120
+ frame = figma.createFrame();
121
+ frame.name = frameName;
122
+ }
123
+
124
+ frame.layoutMode = 'VERTICAL';
125
+ frame.primaryAxisSizingMode = 'AUTO';
126
+ frame.counterAxisSizingMode = 'AUTO';
127
+ frame.counterAxisAlignItems = 'MIN';
128
+ frame.itemSpacing = description ? 10 : 0;
129
+ frame.fills = [];
130
+
131
+ const titleNode = createTextNode(title, {
132
+ fontSize: opts && opts.titleSize ? opts.titleSize : 20,
133
+ lineHeight: opts && opts.titleLineHeight ? opts.titleLineHeight : undefined,
134
+ bold: true,
135
+ });
136
+ titleNode.name = 'Title';
137
+
138
+ frame.children.slice().forEach(function(child: SceneNode) { child.remove(); });
139
+ frame.appendChild(titleNode);
140
+
141
+ if (description) {
142
+ const descriptionNode = createTextNode(description, {
143
+ fontSize: opts && opts.descriptionSize ? opts.descriptionSize : 14,
144
+ lineHeight: 20,
145
+ opacity: 0.62,
146
+ });
147
+ descriptionNode.name = 'Description';
148
+ frame.appendChild(descriptionNode);
149
+ }
150
+
151
+ return frame;
152
+ }
153
+
154
+ export function pruneGeneratedComponentLibrary(themeNames: string[], ctx: StoryBuilderContext): void {
155
+ if (!ENABLE_SYMBOL_MASTERS) return;
156
+ const root = findExistingComponentLibraryRoot();
157
+ if (!root || !Array.isArray(root.children)) return;
158
+
159
+ const requestedThemes = Array.isArray(themeNames)
160
+ ? themeNames
161
+ .filter(function(theme: string) { return typeof theme === 'string' && theme.trim().length > 0; })
162
+ .map(function(theme: string) { return theme.trim(); })
163
+ : [];
164
+ const themeSet: Record<string, boolean> = {};
165
+ for (let i = 0; i < requestedThemes.length; i++) themeSet[requestedThemes[i]] = true;
166
+
167
+ const defsRaw = (COMPONENT_DEFS && Array.isArray(COMPONENT_DEFS.components)) ? COMPONENT_DEFS.components : [];
168
+ const defs = defsRaw
169
+ .map(ctx.normalizeComponentDef)
170
+ .filter(function(def: ComponentDef) {
171
+ return !!def && Array.isArray(def.stories) && def.stories.length > 0;
172
+ });
173
+
174
+ const expectedByTheme: Record<string, Record<string, boolean>> = {};
175
+ for (let i = 0; i < requestedThemes.length; i++) {
176
+ expectedByTheme[requestedThemes[i]] = {};
177
+ }
178
+
179
+ for (let i = 0; i < defs.length; i++) {
180
+ const def = defs[i];
181
+ for (let ti = 0; ti < requestedThemes.length; ti++) {
182
+ const theme = requestedThemes[ti];
183
+ if (shouldCreateCvaMaster(def) || shouldCreateStateMaster(def) || shouldCreateNonCvaMaster(def)) {
184
+ expectedByTheme[theme][def.name + ' [' + theme + ']'] = true;
185
+ }
186
+ }
187
+ }
188
+
189
+ const rootChildren = root.children.slice();
190
+ const seenThemeFrames: Record<string, boolean> = {};
191
+ for (let i = 0; i < rootChildren.length; i++) {
192
+ const child = rootChildren[i];
193
+ if (!child || child.type !== 'FRAME') continue;
194
+ const name = String(child.name || '');
195
+ if (!name.endsWith(' Masters')) continue;
196
+
197
+ if (seenThemeFrames[name]) {
198
+ if (isGeneratedDesignSystemNode(child)) child.remove();
199
+ continue;
200
+ }
201
+ seenThemeFrames[name] = true;
202
+
203
+ const theme = name.slice(0, -' Masters'.length);
204
+ if (!themeSet[theme]) {
205
+ if (isGeneratedDesignSystemNode(child)) child.remove();
206
+ continue;
207
+ }
208
+
209
+ const expectedNames = expectedByTheme[theme] || {};
210
+ if (!Array.isArray(child.children)) continue;
211
+
212
+ const childMasters = child.children.slice();
213
+ const seenMasterNames: Record<string, boolean> = {};
214
+ for (let mi = 0; mi < childMasters.length; mi++) {
215
+ const master = childMasters[mi];
216
+ if (!master) continue;
217
+ const masterName = String(master.name || '');
218
+ const isExpected = !!expectedNames[masterName];
219
+ const isGenerated = isGeneratedDesignSystemNode(master) || masterName.endsWith(' [' + theme + ']');
220
+
221
+ if (seenMasterNames[masterName]) {
222
+ if (isGenerated) master.remove();
223
+ continue;
224
+ }
225
+ seenMasterNames[masterName] = true;
226
+
227
+ if (!isExpected && isGenerated) {
228
+ master.remove();
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+
235
+ function appendTypeDefaultPreview(
236
+ layout: LayoutInfo,
237
+ def: ComponentDef,
238
+ story: ComponentStory,
239
+ theme: string,
240
+ ctx: StoryBuilderContext
241
+ ): boolean {
242
+ if (!def) return false;
243
+ if (def.type === 'compound') {
244
+ const symbolInstance = tryCreateNonCvaComponentInstanceShared(def, { props: {} }, theme, ctx, componentInstanceBackend, story);
245
+ if (symbolInstance) {
246
+ layout.appendChild(symbolInstance);
247
+ } else {
248
+ const comp = createCompoundComponent(layout, def, theme);
249
+ if (comp) {
250
+ setGeneratedFallbackReason(comp, getNonCvaSymbolFallbackReason(def, { props: {} }));
251
+ }
252
+ }
253
+ return true;
254
+ }
255
+ if (def.type === 'simple') {
256
+ layout.appendChild(createSimpleStoryInstance(def, { props: {} }, theme, ctx, story));
257
+ return true;
258
+ }
259
+ if (def.type === 'cva') {
260
+ layout.appendChild(createCVAStoryInstance(def, { props: {} }, theme, ctx));
261
+ return true;
262
+ }
263
+ if (def.type === 'state') {
264
+ layout.appendChild(createStateStoryInstance(def, { props: {} }, theme, ctx, story));
265
+ return true;
266
+ }
267
+ return false;
268
+ }
269
+
270
+ function populateStoryLayout(
271
+ layout: LayoutInfo,
272
+ def: ComponentDef | null,
273
+ story: ComponentStory,
274
+ theme: string,
275
+ colorGroup: Record<string, string>,
276
+ radiusGroup: Record<string, string> | null,
277
+ ctx: StoryBuilderContext,
278
+ effectiveWidth: number,
279
+ opts?: { viewportWidth?: number; allowTypeDefaultFallback?: boolean }
280
+ ): { added: number; renderMode: string; useStoryTree: boolean } {
281
+ const layoutClasses = normalizeLayoutClasses(story.layoutClasses);
282
+ const skipLayoutFullWidth = shouldSkipFullWidthForClasses(layoutClasses);
283
+ const layoutGridCols = extractGridColumns(layoutClasses);
284
+ const allowAbsolute = ctx.hasExplicitHeight(layoutClasses);
285
+ // Story-level viewport width — passed down via context so deeper
286
+ // elements resolve responsive breakpoints against the viewport (matches
287
+ // CSS @media), not against their container's narrowed maxWidth.
288
+ const storyViewportWidth = opts?.viewportWidth != null && opts.viewportWidth > 0
289
+ ? opts.viewportWidth
290
+ : (Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : undefined);
291
+ // When the tree has both a trigger element AND portal-unwrapped content:
292
+ // - closed-state stories (Default): portal is NOT rendered → no __fromPortal nodes → tree unchanged
293
+ // - open-state stories (OpenPanel): portal IS rendered → extract only the portal content so
294
+ // the popup panel renders in Figma rather than the trigger button
295
+ // When the tree has only a trigger (no __fromPortal): render as-is (trigger button).
296
+ // Three cases for stories that have both a trigger and portal content:
297
+ // 1. Open-state (defaultOpen=true): extract portal content → show popup in Figma
298
+ // 2. Closed-state (no defaultOpen, Radix SSR renders portal anyway): filter portal out → show trigger
299
+ // 3. No portal content at all (base-ui closed state): show tree as-is → shows trigger
300
+ const rawJsxTree = story.jsxTree
301
+ ? resolvePortalAwareStoryTree(story.jsxTree)
302
+ : null;
303
+ // Normalize child-wildcard selector utilities (e.g. *:w-full sm:*:w-auto) into
304
+ // explicit per-child classes before rendering. This keeps main story renders and
305
+ // responsive preview renders behaviorally identical.
306
+ const effectiveJsxTree = rawJsxTree
307
+ ? propagateChildSelectorClasses(rawJsxTree)
308
+ : null;
309
+ const useStoryTree = !!effectiveJsxTree
310
+ && !shouldPreferInstanceRendering(def, story)
311
+ && !shouldSkipStoryJsxTree(def, story);
312
+ // The scanner extracts `story.layoutClasses` from the root JSX node's
313
+ // className regardless of whether the root is a plain <div> wrapper or a
314
+ // real component (`<ScrollArea className="rounded-md border bg-background
315
+ // h-48 w-60 p-3">…`). For div roots the unwrap path below replaces the
316
+ // wrapper with the bench, so applying the visual classes to the bench
317
+ // matches the consumer's intent. For component roots the component
318
+ // renders ITS OWN visual styling — so leaving the same `rounded-md border
319
+ // bg-background` on the bench paints a phantom rounded rectangle behind
320
+ // the real component (the symptom: two overlapping bordered boxes, the
321
+ // bench's padding shifting the component out of register). Reset the
322
+ // bench's visual styling here when we detect this overlap; the bench's
323
+ // sizing was already pinned by resolveStoryLayoutWidth via the layout
324
+ // classes, so the responsive math is unaffected.
325
+ const rootIsOverpaintingComponent =
326
+ !!effectiveJsxTree
327
+ && (effectiveJsxTree as JsxElement).type === 'element'
328
+ && !!(effectiveJsxTree as JsxElement).isComponent
329
+ && layoutClasses.length > 0
330
+ && (() => {
331
+ const rootClasses = splitClassName(
332
+ (effectiveJsxTree as JsxElement).props && (effectiveJsxTree as JsxElement).props.className
333
+ );
334
+ if (rootClasses.length === 0) return false;
335
+ const set: Record<string, true> = {};
336
+ for (const c of rootClasses) set[c] = true;
337
+ for (const c of layoutClasses) if (!set[c]) return false;
338
+ return true;
339
+ })();
340
+ if (rootIsOverpaintingComponent) {
341
+ try { layout.fills = []; } catch { /* ignore */ }
342
+ try { layout.strokes = []; } catch { /* ignore */ }
343
+ try { layout.cornerRadius = 0; } catch { /* ignore */ }
344
+ try {
345
+ layout.paddingLeft = 0;
346
+ layout.paddingRight = 0;
347
+ layout.paddingTop = 0;
348
+ layout.paddingBottom = 0;
349
+ } catch { /* ignore */ }
350
+ }
351
+ const layoutPadding = (layout.paddingLeft || 0) + (layout.paddingRight || 0);
352
+ const contentWidth = effectiveWidth ? effectiveWidth - layoutPadding : undefined;
353
+ let added = 0;
354
+ let renderMode = 'none';
355
+
356
+ if (useStoryTree) {
357
+ let rendered = false;
358
+ const rootNode = effectiveJsxTree as JsxElement | null;
359
+ if (layoutClasses.length > 0 && rootNode && rootNode.type === 'element' && rootNode.tagName === 'div') {
360
+ const rootClasses = splitClassName(rootNode.props && rootNode.props.className);
361
+ const sameClasses = rootClasses.length > 0 && rootClasses.join(' ') === layoutClasses.join(' ');
362
+ if (sameClasses) {
363
+ const rootStyle = tailwindClassesToStyle(rootClasses, 'default', colorGroup);
364
+ const rootTextToken = rootStyle.textToken || extractTextColorToken(rootClasses) || undefined;
365
+ const rootTextColor = resolveTextColorValue(rootStyle.text, rootTextToken, colorGroup, theme) || undefined;
366
+ let gridChildWidth: number | undefined;
367
+ const rootGridCols = extractGridColumns(rootClasses, contentWidth);
368
+ const gridColsForChildren = layoutGridCols || rootGridCols;
369
+ if (gridColsForChildren && contentWidth != null && contentWidth > 0) {
370
+ const gap = layout.itemSpacing || 0;
371
+ const available = contentWidth - gap * Math.max(0, gridColsForChildren - 1);
372
+ if (available > 0) {
373
+ gridChildWidth = Math.max(0, available / gridColsForChildren);
374
+ }
375
+ }
376
+ const rootContext: StoryRenderContext = {
377
+ textColor: rootTextColor,
378
+ textColorToken: rootTextToken,
379
+ parentLayout: layout.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
380
+ maxWidth: gridChildWidth != null ? gridChildWidth : contentWidth,
381
+ viewportWidth: storyViewportWidth,
382
+ };
383
+ for (const child of rootNode.children || []) {
384
+ const childNode = ctx.renderJsxTree(child, colorGroup, radiusGroup, theme, 0, rootContext);
385
+ if (!childNode) continue;
386
+ layout.appendChild(childNode);
387
+ ctx.applyAbsoluteIfAllowed(childNode, layout, allowAbsolute);
388
+ const fullWidthOptions = (skipLayoutFullWidth || contentWidth != null)
389
+ ? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
390
+ : undefined;
391
+ applyFullWidthIfPossible(childNode, layout, fullWidthOptions);
392
+ applyRingIfPossible(childNode, layout);
393
+ applyAspectRatioIfPossible(childNode);
394
+ enforceGrowChildPrimaryFixed(childNode, layout);
395
+ const childRootGridCols = extractRootGridColsFromTree(child);
396
+ if ('layoutMode' in childNode && childRootGridCols != null) {
397
+ ctx.applyGridColumnsWithReflow(childNode as FrameNode, contentWidth);
398
+ }
399
+ rendered = true;
400
+ }
401
+ if (rendered) {
402
+ renderMode = 'tree-root-children';
403
+ added = 1;
404
+ }
405
+ }
406
+ }
407
+
408
+ // Transparent context-provider roots (Radix/Base UI *.Root, *.Provider) have
409
+ // no className or component def — they exist only to supply React context.
410
+ // When such a root has 2+ children (e.g. Trigger + Content in `defaultOpen`
411
+ // stories), rendering the wrapper adds an empty frame around the scene.
412
+ // Iterate its children directly into the Story Layout instead.
413
+ if (!rendered && rootNode && rootNode.type === 'element' && rootNode.isComponent
414
+ && (!rootNode.props || !rootNode.props.className)
415
+ && !ctx.getComponentDefByName(rootNode.tagName)
416
+ && Array.isArray(rootNode.children) && rootNode.children.length > 1) {
417
+ const rootContext: StoryRenderContext = {
418
+ parentLayout: layout.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
419
+ maxWidth: contentWidth,
420
+ viewportWidth: storyViewportWidth,
421
+ };
422
+ for (const child of rootNode.children) {
423
+ const childNode = ctx.renderJsxTree(child, colorGroup, radiusGroup, theme, 0, rootContext);
424
+ if (!childNode) continue;
425
+ layout.appendChild(childNode);
426
+ ctx.applyAbsoluteIfAllowed(childNode, layout, allowAbsolute);
427
+ const fullWidthOptions = (skipLayoutFullWidth || contentWidth != null)
428
+ ? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
429
+ : undefined;
430
+ applyFullWidthIfPossible(childNode, layout, fullWidthOptions);
431
+ applyRingIfPossible(childNode, layout);
432
+ applyAspectRatioIfPossible(childNode);
433
+ enforceGrowChildPrimaryFixed(childNode, layout);
434
+ rendered = true;
435
+ }
436
+ if (rendered) {
437
+ renderMode = 'tree-root-children-unwrapped';
438
+ added = 1;
439
+ }
440
+ }
441
+
442
+ if (!rendered) {
443
+ const treeContext: StoryRenderContext = {
444
+ maxWidth: contentWidth,
445
+ parentLayout: layout.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
446
+ viewportWidth: storyViewportWidth,
447
+ };
448
+ const treeNode = ctx.renderJsxTree(effectiveJsxTree, colorGroup, radiusGroup, theme, 0, treeContext);
449
+ if (treeNode) {
450
+ layout.appendChild(treeNode);
451
+ ctx.applyAbsoluteIfAllowed(treeNode, layout, allowAbsolute);
452
+ const fullWidthOptions = (skipLayoutFullWidth || contentWidth != null)
453
+ ? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
454
+ : undefined;
455
+ applyFullWidthIfPossible(treeNode, layout, fullWidthOptions);
456
+ applyRingIfPossible(treeNode, layout);
457
+ applyAspectRatioIfPossible(treeNode);
458
+ enforceGrowChildPrimaryFixed(treeNode, layout);
459
+ const treeRootGridCols = extractRootGridColsFromTree(effectiveJsxTree);
460
+ if ('layoutMode' in treeNode && treeRootGridCols != null) {
461
+ ctx.applyGridColumnsWithReflow(treeNode as FrameNode, contentWidth);
462
+ }
463
+
464
+ if (effectiveWidth && rootNode && rootNode.type === 'element') {
465
+ const rootClasses = splitClassName(rootNode.props && rootNode.props.className);
466
+ const wantsFullWidth = rootClasses.includes('w-full');
467
+ if (wantsFullWidth && 'resize' in treeNode) {
468
+ try {
469
+ // Respect max-w-* constraint: clamp to min(layout.width, max-width).
470
+ const maxWConstraint = extractMaxWidth(rootClasses);
471
+ const targetWidth = maxWConstraint != null ? Math.min(layout.width, maxWConstraint) : layout.width;
472
+ treeNode.resize(targetWidth, treeNode.height);
473
+ const treeLayout = ('layoutMode' in treeNode) ? treeNode.layoutMode : 'VERTICAL';
474
+ if (treeLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in treeNode) {
475
+ treeNode.primaryAxisSizingMode = 'FIXED';
476
+ } else if (treeLayout === 'VERTICAL' && 'counterAxisSizingMode' in treeNode) {
477
+ treeNode.counterAxisSizingMode = 'FIXED';
478
+ }
479
+ // Reapply right-anchored absolute children (e.g. DialogPrimitive.Close) after resize.
480
+ if (treeNode.type === 'FRAME') {
481
+ reapplyRightPositionedChildren(treeNode, targetWidth);
482
+ }
483
+ } catch (_err) {
484
+ // ignore resize errors
485
+ }
486
+ }
487
+ }
488
+ added = 1;
489
+ renderMode = 'tree-root';
490
+ }
491
+ }
492
+ }
493
+
494
+ // `applyGridColumnsIfPossible` guards on `cols <= 1` internally, so
495
+ // forwarding `layoutGridCols === 1` (the Tailwind default for plain
496
+ // `grid` with no `grid-cols-N`) is a no-op. The truthiness check
497
+ // here just avoids a function call when there's no grid at all.
498
+ if (layoutGridCols) {
499
+ ctx.applyGridColumnsWithReflow(layout, effectiveWidth || layout.width, layoutGridCols);
500
+ }
501
+
502
+ const layoutHasChildren = hasNodeChildren(layout);
503
+ const normalizedDefName = normalizeComponentName(def && def.name ? def.name : '');
504
+ const isGuardedPortalDef =
505
+ (normalizedDefName && INSTANCE_FALLBACK_COMPONENTS.has(normalizedDefName))
506
+ || jsxTreeIsPortalTriggerOnly(story && story.jsxTree);
507
+ if (added === 0 && !layoutHasChildren && isGuardedPortalDef) {
508
+ added += renderGuardedPortalStoryInstances(layout, def, story, theme, colorGroup, radiusGroup, ctx);
509
+ if (added > 0) {
510
+ renderMode = 'guarded-portal';
511
+ }
512
+ }
513
+
514
+ if (added === 0 && !layoutHasChildren && !useStoryTree) {
515
+ if (def) {
516
+ for (const instance of story.instances || []) {
517
+ if (!instance || !instance.componentName) continue;
518
+ const normalizedInstanceName = instance.componentName.toLowerCase().replace(/-/g, '');
519
+ const normalizedCurrentDefName = def.name.toLowerCase().replace(/-/g, '');
520
+ if (normalizedInstanceName !== normalizedCurrentDefName) continue;
521
+ if (isGuardedPortalDef) continue;
522
+ if (appendResolvedInstance(layout, def, instance, story, theme, colorGroup, radiusGroup, ctx)) {
523
+ added++;
524
+ }
525
+ }
526
+ } else {
527
+ added = renderStoryInstances(layout, story, theme, colorGroup, radiusGroup, ctx);
528
+ }
529
+ if (added > 0) {
530
+ renderMode = 'instances';
531
+ }
532
+ }
533
+
534
+ const finalLayoutChildren = layout.children;
535
+ const hasFinalLayoutChildren = !!finalLayoutChildren
536
+ && typeof finalLayoutChildren.length === 'number'
537
+ && finalLayoutChildren.length > 0;
538
+ if (added === 0 && !hasFinalLayoutChildren && opts?.allowTypeDefaultFallback && def) {
539
+ if (appendTypeDefaultPreview(layout, def, story, theme, ctx)) {
540
+ renderMode = 'type-default';
541
+ }
542
+ }
543
+
544
+ if (added === 0 && !hasNodeChildren(layout) && !opts?.allowTypeDefaultFallback) {
545
+ layout.appendChild(createTextNode(story.name || 'Story', { fontSize: 12, opacity: 0.6 }));
546
+ renderMode = 'placeholder';
547
+ }
548
+
549
+ // Distribute remaining vertical space among flex-1/grow children inside
550
+ // every viewport-anchored VERTICAL frame (Story Layout itself plus any
551
+ // nested fixed-height sub-frame). Runs before absolute-reflow so deferred
552
+ // positions settle against the final heights.
553
+ walkVerticalFlexGrow(layout);
554
+
555
+ // Final pass: resolve deferred absolute positioning after all story-level
556
+ // width/height/layout operations have completed.
557
+ reflowDeferredAbsolutePositioningTree(layout);
558
+
559
+ return { added, renderMode, useStoryTree };
560
+ }
561
+
562
+ function walkVerticalFlexGrow(node: SceneNode): void {
563
+ if (!node || typeof node !== 'object') return;
564
+ if (node.type === 'FRAME' && node.layoutMode === 'VERTICAL' && node.primaryAxisSizingMode === 'FIXED') {
565
+ applyVerticalFlexGrowIfPossible(node);
566
+ }
567
+ if (!('children' in node) || !Array.isArray(node.children)) return;
568
+ for (let i = 0; i < node.children.length; i++) walkVerticalFlexGrow(node.children[i]);
569
+ }
570
+
571
+ export function renderStandaloneStory(
572
+ story: ComponentStory,
573
+ theme: string,
574
+ ctx: StoryBuilderContext,
575
+ viewportWidth?: number
576
+ ): FrameNode {
577
+ const themeContext = getThemeContext(theme);
578
+ const colorGroup = themeContext.colorGroup;
579
+ const radiusGroup = themeContext.radiusGroup;
580
+ const layoutClasses = normalizeLayoutClasses(story.layoutClasses);
581
+
582
+ const layout = figma.createFrame();
583
+ layout.name = 'Story Layout';
584
+ layout.primaryAxisSizingMode = 'AUTO';
585
+ layout.counterAxisSizingMode = 'AUTO';
586
+ layout.fills = [];
587
+ ctx.applyClipBehavior(layout, layoutClasses);
588
+
589
+ ctx.applyLayoutClasses(layout, layoutClasses, colorGroup, radiusGroup, theme);
590
+ const effectiveWidth = resolveStoryLayoutWidth(story, layout, layoutClasses, ctx, viewportWidth);
591
+ resolveStoryLayoutHeight(story, layout, layoutClasses, ctx);
592
+ const result = populateStoryLayout(layout, null, story, theme, colorGroup, radiusGroup, ctx, effectiveWidth, {
593
+ viewportWidth,
594
+ allowTypeDefaultFallback: false,
595
+ });
596
+ expandStoryLayoutForPortalContent(layout);
597
+ setStoryRenderDiagnostics(layout, null, story, result.renderMode, result.added, result.useStoryTree);
598
+
599
+ return layout;
600
+ }
601
+
602
+ /**
603
+ * Portal panels (marked via `inkbridge:portal-panel` pluginData) visually escape
604
+ * their parent in the browser. In Figma we render them inside the Story Layout
605
+ * frame, so if the layout is narrower than the panel it clips. Walk the subtree,
606
+ * find portal panels, grow the layout to contain them.
607
+ */
608
+ function expandStoryLayoutForPortalContent(layout: FrameNode): void {
609
+ if (!layout || typeof layout.width !== 'number') return;
610
+ const layoutBox = layout.absoluteBoundingBox;
611
+ if (!layoutBox) return;
612
+ let requiredWidth = layout.width;
613
+ const stack: SceneNode[] = Array.isArray(layout.children) ? layout.children.slice() : [];
614
+ while (stack.length > 0) {
615
+ const node = stack.pop();
616
+ if (!node) continue;
617
+ try {
618
+ const tag = typeof node.getPluginData === 'function'
619
+ ? node.getPluginData('inkbridge:portal-panel')
620
+ : '';
621
+ if (tag === '1' && node.absoluteBoundingBox) {
622
+ const overflow = (node.absoluteBoundingBox.x + node.absoluteBoundingBox.width)
623
+ - (layoutBox.x + layoutBox.width);
624
+ if (overflow > 0) requiredWidth = Math.max(requiredWidth, layout.width + overflow);
625
+ }
626
+ } catch { /* ignore */ }
627
+ if ('children' in node && Array.isArray(node.children) && node.children.length > 0) {
628
+ for (const child of node.children) stack.push(child);
629
+ }
630
+ }
631
+ if (requiredWidth > layout.width) {
632
+ try {
633
+ layout.resize(requiredWidth, layout.height);
634
+ if (layout.layoutMode === 'HORIZONTAL' && 'primaryAxisSizingMode' in layout) {
635
+ layout.primaryAxisSizingMode = 'FIXED';
636
+ } else if ('counterAxisSizingMode' in layout) {
637
+ layout.counterAxisSizingMode = 'FIXED';
638
+ }
639
+ } catch { /* ignore */ }
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Build (or incrementally update) a UI components section on the Design System page.
645
+ *
646
+ * ## Incremental strategy
647
+ * The section frame and per-theme column frames are reused across runs — only
648
+ * their contents are updated. Inside each column, component blocks are diffed
649
+ * individually:
650
+ *
651
+ * blockHash = defHash + ":" + tokenHash
652
+ *
653
+ * - Hash match → block is left completely untouched (node ID preserved).
654
+ * - Hash change → block is removed and rebuilt at the same auto-layout index.
655
+ * - New block → appended to the end of the component list.
656
+ * - Removed → blocks whose component no longer exists in defs are deleted.
657
+ *
658
+ * Pack stories are grouped in a named "Pack Stories" container and rebuilt
659
+ * whenever the pack story list changes (compared by hash).
660
+ *
661
+ * Section x/y are only set on first creation. Existing sections keep their
662
+ * current position so designers can reposition without it resetting.
663
+ */
664
+ export interface CreateUIComponentsOptions {
665
+ primary?: boolean;
666
+ secondary?: boolean;
667
+ themeNames?: string[];
668
+ sectionTitle?: string;
669
+ tokenHash?: string;
670
+ showSectionHeader?: boolean;
671
+ sectionPaddingX?: number;
672
+ sectionPaddingY?: number;
673
+ yOffset?: number;
674
+ xOffset?: number;
675
+ excludeComponents?: string[];
676
+ preserveUnselectedComponents?: boolean;
677
+ storyTagFilter?: string[];
678
+ onlyComponents?: string[];
679
+ /**
680
+ * Optional progress hook. Called at phase boundaries so the loading
681
+ * view can show finer-grained status than the single
682
+ * "Building components…" message. Pair with `Promise.resolve()` and
683
+ * `setTimeout(0)` upstream so the iframe gets a paint frame between
684
+ * synchronous build chunks. Each call costs ~50ms of yield — limit to
685
+ * per-theme and per-atomic-section (atoms / molecules / organisms),
686
+ * not per-component, or the overhead dominates the savings.
687
+ *
688
+ * Accepts either a flat string (legacy) or `{ phase, detail }`. emit()
689
+ * below sends detail-only updates so the parent-set phase
690
+ * ("Building components…") stays visible as a sticky header.
691
+ */
692
+ onStatus?: (status: StatusUpdate) => Promise<void> | void;
693
+ }
694
+
695
+ export async function createUIComponents(parent: FrameNode | PageNode, opts: CreateUIComponentsOptions, ctx: StoryBuilderContext): Promise<FrameNode> {
696
+ const options = {
697
+ primary: true,
698
+ secondary: true,
699
+ themeNames: getThemeNames(TOKENS),
700
+ ...(opts || {}),
701
+ };
702
+ debug('createUIComponents start', { opts: options });
703
+
704
+ // Progress hook + a soft yield between phases. `onStatus` posts a
705
+ // status update to the loading view. `noYield: true` posts the message
706
+ // (the iframe runs on its own thread and updates the panel) but skips
707
+ // the 50ms wait — without that, every fine-grained emit would let
708
+ // Figma's canvas paint a half-built scene-graph state, producing the
709
+ // theme-column overlap the user sees mid-build. Coarse phases in
710
+ // main.ts (Reading components / Building tokens / Building components)
711
+ // still yield 50ms so the canvas paints between top-level phases.
712
+ const onStatus = options.onStatus;
713
+ const phaseStart = Date.now();
714
+ async function emit(message: string): Promise<void> {
715
+ if (INKBRIDGE_PERF_LOGS) {
716
+ const elapsed = ((Date.now() - phaseStart) / 1000).toFixed(1);
717
+ console.log('[Inkbridge][build] +' + elapsed + 's ' + message);
718
+ }
719
+ if (onStatus) {
720
+ await Promise.resolve(onStatus({ detail: message, noYield: true }));
721
+ }
722
+ }
723
+
724
+ const sectionTitle: string = options.sectionTitle || 'UI Components';
725
+
726
+ // tokenHash is computed once per run in design-system.ts and forwarded here.
727
+ // It covers all TOKENS so any token change invalidates all component blocks.
728
+ const tokenHash: string = typeof options.tokenHash === 'string' ? options.tokenHash : '';
729
+ const showSectionHeader = options.showSectionHeader !== false;
730
+ const sectionPaddingX = typeof options.sectionPaddingX === 'number'
731
+ ? options.sectionPaddingX
732
+ : (showSectionHeader ? BOARD_LAYOUT.sectionPaddingX : 0);
733
+ const sectionPaddingY = typeof options.sectionPaddingY === 'number'
734
+ ? options.sectionPaddingY
735
+ : (showSectionHeader ? BOARD_LAYOUT.sectionPaddingY : 0);
736
+
737
+ // --- Find or create the section frame ---
738
+ // On first run it is created and positioned. On subsequent runs the existing
739
+ // frame is reused so its position and any designer annotations are preserved.
740
+ removeDuplicateChildrenByName(parent, sectionTitle, 'FRAME');
741
+ let section: FrameNode = findChildByName(parent, sectionTitle);
742
+ if (!section) {
743
+ section = figma.createFrame();
744
+ section.name = sectionTitle;
745
+ const offsetY = typeof options.yOffset === 'number' ? options.yOffset : 0;
746
+ const offsetX = typeof options.xOffset === 'number' ? options.xOffset : 0;
747
+ section.x = offsetX;
748
+ section.y = offsetY;
749
+ parent.appendChild(section);
750
+ }
751
+ tagGeneratedNode(section, 'section:' + sectionTitle);
752
+ section.layoutMode = 'VERTICAL';
753
+ section.itemSpacing = showSectionHeader ? BOARD_LAYOUT.boardGap : 0;
754
+ section.primaryAxisSizingMode = 'AUTO';
755
+ section.counterAxisSizingMode = 'AUTO';
756
+ section.paddingLeft = section.paddingRight = sectionPaddingX;
757
+ section.paddingTop = section.paddingBottom = sectionPaddingY;
758
+ section.counterAxisAlignItems = 'MIN';
759
+ section.fills = [];
760
+ ctx.applyClipBehavior(section, []);
761
+ removeDirectTextChildren(section);
762
+
763
+ // Early reflow guard against the Design Tokens row: re-anchor section.y
764
+ // before any visible content is built so a previously-created section
765
+ // (with stale y from before the tokens row grew) doesn't paint
766
+ // overlapping tokens. design-system.ts runs the same guard AFTER
767
+ // `createUIComponents` returns; this one front-loads it. Mirrors the
768
+ // post-build math (only pushes y down, never up) so designer-set
769
+ // positions further down are preserved.
770
+ const tokensSibling = findChildByName(parent, 'Design Tokens') as FrameNode | null;
771
+ if (tokensSibling) {
772
+ const tokensY = typeof tokensSibling.y === 'number' ? tokensSibling.y : 0;
773
+ const tokensHeight = typeof tokensSibling.height === 'number' ? tokensSibling.height : 0;
774
+ const minY = tokensY + tokensHeight + 80;
775
+ if (section.y < minY) section.y = minY;
776
+ const tokensX = typeof tokensSibling.x === 'number' ? tokensSibling.x : 0;
777
+ if (section.x < tokensX) section.x = tokensX;
778
+ }
779
+
780
+
781
+ const existingSectionHeader = findChildByName(section, 'Section Header');
782
+ if (showSectionHeader) {
783
+ const sectionHeader = ensureHeaderBlock(section, 'Section Header', sectionTitle, null, {
784
+ titleSize: 32,
785
+ titleLineHeight: 38,
786
+ });
787
+ if (findChildIndexByName(section, 'Section Header') !== 0) {
788
+ section.insertChild(0, sectionHeader);
789
+ }
790
+ } else if (existingSectionHeader) {
791
+ existingSectionHeader.remove();
792
+ }
793
+
794
+ function formatThemeLabel(theme: string): string {
795
+ if (!theme) return 'Theme';
796
+ return theme.charAt(0).toUpperCase() + theme.slice(1);
797
+ }
798
+
799
+ // Build a single component block frame (all stories for one component def).
800
+ // Returns the newly created block frame.
801
+ function buildComponentBlock(def: ComponentDef, theme: string, _colFrame: FrameNode): FrameNode {
802
+ const themeContext = getThemeContext(theme);
803
+ const colorGroup = themeContext.colorGroup;
804
+ const radiusGroup = themeContext.radiusGroup;
805
+
806
+ const block = figma.createFrame();
807
+ block.name = def.name;
808
+ block.layoutMode = 'VERTICAL';
809
+ block.itemSpacing = BOARD_LAYOUT.componentTitleGap;
810
+ block.primaryAxisSizingMode = 'AUTO';
811
+ block.counterAxisSizingMode = 'AUTO';
812
+ block.counterAxisAlignItems = 'MIN';
813
+ block.fills = [];
814
+ ctx.applyClipBehavior(block, []);
815
+
816
+ block.appendChild(createTextNode(def.name, { fontSize: 22, lineHeight: 28, bold: true }));
817
+
818
+ const storyList = def.stories || [];
819
+ for (let storyIndex = 0; storyIndex < storyList.length; storyIndex++) {
820
+ const story = storyList[storyIndex];
821
+ const storyWrap = figma.createFrame();
822
+ storyWrap.name = story.name;
823
+ storyWrap.layoutMode = 'VERTICAL';
824
+ storyWrap.itemSpacing = BOARD_LAYOUT.storyGap;
825
+ storyWrap.primaryAxisSizingMode = 'AUTO';
826
+ storyWrap.counterAxisSizingMode = 'AUTO';
827
+ storyWrap.counterAxisAlignItems = 'MIN';
828
+ storyWrap.fills = [];
829
+ ctx.applyClipBehavior(storyWrap, []);
830
+
831
+ storyWrap.appendChild(createTextNode(story.name, { fontSize: 16, lineHeight: 22, opacity: 0.6, textAlignHorizontal: 'LEFT' }));
832
+
833
+ const layout = figma.createFrame();
834
+ layout.name = 'Story Layout';
835
+ layout.primaryAxisSizingMode = 'AUTO';
836
+ layout.counterAxisSizingMode = 'AUTO';
837
+ layout.fills = [];
838
+
839
+ const layoutClasses = normalizeLayoutClasses(story.layoutClasses);
840
+ ctx.applyClipBehavior(layout, layoutClasses);
841
+ ctx.applyLayoutClasses(layout, layoutClasses, colorGroup, radiusGroup, theme);
842
+
843
+ const effectiveWidth = resolveStoryLayoutWidth(story, layout, layoutClasses, ctx);
844
+ resolveStoryLayoutHeight(story, layout, layoutClasses, ctx);
845
+ const populated = populateStoryLayout(layout, def, story, theme, colorGroup, radiusGroup, ctx, effectiveWidth, {
846
+ allowTypeDefaultFallback: true,
847
+ });
848
+ expandStoryLayoutForPortalContent(layout);
849
+ const added = populated.added;
850
+ const renderMode = populated.renderMode;
851
+ const useStoryTree = populated.useStoryTree;
852
+ setStoryRenderDiagnostics(layout, def, story, renderMode, added, useStoryTree);
853
+
854
+ storyWrap.appendChild(layout);
855
+ if (shouldRenderStatesForStory(def, story, storyIndex)) {
856
+ const statesBlock = createStatePreviewBlock(def, story, theme, ctx);
857
+ if (statesBlock) storyWrap.appendChild(statesBlock);
858
+ }
859
+ // No pre-flight gate — `createResponsivePreviewBlock` already
860
+ // returns null when the story has no responsive signals (layout /
861
+ // tree / instance classes all lack `sm:`/`md:`/`lg:` etc.) or when
862
+ // the breakpoint set collapses to one, so the gate would have been
863
+ // a strict subset of the renderer's existing check.
864
+ const responsiveBlock = createResponsivePreviewBlock(def, story, theme, ctx);
865
+ if (responsiveBlock) storyWrap.appendChild(responsiveBlock);
866
+ block.appendChild(storyWrap);
867
+ }
868
+
869
+ return block;
870
+ }
871
+
872
+ // Build (or incrementally update) a theme column frame inside the Columns container.
873
+ async function addColumn(label: string, theme: string): Promise<FrameNode> {
874
+ await emit('Building ' + label.toLowerCase() + ' theme…');
875
+ // --- Find or create the column frame ---
876
+ // Column frames live inside the "Columns" container (built below) and are
877
+ // identified by name. Existing columns are reused so their position in the
878
+ // auto-layout and any sub-frames with preserved node IDs stay intact.
879
+ const columnsContainer: FrameNode | null = findChildByName(section, 'Columns') as FrameNode | null;
880
+ if (columnsContainer) {
881
+ removeDuplicateChildrenByName(columnsContainer, label + ' Column', 'FRAME');
882
+ }
883
+ let colFrame: FrameNode | null = columnsContainer ? findChildByName(columnsContainer, label + ' Column') as FrameNode | null : null;
884
+ if (!colFrame) {
885
+ colFrame = figma.createFrame();
886
+ colFrame.name = label + ' Column';
887
+ // Park the new column far off-screen. `figma.createFrame()` in
888
+ // API 1.0 auto-appends to `figma.currentPage`, so the freshly-
889
+ // created node is *not* orphan — it sits as a direct child of
890
+ // the Design System page at default `(0, 0)` until the outer
891
+ // loop's `columns.insertChild(ti, themeCol)` re-parents it
892
+ // AFTER addColumn returns. Between then and now the per-section
893
+ // yields let Figma paint that intermediate state. (0, 0) on the
894
+ // design system page is right inside the Design Tokens row, so
895
+ // the user sees the column overlap tokens for the entire build.
896
+ // Auto-layout overrides x/y when the column lands in the
897
+ // `Columns` HORIZONTAL container, so this park has no effect on
898
+ // the final position. See also `componentList` / `packStoriesFrame`
899
+ // / `sectionFrame` below — they get appended into the off-screen
900
+ // colFrame immediately for the same reason.
901
+ colFrame.x = -100000;
902
+ colFrame.y = -100000;
903
+ }
904
+ tagGeneratedNode(colFrame, 'theme-column:' + sectionTitle + ':' + theme);
905
+ colFrame.layoutMode = 'VERTICAL';
906
+ colFrame.itemSpacing = BOARD_LAYOUT.columnGap;
907
+ colFrame.primaryAxisSizingMode = 'AUTO';
908
+ colFrame.counterAxisSizingMode = 'AUTO';
909
+ colFrame.counterAxisAlignItems = 'MIN';
910
+ colFrame.paddingLeft = colFrame.paddingRight = BOARD_LAYOUT.columnPaddingX;
911
+ colFrame.paddingTop = colFrame.paddingBottom = BOARD_LAYOUT.columnPaddingY;
912
+ colFrame.fills = [];
913
+ // Keep columns transparent; story surfaces are responsible for their own backgrounds.
914
+ ctx.applyClipBehavior(colFrame, []);
915
+ removeDirectTextChildren(colFrame);
916
+ const themeHeader = ensureHeaderBlock(
917
+ colFrame,
918
+ 'Theme Header',
919
+ label + ' Theme',
920
+ 'Generated component board with grouped stories and larger comparison spacing.',
921
+ { titleSize: 32, titleLineHeight: 38, descriptionSize: 14 }
922
+ );
923
+ if (findChildIndexByName(colFrame, 'Theme Header') !== 0) {
924
+ colFrame.insertChild(0, themeHeader);
925
+ }
926
+
927
+ // --- Pack stories (incremental by hash) ---
928
+ // Wrapped in a named container so we can cleanly replace them without
929
+ // disturbing the component blocks below.
930
+ const pack = getActivePack();
931
+ const packStories = pack && pack.stories ? pack.stories : [];
932
+ const storyTagFilter = options && options.storyTagFilter ? String(options.storyTagFilter) : '';
933
+ const filteredStories = packStories.filter(function(s: ComponentStory) {
934
+ if (!storyTagFilter) return true;
935
+ const tags = s.tags || [];
936
+ return tags.indexOf(storyTagFilter) !== -1;
937
+ });
938
+ const packStoriesHash = hashString(JSON.stringify(filteredStories) + theme);
939
+ removeDuplicateChildrenByName(colFrame, 'Showcase Stories', 'FRAME');
940
+ let packStoriesFrame: FrameNode | null = findChildByName(colFrame, 'Showcase Stories');
941
+ if (!packStoriesFrame || getFrameHash(packStoriesFrame) !== packStoriesHash) {
942
+ if (packStoriesFrame) packStoriesFrame.remove();
943
+ const stalePackFrame = findChildByName(colFrame, 'Pack Stories');
944
+ if (stalePackFrame) stalePackFrame.remove();
945
+ if (filteredStories.length > 0) {
946
+ packStoriesFrame = figma.createFrame();
947
+ packStoriesFrame.name = 'Showcase Stories';
948
+ // Append into colFrame immediately so the API-1.0 auto-append to
949
+ // currentPage doesn't leave it at ds-page (0,0) during the
950
+ // synchronous build of its children (same class of bug as
951
+ // `componentList` above).
952
+ colFrame.appendChild(packStoriesFrame);
953
+ packStoriesFrame.layoutMode = 'VERTICAL';
954
+ packStoriesFrame.itemSpacing = BOARD_LAYOUT.showcaseGap;
955
+ packStoriesFrame.primaryAxisSizingMode = 'AUTO';
956
+ packStoriesFrame.counterAxisSizingMode = 'AUTO';
957
+ packStoriesFrame.counterAxisAlignItems = 'MIN';
958
+ packStoriesFrame.fills = [];
959
+ ctx.applyClipBehavior(packStoriesFrame, []);
960
+ packStoriesFrame.appendChild(ensureHeaderBlock(
961
+ packStoriesFrame,
962
+ 'Section Header',
963
+ 'Showcase Stories',
964
+ 'Larger composite stories rendered before the component catalog.',
965
+ { titleSize: 20, titleLineHeight: 26, descriptionSize: 14 }
966
+ ));
967
+ for (let si = 0; si < filteredStories.length; si++) {
968
+ const story = filteredStories[si];
969
+ const storyBlock = figma.createFrame();
970
+ storyBlock.name = story.name || 'Story';
971
+ storyBlock.layoutMode = 'VERTICAL';
972
+ storyBlock.itemSpacing = BOARD_LAYOUT.componentTitleGap;
973
+ storyBlock.primaryAxisSizingMode = 'AUTO';
974
+ storyBlock.counterAxisSizingMode = 'AUTO';
975
+ storyBlock.counterAxisAlignItems = 'MIN';
976
+ storyBlock.fills = [];
977
+ ctx.applyClipBehavior(storyBlock, []);
978
+ storyBlock.appendChild(createTextNode(story.name || 'Story', { fontSize: 16, lineHeight: 22, bold: true }));
979
+ storyBlock.appendChild(renderStandaloneStory(story, theme, ctx));
980
+ packStoriesFrame.appendChild(storyBlock);
981
+ }
982
+ setFrameHash(packStoriesFrame, packStoriesHash);
983
+ tagGeneratedNode(packStoriesFrame, 'showcase-stories:' + sectionTitle + ':' + theme);
984
+ }
985
+ }
986
+ packStoriesFrame = findChildByName(colFrame, 'Showcase Stories');
987
+ if (packStoriesFrame && findChildIndexByName(colFrame, 'Showcase Stories') !== 1) {
988
+ colFrame.insertChild(1, packStoriesFrame);
989
+ }
990
+
991
+ // --- Component blocks (per-component hash diff) ---
992
+ // "Component Blocks" is a named container holding one block per component def.
993
+ // We find or create it, then diff its contents against the current defs list.
994
+ removeDuplicateChildrenByName(colFrame, 'Component Blocks', 'FRAME');
995
+ let componentList: FrameNode = findChildByName(colFrame, 'Component Blocks');
996
+ if (!componentList) {
997
+ componentList = figma.createFrame();
998
+ componentList.name = 'Component Blocks';
999
+ componentList.layoutMode = 'VERTICAL';
1000
+ // Append into colFrame IMMEDIATELY so the API-1.0 auto-append to
1001
+ // figma.currentPage doesn't leave it as a ds-page direct child at
1002
+ // (0, 0) — that's the frame we observed sitting ON TOP OF the
1003
+ // Design Tokens row during the entire build (47k px tall, exactly
1004
+ // where the user reported overlap). Attaching it to colFrame
1005
+ // (parked at -100000, -100000) means the build's incremental
1006
+ // content is built in the off-screen tree.
1007
+ colFrame.appendChild(componentList);
1008
+ }
1009
+ tagGeneratedNode(componentList, 'component-blocks:' + sectionTitle + ':' + theme);
1010
+ componentList.primaryAxisSizingMode = 'AUTO';
1011
+ componentList.counterAxisSizingMode = 'AUTO';
1012
+ componentList.counterAxisAlignItems = 'MIN';
1013
+ componentList.itemSpacing = BOARD_LAYOUT.sectionGroupGap;
1014
+ componentList.fills = [];
1015
+ ctx.applyClipBehavior(componentList, []);
1016
+
1017
+ const defsRaw = (COMPONENT_DEFS && COMPONENT_DEFS.components) ? COMPONENT_DEFS.components : [];
1018
+ const onlyComponents: string[] | undefined = options.onlyComponents;
1019
+ const excludeComponents: string[] | undefined = options.excludeComponents;
1020
+ const preserveUnselectedComponents = options.preserveUnselectedComponents === true;
1021
+ const defs = defsRaw
1022
+ .map(ctx.normalizeComponentDef)
1023
+ .filter(function(d: ComponentDef) {
1024
+ if (!d || !d.stories || d.stories.length === 0) return false;
1025
+ if (onlyComponents && onlyComponents.length > 0) return onlyComponents.includes(d.name);
1026
+ if (excludeComponents && excludeComponents.length > 0) return !excludeComponents.includes(d.name);
1027
+ return true;
1028
+ })
1029
+ .sort(function(a: ComponentDef, b: ComponentDef) { return a.name.localeCompare(b.name); });
1030
+
1031
+ warmSymbolMasters(defs, theme, ctx);
1032
+
1033
+ const groupedDefs = groupComponentDefs(defs);
1034
+ const expectedSectionNames: Record<string, boolean> = {};
1035
+ for (let gi = 0; gi < groupedDefs.length; gi++) {
1036
+ expectedSectionNames[getComponentSectionName(groupedDefs[gi].section)] = true;
1037
+ }
1038
+
1039
+ const existingGroups: SceneNode[] = componentList.children ? Array.from(componentList.children) : [];
1040
+ const seenSectionFrames: Record<string, boolean> = {};
1041
+ for (let gi = 0; gi < existingGroups.length; gi++) {
1042
+ const existingGroup = existingGroups[gi];
1043
+ const duplicate = !!seenSectionFrames[existingGroup.name];
1044
+ const stale = !expectedSectionNames[existingGroup.name];
1045
+ if (duplicate || (stale && !preserveUnselectedComponents)) {
1046
+ existingGroup.remove();
1047
+ } else {
1048
+ seenSectionFrames[existingGroup.name] = true;
1049
+ }
1050
+ }
1051
+
1052
+ for (let gi = 0; gi < groupedDefs.length; gi++) {
1053
+ const group = groupedDefs[gi];
1054
+ const sectionName = getComponentSectionName(group.section);
1055
+ // Per-atomic-section status (atoms / molecules / organisms / …).
1056
+ // Includes the theme label so a multi-theme run shows clearly which
1057
+ // pass is in flight. Yields the JS thread so the iframe repaints.
1058
+ await emit(label + ' theme · ' + sectionName + ' (' + group.defs.length + ' components)');
1059
+ removeDuplicateChildrenByName(componentList, sectionName, 'FRAME');
1060
+ let sectionFrame: FrameNode = findChildByName(componentList, sectionName);
1061
+ if (!sectionFrame) {
1062
+ sectionFrame = figma.createFrame();
1063
+ sectionFrame.name = sectionName;
1064
+ // Append into componentList immediately (componentList is now
1065
+ // inside the off-screen colFrame, so sectionFrame inherits the
1066
+ // off-screen position). Same class of bug as `componentList`
1067
+ // and `packStoriesFrame` above — without this, sectionFrame
1068
+ // sits at ds-page (0,0) during the synchronous build of its
1069
+ // component blocks. The later `componentList.appendChild` /
1070
+ // `componentList.insertChild` calls then no-op or re-order.
1071
+ componentList.appendChild(sectionFrame);
1072
+ }
1073
+ tagGeneratedNode(sectionFrame, 'atomic-section:' + sectionTitle + ':' + theme + ':' + group.section.key);
1074
+
1075
+ sectionFrame.layoutMode = 'VERTICAL';
1076
+ sectionFrame.primaryAxisSizingMode = 'AUTO';
1077
+ sectionFrame.counterAxisSizingMode = 'AUTO';
1078
+ sectionFrame.counterAxisAlignItems = 'MIN';
1079
+ sectionFrame.itemSpacing = BOARD_LAYOUT.sectionTitleGap;
1080
+ sectionFrame.fills = [];
1081
+ ctx.applyClipBehavior(sectionFrame, []);
1082
+
1083
+ const groupHeader = ensureHeaderBlock(
1084
+ sectionFrame,
1085
+ 'Section Header',
1086
+ group.section.title,
1087
+ group.section.description,
1088
+ { titleSize: 26, titleLineHeight: 32, descriptionSize: 14 }
1089
+ );
1090
+ if (findChildIndexByName(sectionFrame, 'Section Header') !== 0) {
1091
+ sectionFrame.insertChild(0, groupHeader);
1092
+ }
1093
+
1094
+ removeDuplicateChildrenByName(sectionFrame, 'Blocks', 'FRAME');
1095
+ let groupBlocks: FrameNode = findChildByName(sectionFrame, 'Blocks');
1096
+ if (!groupBlocks) {
1097
+ groupBlocks = figma.createFrame();
1098
+ groupBlocks.name = 'Blocks';
1099
+ sectionFrame.appendChild(groupBlocks);
1100
+ }
1101
+ tagGeneratedNode(groupBlocks, 'atomic-section-blocks:' + sectionTitle + ':' + theme + ':' + group.section.key);
1102
+ groupBlocks.layoutMode = 'VERTICAL';
1103
+ groupBlocks.primaryAxisSizingMode = 'AUTO';
1104
+ groupBlocks.counterAxisSizingMode = 'AUTO';
1105
+ groupBlocks.counterAxisAlignItems = 'MIN';
1106
+ groupBlocks.itemSpacing = BOARD_LAYOUT.componentBlockGap;
1107
+ groupBlocks.fills = [];
1108
+ ctx.applyClipBehavior(groupBlocks, []);
1109
+
1110
+ const expectedNames: Record<string, boolean> = {};
1111
+ for (let di = 0; di < group.defs.length; di++) {
1112
+ expectedNames[group.defs[di].name] = true;
1113
+ }
1114
+
1115
+ const existingBlocks: SceneNode[] = groupBlocks.children ? Array.from(groupBlocks.children) : [];
1116
+ const seenComponentBlocks: Record<string, boolean> = {};
1117
+ for (let ei = 0; ei < existingBlocks.length; ei++) {
1118
+ const existingBlock = existingBlocks[ei];
1119
+ const duplicate = !!seenComponentBlocks[existingBlock.name];
1120
+ const stale = !expectedNames[existingBlock.name];
1121
+ if (duplicate || (stale && !preserveUnselectedComponents)) {
1122
+ existingBlock.remove();
1123
+ debug('Removed stale component block', { name: existingBlock.name });
1124
+ } else {
1125
+ seenComponentBlocks[existingBlock.name] = true;
1126
+ }
1127
+ }
1128
+
1129
+ for (let di = 0; di < group.defs.length; di++) {
1130
+ const def = group.defs[di];
1131
+ const blockHash = hashDef(def) + ':' + tokenHash + ':' + RENDER_ENGINE_VERSION;
1132
+ const existingBlock: FrameNode = findChildByName(groupBlocks, def.name);
1133
+ const existingBlockIndex = existingBlock ? findChildIndexByName(groupBlocks, def.name) : -1;
1134
+
1135
+ if (existingBlock && getFrameHash(existingBlock) === blockHash) {
1136
+ const currentIndex = existingBlockIndex;
1137
+ if (!preserveUnselectedComponents && currentIndex !== di) {
1138
+ groupBlocks.insertChild(di, existingBlock);
1139
+ }
1140
+ debug('Component block unchanged — skipped', { name: def.name });
1141
+ continue;
1142
+ }
1143
+
1144
+ let insertIndex = di;
1145
+ if (preserveUnselectedComponents) {
1146
+ insertIndex = existingBlockIndex >= 0 ? existingBlockIndex : groupBlocks.children.length;
1147
+ }
1148
+ if (existingBlock) {
1149
+ existingBlock.remove();
1150
+ debug('Component block changed — rebuilding', { name: def.name });
1151
+ }
1152
+
1153
+ // Per-component timing log to the plugin console (no toast,
1154
+ // no yield — would balloon overhead). Lets us see in dev tools
1155
+ // which components dominate build time without slowing the run.
1156
+ const componentStart = Date.now();
1157
+ const newBlock = buildComponentBlock(def, theme, colFrame);
1158
+ const componentMs = Date.now() - componentStart;
1159
+ if (INKBRIDGE_PERF_LOGS && componentMs > 50) {
1160
+ console.log('[Inkbridge][build] · ' + def.name + ' (' + theme + ') ' + componentMs + 'ms');
1161
+ }
1162
+ setFrameHash(newBlock, blockHash);
1163
+ tagGeneratedNode(newBlock, 'component-block:' + sectionTitle + ':' + theme + ':' + def.name);
1164
+
1165
+ if (insertIndex >= 0 && insertIndex < groupBlocks.children.length) {
1166
+ groupBlocks.insertChild(insertIndex, newBlock);
1167
+ } else {
1168
+ groupBlocks.appendChild(newBlock);
1169
+ }
1170
+ }
1171
+
1172
+ const currentSectionIndex = findChildIndexByName(componentList, sectionName);
1173
+ if (currentSectionIndex === -1) {
1174
+ // Newly created section: always attach, even when preserving unselected.
1175
+ // Without this, a subset run on a fresh file produces a section frame
1176
+ // that never enters the component list and no blocks are rendered.
1177
+ componentList.appendChild(sectionFrame);
1178
+ } else if (!preserveUnselectedComponents && currentSectionIndex !== gi) {
1179
+ componentList.insertChild(gi, sectionFrame);
1180
+ }
1181
+ }
1182
+
1183
+ if (componentList.children.length > 0 && !findChildByName(colFrame, 'Component Blocks')) {
1184
+ colFrame.appendChild(componentList);
1185
+ }
1186
+ if (findChildByName(colFrame, 'Component Blocks')) {
1187
+ const componentListIndex = packStoriesFrame ? 2 : 1;
1188
+ if (findChildIndexByName(colFrame, 'Component Blocks') !== componentListIndex) {
1189
+ colFrame.insertChild(componentListIndex, componentList);
1190
+ }
1191
+ }
1192
+
1193
+ return colFrame;
1194
+ }
1195
+
1196
+ // --- Find or create the Columns container ---
1197
+ // Named "Columns" so it survives across runs. Column frames inside it are
1198
+ // found/created by addColumn using the theme label as a stable key.
1199
+ let columns: FrameNode | null = findChildByName(section, 'Columns');
1200
+ if (!columns) {
1201
+ columns = figma.createFrame();
1202
+ columns.name = 'Columns';
1203
+ section.appendChild(columns);
1204
+ }
1205
+ tagGeneratedNode(columns, 'columns:' + sectionTitle);
1206
+ columns.layoutMode = 'HORIZONTAL';
1207
+ columns.itemSpacing = BOARD_LAYOUT.columnsGap;
1208
+ columns.primaryAxisSizingMode = 'AUTO';
1209
+ columns.counterAxisSizingMode = 'AUTO';
1210
+ columns.counterAxisAlignItems = 'MIN';
1211
+ columns.fills = [];
1212
+ ctx.applyClipBehavior(columns, []);
1213
+
1214
+ const requestedThemes: string[] = Array.isArray(options.themeNames)
1215
+ ? options.themeNames.filter(function(theme: string, index: number, list: string[]) {
1216
+ return typeof theme === 'string' &&
1217
+ theme.trim().length > 0 &&
1218
+ list.indexOf(theme) === index;
1219
+ })
1220
+ : [];
1221
+ if (requestedThemes.length === 0) {
1222
+ if (options.primary) requestedThemes.push('primary');
1223
+ if (options.secondary) requestedThemes.push('secondary');
1224
+ }
1225
+ const expectedColumnNames: Record<string, boolean> = {};
1226
+ const multiMode = isMultiModeEnabled();
1227
+ if (multiMode) {
1228
+ const activeTheme = requestedThemes[0] || 'primary';
1229
+ expectedColumnNames['Theme Column'] = true;
1230
+ const singleCol = await addColumn('Theme', activeTheme);
1231
+ if (findChildIndexByName(columns, 'Theme Column') !== 0) {
1232
+ columns.insertChild(0, singleCol);
1233
+ }
1234
+ setThemeMode(singleCol, activeTheme);
1235
+ } else {
1236
+ for (let ti = 0; ti < requestedThemes.length; ti++) {
1237
+ const themeName = requestedThemes[ti];
1238
+ const themeCol = await addColumn(formatThemeLabel(themeName), themeName);
1239
+ const columnName = formatThemeLabel(themeName) + ' Column';
1240
+ expectedColumnNames[columnName] = true;
1241
+ if (findChildIndexByName(columns, columnName) !== ti) {
1242
+ columns.insertChild(ti, themeCol);
1243
+ }
1244
+ setThemeMode(themeCol, themeName);
1245
+ }
1246
+ }
1247
+ const existingColumns: SceneNode[] = columns.children ? Array.from(columns.children) : [];
1248
+ const seenColumns: Record<string, boolean> = {};
1249
+ for (let i = 0; i < existingColumns.length; i++) {
1250
+ const col = existingColumns[i];
1251
+ if (!expectedColumnNames[col.name] || seenColumns[col.name]) {
1252
+ col.remove();
1253
+ } else {
1254
+ seenColumns[col.name] = true;
1255
+ }
1256
+ }
1257
+ debug('UI section updated', { columns: columns.children.length });
1258
+ return section;
1259
+ }
1260
+
1261
+ // ====================================================================
1262
+ // Pre-flight data computation
1263
+ // ====================================================================
1264
+
1265
+ export interface PreflightItem {
1266
+ name: string;
1267
+ status: 'new' | 'changed' | 'unchanged';
1268
+ section: string;
1269
+ sectionTitle: string;
1270
+ /**
1271
+ * Optional human-readable label. Used when `name` is a synthetic identifier
1272
+ * (e.g. the design tokens toggle) that isn't suitable for direct display.
1273
+ */
1274
+ displayName?: string;
1275
+ }
1276
+
1277
+ export function computePreflightData(tokenHash: string): PreflightItem[] {
1278
+ let ds: PageNode | null = null;
1279
+ if (figma.root && Array.isArray(figma.root.children)) {
1280
+ for (let pi = 0; pi < figma.root.children.length; pi++) {
1281
+ if (figma.root.children[pi].name === 'Design System') {
1282
+ ds = figma.root.children[pi];
1283
+ break;
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ const defsRaw = (COMPONENT_DEFS && COMPONENT_DEFS.components) ? COMPONENT_DEFS.components : [];
1289
+ const result: PreflightItem[] = [];
1290
+
1291
+ for (let i = 0; i < defsRaw.length; i++) {
1292
+ const def = normalizeForPreflight(defsRaw[i]);
1293
+ if (!def || !def.stories || def.stories.length === 0) continue;
1294
+
1295
+ const section = inferComponentSection(def);
1296
+ const defHash = hashDef(def);
1297
+
1298
+ let status: 'new' | 'changed' | 'unchanged' = 'new';
1299
+ if (ds) {
1300
+ const matchingBlocks = findComponentBlocksInPage(ds, def.name);
1301
+ if (matchingBlocks.length > 0) {
1302
+ let hasCurrentHash = false;
1303
+ for (let bi = 0; bi < matchingBlocks.length; bi++) {
1304
+ if (blockHashMatchesCurrent(getFrameHash(matchingBlocks[bi]), defHash, tokenHash, RENDER_ENGINE_VERSION)) {
1305
+ hasCurrentHash = true;
1306
+ break;
1307
+ }
1308
+ }
1309
+ status = hasCurrentHash ? 'unchanged' : 'changed';
1310
+ }
1311
+ }
1312
+
1313
+ result.push({
1314
+ name: def.name,
1315
+ status: status,
1316
+ section: section.key,
1317
+ sectionTitle: section.title,
1318
+ });
1319
+ }
1320
+
1321
+ return result;
1322
+ }