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,2676 @@
1
+ // External modules (one import per source).
2
+ import {
3
+ parseColor,
4
+ pxFromSizeToken,
5
+ extractTextColorToken,
6
+ resolveTextColorFallback,
7
+ resolveTextColorValue,
8
+ type RGB,
9
+ } from '../tokens';
10
+ import {
11
+ applyTailwindStylesToFrame,
12
+ applyNodeTransforms,
13
+ getCompoundClasses,
14
+ getClassesForBreakpoint,
15
+ isElementLikeNode,
16
+ mergeClasses,
17
+ resolveNodeIR,
18
+ resolveMaxWidth,
19
+ splitClassName,
20
+ getBaseClass,
21
+ type JsxNode,
22
+ type JsxElement,
23
+ type JsxText,
24
+ type NodeIR,
25
+ type NodeIRElement,
26
+ type NodeIRHelpers,
27
+ } from '../tailwind';
28
+ import {
29
+ applyAbsoluteIfPossible,
30
+ applyAspectRatioIfPossible,
31
+ applyDeferredPercentPositioning,
32
+ applyBorderWidthUtilities,
33
+ applyClipBehavior,
34
+ applyDeferredBottomPositioning,
35
+ applyDeferredCenterYPositioning,
36
+ applyFullWidthIfPossible,
37
+ applyRingIfPossible,
38
+ applyGridColumnsWithReflow,
39
+ enforceFixedBoxSizingAfterLayout,
40
+ enforceGrowChildPrimaryFixed,
41
+ extractGridColumns,
42
+ getNodeLayoutComputed,
43
+ LayoutParser,
44
+ markAbsoluteNode,
45
+ markAspectRatio,
46
+ markFullHeightNode,
47
+ markFullWidthNode,
48
+ markSvgContentWrap,
49
+ maybeWrapMxAuto,
50
+ resolveGridWidthOverride,
51
+ resolveTextResizeWidth,
52
+ setFrameCrossAlign,
53
+ shouldClipContent,
54
+ shouldSkipFullWidthForClasses,
55
+ shouldStretchToParentWidth,
56
+ solveLayoutWidths,
57
+ } from '../layout';
58
+ import {
59
+ analyzeSymbolPolicy,
60
+ createCompoundComponent,
61
+ findExistingThemedComponentNode,
62
+ getComponentDefByName,
63
+ getInstantiableComponent,
64
+ tryCreateCvaComponentInstance as tryCreateCvaComponentInstanceShared,
65
+ tryCreateNonCvaComponentInstance as tryCreateNonCvaComponentInstanceShared,
66
+ type InstanceBackend,
67
+ type ComponentDef,
68
+ type ComponentInstance,
69
+ type CvaAnalysis,
70
+ } from '../components';
71
+ import {
72
+ TAG_TYPOGRAPHY,
73
+ createInlineTextNode,
74
+ createTextNode,
75
+ getLineHeightFromClasses,
76
+ getNodeTextStyle,
77
+ getTextAlignFromClasses,
78
+ isInlineTextNode,
79
+ maybeExpandTextLineComponent,
80
+ type CreateTextOptions,
81
+ } from '../text';
82
+ import {
83
+ buildDecorativeClipPathNode,
84
+ createIconFromSvg,
85
+ injectPortalPanelHeights,
86
+ injectPortalPanelWidths,
87
+ nodeIrToSvg,
88
+ parseClipPathFromStyle,
89
+ resizeSvgNodeTo,
90
+ } from '../effects';
91
+ import { getImageHash, getSvgString } from '../cache';
92
+
93
+ // Sibling design-system modules.
94
+ import { defaultGridWidth } from './story-layout';
95
+ import { type StoryBuilderContext } from './story-builder-context';
96
+ import {
97
+ createUIComponents as createUIComponentsFromStory,
98
+ pruneGeneratedComponentLibrary as pruneGeneratedComponentLibraryFromStory,
99
+ type CreateUIComponentsOptions,
100
+ } from './story-builder';
101
+ import { ensureCvaComponentSet as ensureCvaComponentSetFromStory } from './cva-master';
102
+ import {
103
+ isAccordionRootTag,
104
+ isAccordionItemTag,
105
+ isAccordionContentTag,
106
+ isAccordionHeaderTag,
107
+ isRadioGroupRootTag,
108
+ isRadioGroupItemTag,
109
+ isRadioGroupIndicatorTag,
110
+ isTabsRootTag,
111
+ isTabsTriggerTag,
112
+ isTabsContentTag,
113
+ isSelectRootTag,
114
+ isSelectContentTag,
115
+ isSelectTriggerTag,
116
+ isSelectValueTag,
117
+ isSelectItemTag,
118
+ isSelectItemIndicatorTag,
119
+ isPortalArrowTag,
120
+ isSelectScrollButtonTag,
121
+ } from './tag-predicates';
122
+ import { isTruthyStateProp } from './state-utils';
123
+ import {
124
+ hasTableCellSizeOverride,
125
+ getNumericColSpan,
126
+ isSemanticTableContainer,
127
+ clearTopBorder,
128
+ clearBottomBorder,
129
+ } from './table-helpers';
130
+ import {
131
+ constrainSingleHorizontalTextChild,
132
+ stabilizeHorizontalStretchChild,
133
+ reflowMxAutoChildren,
134
+ applyVerticalMarginSpacing,
135
+ enforceTabsChildSizing,
136
+ } from './frame-stabilizers';
137
+ import {
138
+ unwrapTransparentWrapper,
139
+ createPerChildRenderContext,
140
+ getRenderableChildren,
141
+ shouldMergeDefBaseClassesForTag,
142
+ pickPreservedWrapperBaseClasses,
143
+ normalizeComponentDef,
144
+ } from './node-helpers';
145
+ import {
146
+ normalizeSelectableValue,
147
+ normalizeAccordionDefaultValue,
148
+ resolveAccordionItemValue,
149
+ resolveRadioItemChecked,
150
+ resolveTabsNodeActive,
151
+ resolveSelectItemSelected,
152
+ findSelectedSelectLabel,
153
+ findFirstSelectItemValue,
154
+ prettySelectValueLabel,
155
+ collectTextContent,
156
+ } from './selectable-state';
157
+ import {
158
+ getFontStyleFromClasses,
159
+ getResolvedTextSizeFromClasses,
160
+ getResolvedBoldFromClasses,
161
+ } from './typography';
162
+ import { expandActiveConditionalVariants } from './node-variants';
163
+ import {
164
+ resolveNodeResponsiveClasses,
165
+ promoteFocusVariants,
166
+ resolveBreakpointIndexForWidth,
167
+ resolveClassesForBreakpoint,
168
+ } from './responsive-resolver';
169
+ import {
170
+ resolveIconForegroundColor,
171
+ wrapSvgIcon,
172
+ renderRegistryIcon,
173
+ renderMappedIcon,
174
+ } from './icon-rendering';
175
+ import { inferCvaDefFromClasses, buildCvaInstanceProps } from './cva-inference';
176
+ import { toFigmaVariantPropertyName, toFigmaVariantPropertyValue } from './master-shared';
177
+ import { applyTextOverrideToInstance } from './symbol-fallback';
178
+ import {
179
+ type RenderContext,
180
+ hasWidthHintInClasses,
181
+ propsContainWidthHint,
182
+ hasExplicitHeight,
183
+ } from './render-context';
184
+ import { setGeneratedSymbolDebugData } from './generated-node';
185
+
186
+ /**
187
+ * Inline SVGs flow through the icon-rendering path which sizes them via
188
+ * `resolveIconSizeFromClasses` (defaults to 24×24 when no `size-*` / `w-*`
189
+ * / `h-*` numeric class is present). When the source SVG instead carries
190
+ * fill-parent classes — `h-full`, `w-full`, `inset-0`, `inset-x-0`,
191
+ * `inset-y-0`, or `aspect-{n}/{m}` — propagate those marks onto the wrapped
192
+ * frame so the existing layout pipeline (`applyFullWidthIfPossible`,
193
+ * `applyAspectRatioIfPossible`) resizes it like any other div would.
194
+ *
195
+ * The wrap originally contains a 24×24 SVG icon node. When the wrap is
196
+ * later resized to fill its parent (e.g. 720×432 for an `aspect-5/3`
197
+ * overlay), the inner SVG vectors don't auto-scale — `resize()` on the
198
+ * outer frame leaves them at 24×24. Register a resize hook so the layout
199
+ * pipeline rescales the inner SVG via `resizeSvgNodeTo` when it resizes
200
+ * the wrap. Required for both `<svg className="absolute inset-0 h-full
201
+ * w-full">` overlays AND `aspect-*` SVGs sized via aspect-ratio retry.
202
+ */
203
+ function applySvgLayoutMarksFromClasses(
204
+ wrapped: SceneNode,
205
+ classes: string[] | undefined,
206
+ inner: SceneNode | null,
207
+ svgProps?: Record<string, unknown>
208
+ ): void {
209
+ let needsContentScale = false;
210
+ // SVG HTML attrs `width="100%"` / `height="100%"` (used by e.g. the
211
+ // Bridge SVG: `<svg width="100%" height="100%" ...>`) signal the same
212
+ // fill-parent intent as Tailwind `w-full` / `h-full`. The icon-size
213
+ // resolver now skips % values, so without this the wrap defaults to
214
+ // a 24×24 icon size and never grows.
215
+ if (svgProps) {
216
+ const w = svgProps.width;
217
+ const h = svgProps.height;
218
+ if (typeof w === 'string' && /%\s*$/.test(w.trim())) {
219
+ markFullWidthNode(wrapped);
220
+ needsContentScale = true;
221
+ }
222
+ if (typeof h === 'string' && /%\s*$/.test(h.trim())) {
223
+ markFullHeightNode(wrapped);
224
+ needsContentScale = true;
225
+ }
226
+ }
227
+ if (!classes || classes.length === 0) {
228
+ // No Tailwind fill-parent classes, but % props may still trigger
229
+ // content-scale + clipping below. Fall through.
230
+ if (!needsContentScale) return;
231
+ classes = [];
232
+ }
233
+ for (const cls of classes) {
234
+ if (cls === 'absolute') {
235
+ // The SVG branch in renderJsxTree bypasses the standard Tailwind
236
+ // class-application pass that would otherwise mark `absolute`.
237
+ // Without this, ABSOLUTE_NODES doesn't include the wrap and the
238
+ // layout pipeline's absolute-child branches (e.g. the direct-resize
239
+ // path for full-width/height absolute children) never fire.
240
+ markAbsoluteNode(wrapped);
241
+ }
242
+ if (cls === 'w-full' || cls === 'inset-0' || cls === 'inset-x-0') {
243
+ markFullWidthNode(wrapped);
244
+ needsContentScale = true;
245
+ }
246
+ if (cls === 'h-full' || cls === 'inset-0' || cls === 'inset-y-0') {
247
+ markFullHeightNode(wrapped);
248
+ needsContentScale = true;
249
+ }
250
+ if (cls === 'aspect-square') {
251
+ markAspectRatio(wrapped, 1);
252
+ needsContentScale = true;
253
+ } else if (cls === 'aspect-video') {
254
+ markAspectRatio(wrapped, 16 / 9);
255
+ needsContentScale = true;
256
+ } else {
257
+ const fraction = cls.match(/^aspect-(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
258
+ if (fraction) {
259
+ const aw = parseFloat(fraction[1]);
260
+ const ah = parseFloat(fraction[2]);
261
+ if (aw > 0 && ah > 0) markAspectRatio(wrapped, aw / ah);
262
+ needsContentScale = true;
263
+ }
264
+ }
265
+ }
266
+ if (needsContentScale) {
267
+ // SVGs with fill-parent classes are render canvases, not glyphs.
268
+ // Browsers clip content to the SVG viewBox by default; Figma doesn't
269
+ // (`normalizeIconNode` explicitly disables `clipsContent` for icons).
270
+ // Re-enable clipping on both the wrap and the inner SVG node so paths
271
+ // that extend outside the viewBox (e.g. the round-trip arrow arcs
272
+ // that bulge above y=0) don't bleed over neighbouring content.
273
+ if ('clipsContent' in wrapped) {
274
+ try { (wrapped as FrameNode).clipsContent = true; } catch (_e) { /* ignore */ }
275
+ }
276
+ if (inner && 'clipsContent' in inner) {
277
+ try { (inner as FrameNode).clipsContent = true; } catch (_e) { /* ignore */ }
278
+ }
279
+ }
280
+ if (needsContentScale && inner) {
281
+ // Parse viewBox and preserveAspectRatio so the resize hook respects
282
+ // the SVG's intrinsic aspect ratio. The inner SVG node was pre-sized
283
+ // to a 24×24 icon by `wrapSvgIcon`, which clobbers the viewBox
284
+ // dimensions — uniform scaling against 24×24 squares the result.
285
+ let viewBoxW: number | null = null;
286
+ let viewBoxH: number | null = null;
287
+ if (svgProps && typeof svgProps.viewBox === 'string') {
288
+ const parts = svgProps.viewBox.trim().split(/[\s,]+/).map(parseFloat);
289
+ if (parts.length === 4 && parts.every(Number.isFinite)) {
290
+ viewBoxW = parts[2];
291
+ viewBoxH = parts[3];
292
+ }
293
+ }
294
+
295
+ const par = svgProps && typeof svgProps.preserveAspectRatio === 'string'
296
+ ? svgProps.preserveAspectRatio
297
+ : 'xMidYMid meet';
298
+ const parPieces = par.trim().split(/\s+/);
299
+ const align = parPieces[0] || 'xMidYMid';
300
+ const meetOrSlice = (parPieces[1] === 'slice' ? 'slice' : 'meet') as 'meet' | 'slice';
301
+ const alignX: 'min' | 'mid' | 'max' =
302
+ align.indexOf('xMin') === 0 ? 'min' : align.indexOf('xMax') === 0 ? 'max' : 'mid';
303
+ const alignY: 'min' | 'mid' | 'max' =
304
+ align.indexOf('YMin') > 0 ? 'min' : align.indexOf('YMax') > 0 ? 'max' : 'mid';
305
+
306
+ // Capture each VECTOR's original strokeWeight on first hook invocation.
307
+ // `resizeSvgNodeTo` scales positions and dimensions but does NOT touch
308
+ // stroke width — so a bridge with `stroke-width="20"` keeps that 20px
309
+ // stroke even when scaled 3.2× to fill its container, making the arch
310
+ // look way too thin. Re-apply `base * scale` each call (idempotent).
311
+ const baseStrokeWeights = new WeakMap<SceneNode, number>();
312
+ markSvgContentWrap(wrapped, (w, h) => {
313
+ // baseW/baseH must reflect the SVG's INTRINSIC aspect ratio, not the
314
+ // post-wrap 24×24 icon dimensions. Prefer the parsed viewBox; fall
315
+ // back to the inner node's current size only if viewBox is missing.
316
+ const innerFrame = inner as FrameNode;
317
+ const baseW = viewBoxW && viewBoxW > 0 ? viewBoxW : (innerFrame.width || 1);
318
+ const baseH = viewBoxH && viewBoxH > 0 ? viewBoxH : (innerFrame.height || 1);
319
+ const sx = w / baseW;
320
+ const sy = h / baseH;
321
+ // Uniform scale per `preserveAspectRatio`:
322
+ // meet → min(sx, sy) — fit inside, may leave empty space (centered or aligned)
323
+ // slice → max(sx, sy) — fill, may crop overflow (wrap clipsContent)
324
+ const scale = meetOrSlice === 'slice' ? Math.max(sx, sy) : Math.min(sx, sy);
325
+ const targetW = baseW * scale;
326
+ const targetH = baseH * scale;
327
+ resizeSvgNodeTo(inner, targetW, targetH);
328
+ // Scale strokes uniformly to keep visual stroke proportions matching
329
+ // the browser (where strokes scale with the viewBox).
330
+ if ('findAll' in inner) {
331
+ const vectors = (inner as FrameNode).findAll(
332
+ (n: SceneNode) => n.type === 'VECTOR'
333
+ ) as VectorNode[];
334
+ for (const v of vectors) {
335
+ let base = baseStrokeWeights.get(v);
336
+ if (base == null) {
337
+ const sw = v.strokeWeight;
338
+ if (typeof sw === 'number' && sw > 0) {
339
+ base = sw;
340
+ baseStrokeWeights.set(v, sw);
341
+ }
342
+ }
343
+ if (base != null && Number.isFinite(scale) && scale > 0) {
344
+ try { v.strokeWeight = base * scale; } catch (_e) { /* ignore */ }
345
+ }
346
+ }
347
+ }
348
+ if ('x' in inner && 'width' in inner && 'x' in wrapped && 'width' in wrapped) {
349
+ try {
350
+ const ix =
351
+ alignX === 'min' ? 0
352
+ : alignX === 'max' ? (w - targetW)
353
+ : (w - targetW) / 2;
354
+ const iy =
355
+ alignY === 'min' ? 0
356
+ : alignY === 'max' ? (h - targetH)
357
+ : (h - targetH) / 2;
358
+ (inner as FrameNode).x = Math.round(ix);
359
+ (inner as FrameNode).y = Math.round(iy);
360
+ } catch (_e) {
361
+ // ignore
362
+ }
363
+ }
364
+ });
365
+ }
366
+ }
367
+
368
+ function nodeHasClipPathDescendant(node: NodeIR): boolean {
369
+ if (node.kind === 'text' || node.kind === 'divider') return false;
370
+ if (node.kind === 'ring') return nodeHasClipPathDescendant(node.child);
371
+ if (node.kind === 'fragment') {
372
+ for (let i = 0; i < node.children.length; i++) {
373
+ if (nodeHasClipPathDescendant(node.children[i])) return true;
374
+ }
375
+ return false;
376
+ }
377
+ if (node.kind === 'element') {
378
+ if (parseClipPathFromStyle(node.props.style)) return true;
379
+ }
380
+ for (let i = 0; i < node.children.length; i++) {
381
+ if (nodeHasClipPathDescendant(node.children[i])) return true;
382
+ }
383
+ return false;
384
+ }
385
+
386
+ /**
387
+ * Render a component using its internal jsxTree structure.
388
+ * This handles components with decorative elements (gradient backgrounds, blurred shapes, etc.)
389
+ * by rendering the component's actual DOM structure instead of a simple wrapper.
390
+ */
391
+ function renderComponentWithJsxTree(
392
+ jsxTree: JsxNode,
393
+ actualChildren: NodeIR[],
394
+ instanceProps: Record<string, string> | undefined,
395
+ colorGroup: Record<string, string>,
396
+ radiusGroup: Record<string, string> | null,
397
+ theme: string,
398
+ depth: number,
399
+ context: RenderContext
400
+ ): SceneNode | null {
401
+ // Convert the component's jsxTree to NodeIR and render it
402
+ // When we encounter {children} text, substitute with actualChildren
403
+ const substitutedTree = substituteChildrenInJsxTree(jsxTree, actualChildren, instanceProps, true);
404
+ if (!substitutedTree) return null;
405
+
406
+ // Resolve and render the substituted tree
407
+ const resolved = resolveNodeIR(substitutedTree);
408
+ if (!resolved) return null;
409
+
410
+ const nodeHelpers: NodeIRHelpers = {
411
+ getComponentDefByName: getComponentDefByName,
412
+ normalizeComponentDef: normalizeComponentDef,
413
+ getCompoundClasses: getCompoundClasses,
414
+ mergeClasses: mergeClasses,
415
+ };
416
+ const transformed = applyNodeTransforms(resolved, colorGroup, nodeHelpers);
417
+ const responsiveResolved = resolveNodeResponsiveClasses(transformed, context.maxWidth);
418
+ // Portal panels (Select dropdown, Popover, etc.) with no explicit width need
419
+ // an intrinsic-content width injected so their children render correctly.
420
+ // This pre-pass is a no-op for trees without __fromPortal nodes.
421
+ const withPortalWidths = injectPortalPanelWidths(responsiveResolved);
422
+ // Drawer/sheet panels with `h-full` get a synthetic pixel height so
423
+ // descendant `flex-1` siblings have vertical space to claim without
424
+ // requiring every ancestor in the tree to carry a fixed height.
425
+ const withPortalHeights = injectPortalPanelHeights(withPortalWidths);
426
+ const solved = solveLayoutWidths(withPortalHeights, context.maxWidth);
427
+ return buildFigmaNode(solved, colorGroup, radiusGroup, theme, depth, context);
428
+ }
429
+
430
+ /**
431
+ * Recursively substitute {children} text nodes with actual children from the story.
432
+ */
433
+ function substituteChildrenInJsxTree(
434
+ jsxNode: JsxNode,
435
+ actualChildren: NodeIR[],
436
+ instanceProps?: Record<string, string>,
437
+ isRoot: boolean = false
438
+ ): JsxNode | null {
439
+ if (jsxNode.type === 'text') {
440
+ const textNode = jsxNode as JsxText;
441
+ // Check if this is a {children} placeholder
442
+ if (textNode.content.trim() === '{children}') {
443
+ // Return a fragment-like structure - we'll handle this in element processing
444
+ return null; // Will be handled specially in element processing
445
+ }
446
+ return jsxNode;
447
+ }
448
+
449
+ const element = jsxNode as JsxElement;
450
+ const newChildren: JsxNode[] = [];
451
+
452
+ for (const child of element.children || []) {
453
+ if (child.type === 'text') {
454
+ const textChild = child as JsxText;
455
+ if (textChild.content.trim() === '{children}') {
456
+ // Convert NodeIR children back to JsxNode format for consistency
457
+ for (const actualChild of actualChildren) {
458
+ const jsxChild = nodeIRToJsxNode(actualChild);
459
+ if (jsxChild) {
460
+ newChildren.push(jsxChild);
461
+ }
462
+ }
463
+ continue;
464
+ }
465
+ }
466
+ const substituted = substituteChildrenInJsxTree(child, actualChildren, instanceProps, false);
467
+ if (substituted) {
468
+ newChildren.push(substituted);
469
+ }
470
+ }
471
+
472
+ const mergedProps: Record<string, string> = Object.assign({}, element.props || {});
473
+ if (isRoot && instanceProps) {
474
+ for (const key in instanceProps) {
475
+ if (key === 'children' || key === 'className') continue;
476
+ const value = instanceProps[key];
477
+ if (value === undefined) continue;
478
+ mergedProps[key] = value;
479
+ }
480
+ const baseClasses = splitClassName(mergedProps.className);
481
+ const extraClasses = splitClassName(instanceProps.className);
482
+ if (extraClasses.length > 0) {
483
+ mergedProps.className = mergeClasses(baseClasses, extraClasses).join(' ');
484
+ }
485
+ }
486
+
487
+ return {
488
+ type: 'element',
489
+ tagName: element.tagName,
490
+ isComponent: element.isComponent,
491
+ props: mergedProps,
492
+ children: newChildren,
493
+ } as JsxElement;
494
+ }
495
+
496
+ /**
497
+ * Check whether a component jsxTree explicitly exposes a {children} slot.
498
+ * If it does not, rendering the tree for an instance with children would drop
499
+ * those children, so callers should use the wrapper fallback instead.
500
+ */
501
+ function jsxTreeHasChildrenSlot(jsxNode: JsxNode | null | undefined): boolean {
502
+ if (!jsxNode) return false;
503
+ if (jsxNode.type === 'text') {
504
+ const content = (jsxNode as JsxText).content.trim();
505
+ return /^\{\s*children\s*\}$/.test(content);
506
+ }
507
+ const element = jsxNode as JsxElement;
508
+ for (const child of element.children || []) {
509
+ if (jsxTreeHasChildrenSlot(child)) return true;
510
+ }
511
+ return false;
512
+ }
513
+
514
+ /**
515
+ * Convert a NodeIR node back to JsxNode format.
516
+ */
517
+ function nodeIRToJsxNode(node: NodeIR): JsxNode | null {
518
+ if (node.kind === 'text') {
519
+ return { type: 'text', content: node.text } as JsxText;
520
+ }
521
+
522
+ if (node.kind === 'element' || node.kind === 'component') {
523
+ const children: JsxNode[] = [];
524
+ for (const child of node.children || []) {
525
+ const jsxChild = nodeIRToJsxNode(child);
526
+ if (jsxChild) {
527
+ children.push(jsxChild);
528
+ }
529
+ }
530
+ return {
531
+ type: 'element',
532
+ tagName: node.tagName,
533
+ isComponent: node.kind === 'component',
534
+ props: Object.assign({}, node.props, { className: node.classes.join(' ') }),
535
+ children,
536
+ } as JsxElement;
537
+ }
538
+
539
+ if (node.kind === 'fragment') {
540
+ // Wrap fragment children in a div
541
+ const children: JsxNode[] = [];
542
+ for (const child of node.children || []) {
543
+ const jsxChild = nodeIRToJsxNode(child);
544
+ if (jsxChild) {
545
+ children.push(jsxChild);
546
+ }
547
+ }
548
+ return {
549
+ type: 'element',
550
+ tagName: 'div',
551
+ isComponent: false,
552
+ props: {},
553
+ children,
554
+ } as JsxElement;
555
+ }
556
+
557
+ return null;
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // JSX Tree Rendering
562
+ // ---------------------------------------------------------------------------
563
+
564
+ /**
565
+ * Returns true when any direct IR child carries `ml-auto` (or `mx-auto`)
566
+ * on its className. `ml-auto` on a flex child means "push me along the
567
+ * primary axis"; the Figma equivalent is `primaryAxisAlignItems =
568
+ * 'SPACE_BETWEEN'` on the parent. The parent-side detection is needed
569
+ * because inline-text children (e.g. a `<span className="ml-auto">`
570
+ * containing only text) render via `createInlineTextNode` and never get
571
+ * a frame to carry the per-child `SELF_ALIGNMENT_NODES` mark that
572
+ * `deferred-layout.applyFullWidthIfPossible` reads. DropdownMenuShortcut
573
+ * was the canonical case — the inline-text path swallowed `ml-auto`
574
+ * entirely, leaving the shortcut anchored to the start of the item.
575
+ */
576
+ function hasAutoMarginChild(children: readonly NodeIR[] | undefined): boolean {
577
+ if (!Array.isArray(children)) return false;
578
+ for (const child of children) {
579
+ if (!child || typeof child !== 'object') continue;
580
+ if (child.kind !== 'element' && child.kind !== 'component') continue;
581
+ const cl = (child as { classes?: unknown }).classes;
582
+ if (!Array.isArray(cl)) continue;
583
+ for (const cls of cl) {
584
+ if (cls === 'ml-auto' || cls === 'mx-auto') return true;
585
+ }
586
+ }
587
+ return false;
588
+ }
589
+
590
+ /**
591
+ * Create a separator node for divide-y/divide-x
592
+ */
593
+ function createDivideSeparator(
594
+ direction: 'HORIZONTAL' | 'VERTICAL',
595
+ color: RGB,
596
+ parentWidth: number
597
+ ): FrameNode {
598
+ const sep = figma.createFrame();
599
+ sep.name = 'divider';
600
+ applyClipBehavior(sep, []);
601
+
602
+ if (direction === 'HORIZONTAL') {
603
+ // Horizontal line (for divide-y in vertical list)
604
+ sep.resize(Math.max(parentWidth, 100), 1);
605
+ sep.layoutAlign = 'STRETCH';
606
+ } else {
607
+ // Vertical line (for divide-x in horizontal list)
608
+ sep.resize(1, 24); // Height will adjust
609
+ sep.layoutGrow = 0;
610
+ sep.layoutAlign = 'STRETCH';
611
+ }
612
+
613
+ sep.fills = [{ type: 'SOLID', color: { r: color.r, g: color.g, b: color.b }, opacity: 1 }];
614
+ return sep;
615
+ }
616
+ function resolveCornerRadiusFromClasses(
617
+ classes: string[],
618
+ radiusGroup: Record<string, string> | null
619
+ ): number | null {
620
+ if (classes.includes('rounded-full')) return 9999;
621
+ if (classes.includes('rounded-none')) return 0;
622
+
623
+ for (const cls of classes) {
624
+ const match = cls.match(/^rounded-\[(\d+(?:\.\d+)?)px\]$/);
625
+ if (match) return parseFloat(match[1]);
626
+ }
627
+
628
+ const tokenMap: Record<string, string> = {
629
+ 'rounded-sm': 'sm',
630
+ 'rounded': 'base',
631
+ 'rounded-md': 'md',
632
+ 'rounded-lg': 'lg',
633
+ 'rounded-xl': 'xl',
634
+ 'rounded-2xl': '2xl',
635
+ 'rounded-3xl': '3xl',
636
+ 'rounded-4xl': '4xl',
637
+ };
638
+ const fallbackMap: Record<string, number> = {
639
+ sm: 2,
640
+ base: 4,
641
+ md: 6,
642
+ lg: 8,
643
+ xl: 12,
644
+ '2xl': 16,
645
+ '3xl': 24,
646
+ '4xl': 32,
647
+ };
648
+
649
+ for (const cls of classes) {
650
+ const tokenKey = tokenMap[cls];
651
+ if (!tokenKey) continue;
652
+ if (radiusGroup && radiusGroup[tokenKey]) {
653
+ return pxFromSizeToken(radiusGroup[tokenKey]);
654
+ }
655
+ if (fallbackMap[tokenKey] != null) return fallbackMap[tokenKey];
656
+ }
657
+
658
+ return null;
659
+ }
660
+
661
+ /**
662
+ * Strict variant of state-utils' isTruthyStateProp: only matches the
663
+ * recognised true-tokens (true, 1, 'true', '1', 'checked', 'on'). Used by
664
+ * the master-rendering paths where we need to distinguish "explicitly true"
665
+ * from the looser HTML-attribute truthiness that isTruthyStateProp gives.
666
+ *
667
+ * Local to ui-builder for now; lift to a shared module if a third caller
668
+ * needs the strict semantics.
669
+ */
670
+ function isExplicitlyTrueAttr(value: unknown): boolean {
671
+ if (value === true || value === 1) return true;
672
+ const normalized = String(value == null ? '' : value).trim().toLowerCase();
673
+ return normalized === 'true' || normalized === '1' || normalized === 'checked' || normalized === 'on';
674
+ }
675
+
676
+ function getUiStateVariantNames(def: ComponentDef): string[] {
677
+ const stateMap = def && def.states ? def.states : null;
678
+ if (!stateMap || typeof stateMap !== 'object') return ['default'];
679
+ const keys = Object.keys(stateMap).filter(function(key) { return key !== '__meta'; });
680
+ if (keys.length === 0) return ['default'];
681
+ keys.sort(function(a, b) {
682
+ if (a === 'default') return -1;
683
+ if (b === 'default') return 1;
684
+ return a.localeCompare(b);
685
+ });
686
+ return keys;
687
+ }
688
+
689
+ function getUiVisualStateVariantNames(def: ComponentDef): string[] {
690
+ const names = getUiStateVariantNames(def);
691
+ const out: string[] = [];
692
+ for (let i = 0; i < names.length; i++) {
693
+ if (names[i].toLowerCase() === 'checked') continue;
694
+ out.push(names[i]);
695
+ }
696
+ if (out.length === 0) out.push('default');
697
+ return out;
698
+ }
699
+
700
+ function resolveUiStateVariantName(def: ComponentDef, requested: unknown, includeChecked: boolean = true): string {
701
+ const requestedName = String(requested == null ? '' : requested).trim().toLowerCase();
702
+ const available = includeChecked ? getUiStateVariantNames(def) : getUiVisualStateVariantNames(def);
703
+ for (let i = 0; i < available.length; i++) {
704
+ if (available[i].toLowerCase() === requestedName) return available[i];
705
+ }
706
+ return 'default';
707
+ }
708
+
709
+ function getUiCvaSelectionFromInstance(def: ComponentDef, instance: ComponentInstance): Record<string, string> {
710
+ const props = instance && instance.props ? instance.props : {};
711
+ const selection: Record<string, string> = {};
712
+ const variants = (def && def.variants) || {};
713
+ const keys = Object.keys(variants);
714
+ for (let i = 0; i < keys.length; i++) {
715
+ const key = keys[i];
716
+ const values = Array.isArray(variants[key]) ? variants[key] : [];
717
+ const raw = props[key] != null && String(props[key]).trim() !== ''
718
+ ? String(props[key])
719
+ : (def && def.defaultVariants && def.defaultVariants[key] != null ? String(def.defaultVariants[key]) : '');
720
+ let selected = raw;
721
+ if (!selected && values.length > 0) selected = String(values[0]);
722
+ if (selected) selection[key] = selected;
723
+ }
724
+ return selection;
725
+ }
726
+
727
+ function getUiStateVariantForInstance(def: ComponentDef, instance: ComponentInstance): string {
728
+ const props = instance && instance.props ? instance.props : {};
729
+ const explicitState = props['data-state'] != null ? props['data-state'] : props.state;
730
+ if (explicitState != null && String(explicitState).trim() !== '') {
731
+ return resolveUiStateVariantName(def, explicitState, false);
732
+ }
733
+ if (isExplicitlyTrueAttr(props.disabled) || isExplicitlyTrueAttr(props['aria-disabled'])) {
734
+ return resolveUiStateVariantName(def, 'disabled', false);
735
+ }
736
+ if (isExplicitlyTrueAttr(props['aria-invalid'])) {
737
+ return resolveUiStateVariantName(def, 'error', false);
738
+ }
739
+ return 'default';
740
+ }
741
+
742
+ function uiIsCheckedInstance(instance: ComponentInstance): boolean {
743
+ const props = instance && instance.props ? instance.props : {};
744
+ return isExplicitlyTrueAttr(props.checked)
745
+ || isExplicitlyTrueAttr(props.defaultChecked)
746
+ || isExplicitlyTrueAttr(props['aria-checked']);
747
+ }
748
+
749
+ function uiHasCheckedStateVariant(def: ComponentDef): boolean {
750
+ const names = getUiStateVariantNames(def);
751
+ for (let i = 0; i < names.length; i++) {
752
+ if (names[i].toLowerCase() === 'checked') return true;
753
+ }
754
+ return false;
755
+ }
756
+
757
+ function getInstanceTextOverride(def: ComponentDef, instance: ComponentInstance): string | null {
758
+ const props = instance && instance.props ? instance.props : {};
759
+ if (instance && instance.children != null && String(instance.children).trim() !== '') {
760
+ return String(instance.children);
761
+ }
762
+ if (props.children != null && String(props.children).trim() !== '') {
763
+ return String(props.children);
764
+ }
765
+ if (def && def.type === 'state') {
766
+ if (props.label != null && String(props.label).trim() !== '') return String(props.label);
767
+ if (props.placeholder != null && String(props.placeholder).trim() !== '') return String(props.placeholder);
768
+ }
769
+ return null;
770
+ }
771
+
772
+ function getExistingCvaComponentSet(def: ComponentDef, theme: string): SceneNode | null {
773
+ if (!def || !def.name) return null;
774
+ return findExistingThemedComponentNode(theme, String(def.name) + ' [' + String(theme || 'primary') + ']');
775
+ }
776
+
777
+ /**
778
+ * `uiInstanceBackend` is called from `buildFigmaNode`'s component branch when a
779
+ * CVA component (e.g. `<Badge variant="soft">`) appears INSIDE another story's
780
+ * jsxTree (e.g. `HowItWorksSection` referencing `<Badge>`). The earlier
781
+ * implementation only did a lookup via `findExistingThemedComponentNode` — if
782
+ * the referenced component hadn't been pre-warmed by `warmSymbolMasters`
783
+ * (which happens when the user selects a subset in the preflight, or renders
784
+ * a story before its dependency's master exists), the lookup returned null
785
+ * and the Badge fell back to a flat frame. Route through the full
786
+ * `ensureCvaComponentSet` so a master is created on demand. It internally
787
+ * reuses matching-hash masters, so this is a no-op when one already exists.
788
+ *
789
+ * Recursion guard: `ensureCvaComponentSetFromStory` builds the master by
790
+ * rendering the component's own story jsxTree, which has the component at
791
+ * its root — that render enters `buildFigmaNode`'s component branch and, if
792
+ * unguarded, calls back into this function, looping forever. Track the
793
+ * (def, theme) pairs currently being built so reentrant calls return the
794
+ * existing master or null (falling back to flat-frame rendering).
795
+ */
796
+ const CVA_MASTERS_IN_FLIGHT = new Set<string>();
797
+
798
+ function ensureCvaComponentSetForUi(def: ComponentDef, theme: string, ctx: StoryBuilderContext | null): SceneNode | null {
799
+ if (!def || !def.name) return null;
800
+ const key = String(def.name) + '|' + String(theme || 'primary');
801
+ if (CVA_MASTERS_IN_FLIGHT.has(key)) {
802
+ return getExistingCvaComponentSet(def, theme);
803
+ }
804
+ CVA_MASTERS_IN_FLIGHT.add(key);
805
+ try {
806
+ const effectiveCtx = (ctx || getStoryBuilderContext()) as StoryBuilderContext;
807
+ try {
808
+ return ensureCvaComponentSetFromStory(def, theme, effectiveCtx);
809
+ } catch (_err) {
810
+ return getExistingCvaComponentSet(def, theme);
811
+ }
812
+ } finally {
813
+ CVA_MASTERS_IN_FLIGHT.delete(key);
814
+ }
815
+ }
816
+
817
+ function getExistingStateComponentSet(def: ComponentDef, theme: string): SceneNode | null {
818
+ if (!def || !def.name) return null;
819
+ return findExistingThemedComponentNode(theme, String(def.name) + ' [' + String(theme || 'primary') + ']');
820
+ }
821
+
822
+ function getExistingNonCvaComponentMaster(def: ComponentDef, theme: string): SceneNode | null {
823
+ if (!def || !def.name) return null;
824
+ const node = findExistingThemedComponentNode(theme, String(def.name));
825
+ if (!node) return null;
826
+ if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') return node;
827
+ return null;
828
+ }
829
+
830
+ const uiInstanceBackend: InstanceBackend = {
831
+ enableSymbolMasters: true,
832
+ splitClassName: splitClassName,
833
+ ensureCvaComponentSet: ensureCvaComponentSetForUi,
834
+ ensureStateComponentSet: getExistingStateComponentSet,
835
+ ensureNonCvaComponentMaster: getExistingNonCvaComponentMaster,
836
+ getInstantiableComponent: getInstantiableComponent,
837
+ getCvaSelectionFromInstance: getUiCvaSelectionFromInstance,
838
+ toFigmaVariantPropertyName: toFigmaVariantPropertyName,
839
+ toFigmaVariantPropertyValue: toFigmaVariantPropertyValue,
840
+ getStateVariantForInstance: getUiStateVariantForInstance,
841
+ isCheckedInstance: uiIsCheckedInstance,
842
+ hasCheckedStateVariant: uiHasCheckedStateVariant,
843
+ getInstanceTextOverride: getInstanceTextOverride,
844
+ applyTextOverrideToInstance: applyTextOverrideToInstance,
845
+ applyCompoundTextOverridesToInstance: function() {
846
+ return { bySlot: {}, bySubcomponent: {} };
847
+ },
848
+ analyzeSymbolPolicy: analyzeSymbolPolicy,
849
+ setGeneratedSymbolDebugData: setGeneratedSymbolDebugData,
850
+ };
851
+
852
+ function tryRenderSharedSymbolInstance(analysis: CvaAnalysis, instance: ComponentInstance, theme: string): SceneNode | null {
853
+ if (!analysis) return null;
854
+ if (analysis.type === 'cva') {
855
+ return tryCreateCvaComponentInstanceShared(analysis, instance, theme, null, uiInstanceBackend);
856
+ }
857
+ if (analysis.type === 'state' || analysis.type === 'simple' || analysis.type === 'compound') {
858
+ return tryCreateNonCvaComponentInstanceShared(analysis, instance, theme, null, uiInstanceBackend);
859
+ }
860
+ return null;
861
+ }
862
+
863
+ /**
864
+ * Renders a JSX tree from the scanner into Figma frames
865
+ */
866
+ function getStoryBuilderContext(): StoryBuilderContext {
867
+ return {
868
+ getComponentDefByName: getComponentDefByName,
869
+ normalizeComponentDef: normalizeComponentDef,
870
+ applyClipBehavior: applyClipBehavior,
871
+ applyLayoutClasses: applyLayoutClasses,
872
+ hasExplicitHeight: hasExplicitHeight,
873
+ renderJsxTree: renderJsxTree,
874
+ applyAbsoluteIfAllowed: applyAbsoluteIfAllowed,
875
+ applyGridColumnsWithReflow: applyGridColumnsWithReflow,
876
+ hasWidthHintInClasses: hasWidthHintInClasses,
877
+ propsContainWidthHint: propsContainWidthHint,
878
+ };
879
+ }
880
+
881
+
882
+ export function renderJsxTree(
883
+ node: JsxNode,
884
+ colorGroup: Record<string, string>,
885
+ radiusGroup: Record<string, string> | null,
886
+ theme: string,
887
+ depth: number = 0,
888
+ context: RenderContext = {}
889
+ ): SceneNode | null {
890
+ const resolved = resolveNodeIR(node);
891
+ if (!resolved) return null;
892
+ const nodeHelpers: NodeIRHelpers = {
893
+ getComponentDefByName: getComponentDefByName,
894
+ normalizeComponentDef: normalizeComponentDef,
895
+ getCompoundClasses: getCompoundClasses,
896
+ mergeClasses: mergeClasses,
897
+ };
898
+ const transformed = applyNodeTransforms(resolved, colorGroup, nodeHelpers);
899
+ const responsiveResolved = resolveNodeResponsiveClasses(transformed, context.maxWidth);
900
+ // Portal panels (Select dropdown, Popover, etc.) with no explicit width need
901
+ // an intrinsic-content width injected so their children render correctly.
902
+ // This pre-pass is a no-op for trees without __fromPortal nodes.
903
+ const withPortalWidths = injectPortalPanelWidths(responsiveResolved);
904
+ // Drawer/sheet panels with `h-full` get a synthetic pixel height so
905
+ // descendant `flex-1` siblings have vertical space to claim without
906
+ // requiring every ancestor in the tree to carry a fixed height.
907
+ const withPortalHeights = injectPortalPanelHeights(withPortalWidths);
908
+ const solved = solveLayoutWidths(withPortalHeights, context.maxWidth);
909
+ return buildFigmaNode(solved, colorGroup, radiusGroup, theme, depth, context);
910
+ }
911
+
912
+ function buildFigmaNode(
913
+ node: NodeIR,
914
+ colorGroup: Record<string, string>,
915
+ radiusGroup: Record<string, string> | null,
916
+ theme: string,
917
+ depth: number,
918
+ context: RenderContext
919
+ ): SceneNode | null {
920
+ if (node.kind === 'text') {
921
+ const opts: CreateTextOptions & { theme: string } = { fontSize: context.textFontSize || 14, theme };
922
+ if (context.textFontStyle != null) {
923
+ opts.fontStyle = context.textFontStyle;
924
+ } else if (context.textBold != null) {
925
+ opts.bold = context.textBold;
926
+ }
927
+ if (context.textLineHeight != null) {
928
+ opts.lineHeight = context.textLineHeight;
929
+ }
930
+ if (context.textColor) {
931
+ opts.fill = context.textColor;
932
+ }
933
+ if (context.textAlign) {
934
+ opts.textAlignHorizontal = context.textAlign;
935
+ }
936
+ const textNode = createTextNode(node.text, opts);
937
+ if (context.textTruncate && context.maxWidth) {
938
+ try {
939
+ const maxLines = context.textMaxLines || 1;
940
+ const fontSize = context.textFontSize || 14;
941
+ const lineH = context.textLineHeight || Math.ceil(fontSize * 1.5);
942
+ textNode.textTruncation = 'ENDING';
943
+ textNode.maxLines = maxLines;
944
+ textNode.resize(context.maxWidth, Math.max(lineH, lineH * maxLines));
945
+ } catch (_err) {
946
+ // ignore — truncation not supported in all plugin contexts
947
+ }
948
+ } else if (context.maxWidth && context.parentLayout !== 'HORIZONTAL') {
949
+ try {
950
+ textNode.textAutoResize = 'HEIGHT';
951
+ textNode.resize(context.maxWidth, textNode.height);
952
+ } catch (_err) {
953
+ // ignore resize errors
954
+ }
955
+ }
956
+ return textNode;
957
+ }
958
+
959
+ if (node.kind === 'fragment') {
960
+ if (node.children.length === 0) return null;
961
+ const wrapper = figma.createFrame();
962
+ wrapper.name = 'Fragment';
963
+ wrapper.layoutMode = 'VERTICAL';
964
+ wrapper.primaryAxisSizingMode = 'AUTO';
965
+ wrapper.counterAxisSizingMode = 'AUTO';
966
+ wrapper.fills = [];
967
+ applyClipBehavior(wrapper, []);
968
+ // React `<>...</>` is invisible in CSS — children are laid out as siblings
969
+ // of the fragment's parent. In Figma we can't inline children into an
970
+ // existing parent frame, so we wrap them in a frame and mimic the same
971
+ // behavior: stretch the wrapper to the parent's cross axis (so width
972
+ // cascades down to nav/list/section children) and mark the wrapper as
973
+ // wanting STRETCH so its own children inherit via applyChildProperties.
974
+ if (context.parentLayout === 'VERTICAL') {
975
+ wrapper.layoutAlign = 'STRETCH';
976
+ setFrameCrossAlign(wrapper, 'STRETCH');
977
+ }
978
+ for (const rawChild of node.children) {
979
+ const child = unwrapTransparentWrapper(rawChild);
980
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, context);
981
+ if (childNode) {
982
+ wrapper.appendChild(childNode);
983
+ if (isElementLikeNode(child)) {
984
+ LayoutParser.applyChildProperties(childNode, child.classes, wrapper);
985
+ }
986
+ applyAbsoluteIfPossible(childNode, wrapper);
987
+ stabilizeHorizontalStretchChild(childNode, wrapper);
988
+ constrainSingleHorizontalTextChild(childNode);
989
+ applyFullWidthIfPossible(childNode, wrapper, context.maxWidth != null ? { widthOverride: context.maxWidth } : undefined);
990
+ applyRingIfPossible(childNode, wrapper);
991
+ applyAspectRatioIfPossible(childNode);
992
+ // The just-resolved aspect ratio / full-width may have given this
993
+ // child its final dimensions, so resolve any deferred percent
994
+ // positions on ITS children against those dimensions.
995
+ if ('layoutMode' in childNode) {
996
+ applyDeferredPercentPositioning(childNode as FrameNode);
997
+ }
998
+ }
999
+ }
1000
+ applyDeferredBottomPositioning(wrapper);
1001
+ applyDeferredCenterYPositioning(wrapper);
1002
+ applyDeferredPercentPositioning(wrapper);
1003
+ reflowMxAutoChildren(wrapper);
1004
+ return wrapper;
1005
+ }
1006
+
1007
+ if (node.kind === 'divider') {
1008
+ const parentWidth = context.maxWidth || 200;
1009
+ return createDivideSeparator(node.direction, node.color, parentWidth);
1010
+ }
1011
+
1012
+ if (node.kind === 'ring') {
1013
+ const ring = figma.createFrame();
1014
+ ring.name = 'ring';
1015
+ ring.layoutMode = 'HORIZONTAL';
1016
+ ring.primaryAxisSizingMode = 'AUTO';
1017
+ ring.counterAxisSizingMode = 'AUTO';
1018
+ applyClipBehavior(ring, []);
1019
+ // Apply positioning-only classes (absolute, top-*, right-*, opacity-*, z-*)
1020
+ // to the ring frame so it can be correctly placed when wrapping an absolutely-
1021
+ // positioned element. The child re-applies ALL classes, so passing visual/
1022
+ // layout/sizing classes here doubles them (e.g. Card's `py-4` gets applied to
1023
+ // both ring and child → 32px padding instead of 16px, with the ring's bg-card
1024
+ // fill creating a visible gap between the ring stroke and the child's fill).
1025
+ if (node.classes && node.classes.length > 0) {
1026
+ const positioningClasses = node.classes.filter(cls => {
1027
+ const base = cls.includes(':') ? (cls.split(':').pop() || cls) : cls;
1028
+ return base === 'absolute' || base === 'fixed' || base === 'relative' || base === 'sticky'
1029
+ || /^-?(top|bottom|left|right|inset)(-|$)/.test(base)
1030
+ || /^z-/.test(base)
1031
+ || /^opacity-/.test(base);
1032
+ });
1033
+ if (positioningClasses.length > 0) {
1034
+ applyTailwindStylesToFrame(ring, positioningClasses, colorGroup, radiusGroup, theme);
1035
+ }
1036
+ }
1037
+ // Render ring as a stroke (CSS box-shadow equivalent), not a fill.
1038
+ // A fill-based ring bleeds through transparent child backgrounds; strokes match CSS behavior.
1039
+ ring.fills = [];
1040
+ ring.strokes = [{
1041
+ type: 'SOLID',
1042
+ color: { r: node.ringColor.r, g: node.ringColor.g, b: node.ringColor.b },
1043
+ opacity: node.ringOpacity,
1044
+ }];
1045
+ ring.strokeWeight = node.ringWidth;
1046
+ // CSS `ring-*` is a box-shadow outside the border, so draw the stroke
1047
+ // outside the frame edge. INSIDE hid the stroke when the child filled
1048
+ // the frame exactly (the bg-card fill painted over the 1px stroke band).
1049
+ ring.strokeAlign = 'OUTSIDE';
1050
+
1051
+ let baseRadius: number | null = null;
1052
+ if (isElementLikeNode(node.child)) {
1053
+ baseRadius = resolveCornerRadiusFromClasses(node.child.classes, radiusGroup);
1054
+ }
1055
+ if (baseRadius == null && radiusGroup && radiusGroup.base) {
1056
+ baseRadius = pxFromSizeToken(radiusGroup.base);
1057
+ }
1058
+ if (baseRadius == null) baseRadius = 0;
1059
+ ring.cornerRadius = baseRadius + node.offsetWidth;
1060
+
1061
+ let innerParent: FrameNode = ring;
1062
+ if (node.offsetWidth > 0) {
1063
+ // ring-offset-N — even without an explicit ring-offset-COLOR class
1064
+ // we still need the gap. CSS draws the offset as a transparent
1065
+ // band (inheriting the page background) that separates the inner
1066
+ // element's border from the outer ring stroke. Without this
1067
+ // wrapper, the ring stroke sits flush against the border and the
1068
+ // two visually merge into one thick line — the symptom: shadcn
1069
+ // Select trigger / Input focus state showing a single fat green
1070
+ // ring instead of "gray border + gap + green ring" like in the
1071
+ // browser.
1072
+ const offsetFrame = figma.createFrame();
1073
+ offsetFrame.name = 'ring-offset';
1074
+ offsetFrame.layoutMode = 'HORIZONTAL';
1075
+ offsetFrame.primaryAxisSizingMode = 'AUTO';
1076
+ offsetFrame.counterAxisSizingMode = 'AUTO';
1077
+ applyClipBehavior(offsetFrame, []);
1078
+ if (node.offsetColor) {
1079
+ offsetFrame.fills = [{
1080
+ type: 'SOLID',
1081
+ color: { r: node.offsetColor.r, g: node.offsetColor.g, b: node.offsetColor.b },
1082
+ opacity: 1,
1083
+ }];
1084
+ } else {
1085
+ // Transparent gap — matches CSS's default offset behaviour when
1086
+ // no `ring-offset-COLOR` was set.
1087
+ offsetFrame.fills = [];
1088
+ }
1089
+ offsetFrame.strokes = [];
1090
+ offsetFrame.paddingLeft = offsetFrame.paddingRight = offsetFrame.paddingTop = offsetFrame.paddingBottom = node.offsetWidth;
1091
+ offsetFrame.cornerRadius = baseRadius + node.offsetWidth;
1092
+ ring.appendChild(offsetFrame);
1093
+ innerParent = offsetFrame;
1094
+ }
1095
+
1096
+ // Strip absolute-positioning classes from the child — they've been applied to the
1097
+ // ring frame above so the ring is correctly placed in the parent. If kept on the
1098
+ // child, the child would be positioned absolutely inside the ring (with a negative x),
1099
+ // pushing the icon outside the ring bounds.
1100
+ const ringIsAbsolute = (node.classes || []).some((c: string) => {
1101
+ const base = c.includes(':') ? (c.split(':').pop() || c) : c;
1102
+ return base === 'absolute' || base === 'fixed';
1103
+ });
1104
+ const isPositioningClass = (c: string) => {
1105
+ const base = c.includes(':') ? (c.split(':').pop() || c) : c;
1106
+ return base === 'absolute' || base === 'fixed' || base === 'relative'
1107
+ || /^-?(top|bottom|left|right|inset)(-|$)/.test(base);
1108
+ };
1109
+ const childForBuild = (ringIsAbsolute && isElementLikeNode(node.child))
1110
+ ? Object.assign({}, node.child, { classes: node.child.classes.filter((c: string) => !isPositioningClass(c)) })
1111
+ : node.child;
1112
+ const childLayoutClasses = isElementLikeNode(childForBuild) ? (childForBuild.classes || []) : [];
1113
+ // The ring frame is HORIZONTAL with hug sizing — override the inherited context
1114
+ // so the child doesn't think it's inside the grandparent (e.g. a VERTICAL dialog
1115
+ // content frame) and stretch to its maxWidth.
1116
+ const ringChildContext: RenderContext = {
1117
+ ...context,
1118
+ parentLayout: 'HORIZONTAL',
1119
+ maxWidth: undefined,
1120
+ };
1121
+ const childNode = buildFigmaNode(childForBuild, colorGroup, radiusGroup, theme, depth + 1, ringChildContext);
1122
+ if (childNode) {
1123
+ innerParent.appendChild(childNode);
1124
+ if (childLayoutClasses.length > 0) {
1125
+ LayoutParser.applyChildProperties(childNode, childLayoutClasses, innerParent);
1126
+ }
1127
+ applyAbsoluteIfPossible(childNode, innerParent);
1128
+ stabilizeHorizontalStretchChild(childNode, innerParent);
1129
+ constrainSingleHorizontalTextChild(childNode);
1130
+ if (!ringIsAbsolute) {
1131
+ applyFullWidthIfPossible(childNode, innerParent);
1132
+ }
1133
+ applyRingIfPossible(childNode, innerParent);
1134
+ return maybeWrapMxAuto(ring, node.classes, context);
1135
+ }
1136
+
1137
+ return null;
1138
+ }
1139
+
1140
+ const tagLower = node.tagLower;
1141
+ if ((node.kind === 'component' || node.kind === 'element') && isSelectScrollButtonTag(node.tagName)) {
1142
+ // Radix mounts these controls only when scrolling is possible.
1143
+ // Rendering them statically in Figma adds noisy chevrons for short lists.
1144
+ return null;
1145
+ }
1146
+ if (isSelectItemIndicatorTag(node.tagName) && context.selectItemSelected === false) {
1147
+ return null;
1148
+ }
1149
+ // Portal popper arrow decorations (Popover/Tooltip/DropdownMenu) only make
1150
+ // visual sense pointing at a trigger. In Figma the panel renders in-flow, so
1151
+ // the arrow has nothing to point at — and rendering it as an empty frame
1152
+ // adds a ghost 100px tall block to the panel's HUG height.
1153
+ if (isPortalArrowTag(node.tagName)) {
1154
+ return null;
1155
+ }
1156
+ if (isSelectItemTag(node.tagName)) {
1157
+ const selected = resolveSelectItemSelected(node as NodeIRElement, context);
1158
+ if (selected != null) {
1159
+ if (!node.props) {
1160
+ node.props = {};
1161
+ }
1162
+ if (node.props!['data-state'] == null) {
1163
+ node.props!['data-state'] = selected ? 'checked' : 'unchecked';
1164
+ }
1165
+ if (node.props!['aria-selected'] == null) {
1166
+ node.props!['aria-selected'] = selected ? 'true' : 'false';
1167
+ }
1168
+ // Radix highlights the selected item by default when the menu opens, so
1169
+ // render the selected row with `data-highlighted` set in Figma too.
1170
+ if (selected && node.props!['data-highlighted'] == null) {
1171
+ node.props!['data-highlighted'] = '';
1172
+ }
1173
+ }
1174
+ // When the menu opens with no selection, Radix highlights the first item.
1175
+ // Only set `data-highlighted` (no data-state / aria-selected) so the row
1176
+ // picks up the highlight style without rendering the checkmark indicator.
1177
+ if (
1178
+ context.selectHighlightedValue != null
1179
+ && node.props?.['data-highlighted'] == null
1180
+ && normalizeSelectableValue(node.props?.value) === context.selectHighlightedValue
1181
+ ) {
1182
+ if (!node.props) node.props = {};
1183
+ node.props!['data-highlighted'] = '';
1184
+ }
1185
+ }
1186
+ if (isSelectTriggerTag(node.tagName) && context.selectIsOpen) {
1187
+ if (!node.props) {
1188
+ node.props = {};
1189
+ }
1190
+ if (node.props!['data-state'] == null) {
1191
+ node.props!['data-state'] = 'open';
1192
+ }
1193
+ // Radix moves focus to the trigger when the menu opens. shadcn triggers
1194
+ // show the focus ring only via `focus:ring-*` utilities, so promote those
1195
+ // to active classes so the open trigger renders with its focus ring.
1196
+ const promoted = promoteFocusVariants(node.classes);
1197
+ if (promoted !== node.classes) {
1198
+ node.classes = promoted;
1199
+ }
1200
+ }
1201
+ if (isRadioGroupIndicatorTag(node.tagName) && context.radioItemChecked === false) {
1202
+ return null;
1203
+ }
1204
+ if (isRadioGroupItemTag(node.tagName)) {
1205
+ const derivedChecked = resolveRadioItemChecked(node as NodeIRElement, context);
1206
+ if (derivedChecked === true) {
1207
+ if (!node.props) {
1208
+ node.props = {};
1209
+ }
1210
+ if (node.props!['data-checked'] == null) {
1211
+ node.props!['data-checked'] = 'true';
1212
+ }
1213
+ if (node.props!.checked == null) {
1214
+ node.props!.checked = 'true';
1215
+ }
1216
+ }
1217
+ }
1218
+ if (isTabsTriggerTag(node.tagName) || isTabsContentTag(node.tagName)) {
1219
+ const active = resolveTabsNodeActive(node as NodeIRElement, context);
1220
+ if (active != null) {
1221
+ if (!node.props) {
1222
+ node.props = {};
1223
+ }
1224
+ // Support both Radix-like data-[state=active] and Base UI data-[active] selectors.
1225
+ if (node.props!['data-state'] == null) {
1226
+ node.props!['data-state'] = active ? 'active' : 'inactive';
1227
+ }
1228
+ if (active) {
1229
+ node.props!['data-active'] = 'true';
1230
+ } else if (node.props!['data-active'] != null) {
1231
+ delete node.props!['data-active'];
1232
+ }
1233
+ if (isTabsTriggerTag(node.tagName)) {
1234
+ if (node.props!['aria-selected'] == null) {
1235
+ node.props!['aria-selected'] = active ? 'true' : 'false';
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+ // Use the story-level viewportWidth (constant through the tree) for
1241
+ // responsive breakpoint resolution, NOT the element's local maxWidth
1242
+ // (which is the container's content width and shrinks as we descend).
1243
+ // CSS @media queries match the viewport — a `lg:size-16` icon should
1244
+ // apply at the lg breakpoint regardless of how nested it is inside
1245
+ // narrower containers.
1246
+ const breakpointWidthInput = context.viewportWidth
1247
+ ?? (context.maxWidth != null ? context.maxWidth : defaultGridWidth(3));
1248
+ const breakpointIndex = resolveBreakpointIndexForWidth(breakpointWidthInput);
1249
+ let classes = resolveClassesForBreakpoint(node.classes, breakpointIndex);
1250
+ if (isAccordionHeaderTag(node.tagName) && !classes.includes('w-full')) {
1251
+ classes = mergeClasses(classes, ['w-full']);
1252
+ }
1253
+ classes = expandActiveConditionalVariants(classes, node);
1254
+ const layoutComputed = getNodeLayoutComputed(node);
1255
+ const alignFromClasses = getTextAlignFromClasses(classes);
1256
+ const textStyle = getNodeTextStyle(node, colorGroup);
1257
+ const textToken = textStyle.textToken || extractTextColorToken(classes) || context.textColorToken || null;
1258
+ const fallbackTextColor = resolveTextColorFallback(classes, colorGroup, theme);
1259
+ const ownTextColor = resolveTextColorValue(
1260
+ textStyle.text || fallbackTextColor,
1261
+ textToken,
1262
+ colorGroup,
1263
+ theme
1264
+ );
1265
+ const ownTextFontSize = getResolvedTextSizeFromClasses(classes, context.textFontSize);
1266
+ const ownTextBold = getResolvedBoldFromClasses(classes, context.textBold);
1267
+ const ownTextFontStyle = getFontStyleFromClasses(classes, context.textFontStyle);
1268
+ const ownTextLineHeight = ownTextFontSize != null
1269
+ ? (getLineHeightFromClasses(classes, ownTextFontSize) ?? context.textLineHeight)
1270
+ : context.textLineHeight;
1271
+ const inheritedTextColor = resolveTextColorValue(
1272
+ context.textColor,
1273
+ context.textColorToken,
1274
+ colorGroup,
1275
+ theme
1276
+ );
1277
+ const resolvedTextColor = ownTextColor || inheritedTextColor;
1278
+ let contextualMaxWidth = context.maxWidth;
1279
+ if (layoutComputed.explicitWidth != null) {
1280
+ contextualMaxWidth = layoutComputed.explicitWidth;
1281
+ } else if (layoutComputed.maxWidth != null) {
1282
+ contextualMaxWidth = context.maxWidth != null
1283
+ ? Math.min(layoutComputed.maxWidth, context.maxWidth)
1284
+ : layoutComputed.maxWidth;
1285
+ }
1286
+ // Detect text truncation from classes on this element; reset per-element (don't inherit)
1287
+ let _truncate: boolean | undefined;
1288
+ let _maxLines: number | undefined;
1289
+ if (classes.includes('truncate')) {
1290
+ _truncate = true; _maxLines = 1;
1291
+ } else {
1292
+ for (const cls of classes) {
1293
+ const m = cls.match(/^line-clamp-(\d+)$/);
1294
+ if (m) { _truncate = true; _maxLines = parseInt(m[1], 10); break; }
1295
+ }
1296
+ }
1297
+ const nextContext: RenderContext = {
1298
+ ...context,
1299
+ textAlign: alignFromClasses || context.textAlign,
1300
+ maxWidth: contextualMaxWidth,
1301
+ textFontSize: ownTextFontSize,
1302
+ textBold: ownTextBold,
1303
+ textFontStyle: ownTextFontStyle,
1304
+ textLineHeight: ownTextLineHeight,
1305
+ textColor: resolvedTextColor,
1306
+ textColorToken: textToken || context.textColorToken,
1307
+ textTruncate: _truncate,
1308
+ textMaxLines: _maxLines,
1309
+ };
1310
+
1311
+ if (isAccordionRootTag(node.tagName)) {
1312
+ nextContext.accordionOpenValue = normalizeAccordionDefaultValue(node.props?.defaultValue);
1313
+ nextContext.accordionItemIndex = undefined;
1314
+ nextContext.accordionItemValue = null;
1315
+ nextContext.accordionItemIsOpen = false;
1316
+ } else if (isAccordionItemTag(node.tagName)) {
1317
+ const accordionItemValue = resolveAccordionItemValue(node.props?.value, context.accordionItemIndex);
1318
+ nextContext.accordionItemValue = accordionItemValue;
1319
+ nextContext.accordionItemIsOpen = !!accordionItemValue &&
1320
+ !!nextContext.accordionOpenValue &&
1321
+ accordionItemValue === nextContext.accordionOpenValue;
1322
+ }
1323
+
1324
+ if (isTabsRootTag(node.tagName)) {
1325
+ nextContext.tabsSelectedValue = normalizeSelectableValue(node.props?.value)
1326
+ || normalizeSelectableValue(node.props?.defaultValue);
1327
+ }
1328
+
1329
+ if (isSelectRootTag(node.tagName)) {
1330
+ nextContext.selectSelectedValue = normalizeSelectableValue(node.props?.value)
1331
+ || normalizeSelectableValue(node.props?.defaultValue);
1332
+ nextContext.selectSelectedLabel = nextContext.selectSelectedValue
1333
+ ? findSelectedSelectLabel(node, nextContext.selectSelectedValue)
1334
+ : null;
1335
+ nextContext.selectItemValue = null;
1336
+ nextContext.selectItemSelected = undefined;
1337
+ nextContext.selectIsOpen = isTruthyStateProp(node.props?.open)
1338
+ || isTruthyStateProp(node.props?.defaultOpen);
1339
+ // Radix highlights the first item when the menu opens with no selection.
1340
+ nextContext.selectHighlightedValue = !nextContext.selectSelectedValue && nextContext.selectIsOpen
1341
+ ? findFirstSelectItemValue(node)
1342
+ : null;
1343
+ } else if (isSelectContentTag(node.tagName)) {
1344
+ // Select popovers should size to their content in Figma previews instead of inheriting
1345
+ // the story wrapper max width (e.g. w-56), which causes label wrapping.
1346
+ nextContext.maxWidth = undefined;
1347
+ } else if (isSelectItemTag(node.tagName)) {
1348
+ const itemValue = normalizeSelectableValue(node.props?.value);
1349
+ const selected = resolveSelectItemSelected(node as NodeIRElement, context);
1350
+ nextContext.selectItemValue = itemValue;
1351
+ nextContext.selectItemSelected = selected === true;
1352
+ }
1353
+
1354
+ if (isRadioGroupRootTag(node.tagName)) {
1355
+ nextContext.radioGroupSelectedValue = normalizeSelectableValue(node.props?.value)
1356
+ || normalizeSelectableValue(node.props?.defaultValue);
1357
+ nextContext.radioItemValue = null;
1358
+ nextContext.radioItemChecked = undefined;
1359
+ } else if (isRadioGroupItemTag(node.tagName)) {
1360
+ const itemValue = normalizeSelectableValue(node.props?.value);
1361
+ const checked = resolveRadioItemChecked(node as NodeIRElement, context);
1362
+ nextContext.radioItemValue = itemValue;
1363
+ nextContext.radioItemChecked = checked === true;
1364
+ }
1365
+
1366
+ if (isAccordionContentTag(node.tagName) && !context.accordionItemIsOpen) {
1367
+ return null;
1368
+ }
1369
+ if (isTabsContentTag(node.tagName)) {
1370
+ const forceMount = isTruthyStateProp(node.props?.forceMount);
1371
+ const active = resolveTabsNodeActive(node as NodeIRElement, context);
1372
+ if (!forceMount && active === false) {
1373
+ return null;
1374
+ }
1375
+ }
1376
+
1377
+ // Check for hidden classes - skip rendering unless overridden by a display class
1378
+ // In responsive contexts "hidden sm:flex" produces ["hidden","flex"]; the last display
1379
+ // class wins (same semantics as CSS), so we must not skip when a display override follows.
1380
+ if (classes.includes('hidden') || classes.includes('sr-only')) {
1381
+ const DISPLAY_OVERRIDES = new Set(['flex','inline-flex','block','inline-block','inline','grid','inline-grid','table','table-cell','table-row','contents','flow-root','list-item']);
1382
+ const hiddenIdx = Math.max(classes.lastIndexOf('hidden'), classes.lastIndexOf('sr-only'));
1383
+ const overridden = classes.slice(hiddenIdx + 1).some(c => DISPLAY_OVERRIDES.has(c));
1384
+ if (!overridden) {
1385
+ return null;
1386
+ }
1387
+ }
1388
+
1389
+ if ((node.kind === 'component' || node.kind === 'element') && isSelectValueTag(node.tagName)) {
1390
+ const selectedLabel = (context.selectSelectedLabel || '').trim();
1391
+ const selectedValue = (context.selectSelectedValue || '').trim();
1392
+ const placeholder = String((node.props && node.props.placeholder) || '').trim();
1393
+ const resolvedText = selectedLabel
1394
+ || (selectedValue ? prettySelectValueLabel(selectedValue) : '')
1395
+ || placeholder;
1396
+ if (!resolvedText) return null;
1397
+
1398
+ const textOpts: CreateTextOptions & { theme: string } = { fontSize: context.textFontSize || 14, theme };
1399
+ if (context.textFontStyle != null) {
1400
+ textOpts.fontStyle = context.textFontStyle;
1401
+ } else if (context.textBold != null) {
1402
+ textOpts.bold = context.textBold;
1403
+ }
1404
+ if (context.textLineHeight != null) {
1405
+ textOpts.lineHeight = context.textLineHeight;
1406
+ }
1407
+ if (context.textColor) {
1408
+ textOpts.fill = context.textColor;
1409
+ } else if (!selectedLabel && !selectedValue && placeholder) {
1410
+ textOpts.opacity = 0.4;
1411
+ }
1412
+ return createTextNode(resolvedText, textOpts);
1413
+ }
1414
+
1415
+ if (node.kind === 'element' && tagLower === 'svg') {
1416
+ const svgString = nodeIrToSvg(node);
1417
+ if (svgString) {
1418
+ const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme, node.props);
1419
+ const icon = createIconFromSvg(svgString, fgColor);
1420
+ if (icon) {
1421
+ const wrapped = wrapSvgIcon(icon, classes, node.props, 'icon/svg');
1422
+ // Inline SVGs that intend to fill their parent (e.g. an arrow-overlay
1423
+ // <svg className="absolute inset-0 h-full w-full">) take a default
1424
+ // 24×24 icon size from `resolveIconSizeFromClasses`. Propagate
1425
+ // fill-parent marks so the existing layout pipeline resizes the
1426
+ // wrapping frame to parent dimensions and applies any aspect ratio.
1427
+ applySvgLayoutMarksFromClasses(wrapped, classes, icon, node.props);
1428
+ return maybeWrapMxAuto(wrapped, classes, context);
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ // Image placeholder: `<img>`, Next.js `<Image>`, and any shadcn-style image
1434
+ // primitive that takes a `src` prop and renders no children
1435
+ // (`<AvatarImage>`, `<AvatarPrimitive.Image>`, ...). The latter get flattened
1436
+ // by `flattenComponentNodes` to `kind: 'element', tagLower: 'div'` with
1437
+ // `props.src` preserved — without this widened gate they'd fall into the
1438
+ // generic frame branch and the image would never render.
1439
+ const looksLikeImageSrc = typeof node.props?.src === 'string'
1440
+ && node.props.src.length > 0
1441
+ && (node.props.src.startsWith('/')
1442
+ || node.props.src.startsWith('http://')
1443
+ || node.props.src.startsWith('https://')
1444
+ || node.props.src.startsWith('./')
1445
+ || node.props.src.startsWith('../'));
1446
+ // Both `kind: 'element'` (post-flatten) AND `kind: 'component'`
1447
+ // (un-flattened, e.g. `<AvatarPrimitive.Image>` whose subComponent name
1448
+ // doesn't normalize cleanly enough for `getCompoundClasses` to match the
1449
+ // parent's registry) carry `props.src` identically. The image branch
1450
+ // should fire for either — the alternative is a wrapper-frame default
1451
+ // white frame and no SVG render at all.
1452
+ const isLeafImageLike = looksLikeImageSrc
1453
+ && (node.kind === 'element' || node.kind === 'component')
1454
+ && (!('children' in node) || !node.children || node.children.length === 0);
1455
+ if (tagLower === 'img' || node.tagName === 'Image' || isLeafImageLike) {
1456
+ const ir = layoutComputed.layoutIR;
1457
+ // FILL on either axis means the image should stretch to parent dimensions
1458
+ // (Tailwind `w-full`, `h-full`, or `size-full` — the latter is normalized
1459
+ // to `w-full h-full` at splitClassName time). When parent dimensions are
1460
+ // unknown at build time, fall back to the 200×150 placeholder; the
1461
+ // post-append `applyFullWidthIfPossible` pipeline (driven by the
1462
+ // FULL_WIDTH_NODES / FULL_HEIGHT_NODES marks set inside
1463
+ // `applyTailwindStylesToFrame`) will resize the frame to the parent.
1464
+ const wantsFillWidth = ir.widthMode === 'FILL';
1465
+ const wantsFillHeight = ir.heightMode === 'FILL';
1466
+ const w = ir.fixedWidth
1467
+ ?? (node.props.width != null ? parseFloat(String(node.props.width)) : null)
1468
+ ?? (wantsFillWidth ? 40 : 200);
1469
+ const h = ir.fixedHeight
1470
+ ?? (node.props.height != null ? parseFloat(String(node.props.height)) : null)
1471
+ ?? (wantsFillHeight ? 40 : 150);
1472
+ const imgFrame = figma.createFrame();
1473
+ imgFrame.name = node.props.alt ? String(node.props.alt) : 'image';
1474
+ // layoutMode must stay NONE (fixed-size image frame); don't set sizing mode properties
1475
+ imgFrame.layoutMode = 'NONE';
1476
+ // Default-clear the fill so a transparent frame is the floor for every
1477
+ // branch below. The raster path will replace fills with an IMAGE paint,
1478
+ // the placeholder path with a gray SOLID; the SVG branch leaves fills
1479
+ // empty and renders the vector as a child. Without this default, an SVG
1480
+ // branch that short-circuits (fetch race, alt-only image, ...) leaks
1481
+ // `createFrame()`'s default WHITE fill into the rendered output.
1482
+ imgFrame.fills = [];
1483
+ // Apply border radius / FULL_WIDTH/HEIGHT marks from Tailwind classes
1484
+ applyTailwindStylesToFrame(imgFrame, classes, colorGroup, radiusGroup, theme);
1485
+ // Resize after applyTailwindStylesToFrame in case it modifies the frame
1486
+ imgFrame.resize(Math.max(1, w), Math.max(1, h));
1487
+
1488
+ const src = node.props.src ? String(node.props.src) : null;
1489
+ const svgString = src ? getSvgString(src) : null;
1490
+ const imageHash = src ? getImageHash(src) : null;
1491
+
1492
+ if (svgString) {
1493
+ // SVG: embed the vector node INSIDE imgFrame so all the class-derived
1494
+ // marks (`absolute`, `inset-0`, `w-full`, `h-full`, border radius, ...)
1495
+ // accumulated on imgFrame by `applyTailwindStylesToFrame` apply to the
1496
+ // rendered output. Returning the bare svgNode (the old approach)
1497
+ // discarded every one of those marks and a `<AvatarImage
1498
+ // className="absolute inset-0 size-full">` lost its positioning AND
1499
+ // its parent-fill behaviour.
1500
+ try {
1501
+ const svgNode = figma.createNodeFromSvg(svgString);
1502
+ svgNode.name = node.props.alt ? String(node.props.alt) : 'image';
1503
+ const targetW = Math.max(1, w);
1504
+ const targetH = Math.max(1, h);
1505
+ const originalW = svgNode.width || targetW;
1506
+ const originalH = svgNode.height || targetH;
1507
+ svgNode.resize(targetW, targetH);
1508
+ // Figma keeps stroke weights as absolute pixels across resize; in the browser
1509
+ // SVG strokes scale with the viewBox. Scale vector strokes proportionally so a
1510
+ // 262-unit logo with stroke-width=28 doesn't keep a 28px stroke at 22px render.
1511
+ const scaleStrokes = (scale: number) => {
1512
+ if (!Number.isFinite(scale) || scale <= 0 || scale === 1) return;
1513
+ if (!('findAll' in svgNode)) return;
1514
+ const vectors = svgNode.findAll((c: SceneNode) => c.type === 'VECTOR') as VectorNode[];
1515
+ for (const v of vectors) {
1516
+ const sw = v.strokeWeight;
1517
+ if (typeof sw === 'number' && sw > 0) {
1518
+ try { v.strokeWeight = sw * scale; } catch (_err) { /* ignore */ }
1519
+ }
1520
+ }
1521
+ };
1522
+ scaleStrokes(Math.min(targetW / originalW, targetH / originalH));
1523
+ imgFrame.appendChild(svgNode);
1524
+ // Register a hook so the deferred-layout pipeline keeps the SVG
1525
+ // in sync when imgFrame is resized (e.g. via FULL_WIDTH/HEIGHT
1526
+ // marks resolving against a size-14 / size-20 parent). Without
1527
+ // this the SVG would stay at the build-time fallback (40×40)
1528
+ // even when the surrounding Avatar is larger.
1529
+ markSvgContentWrap(imgFrame, (newW: number, newH: number) => {
1530
+ try {
1531
+ const prevW = svgNode.width || newW;
1532
+ const prevH = svgNode.height || newH;
1533
+ svgNode.resize(Math.max(1, newW), Math.max(1, newH));
1534
+ scaleStrokes(Math.min(newW / prevW, newH / prevH));
1535
+ } catch (_err) { /* ignore */ }
1536
+ });
1537
+ return maybeWrapMxAuto(imgFrame, classes, context);
1538
+ } catch (_e) { /* fall through to placeholder */ }
1539
+ }
1540
+
1541
+ if (imageHash) {
1542
+ imgFrame.fills = [{ type: 'IMAGE', scaleMode: 'FILL', imageHash } as ImagePaint];
1543
+ } else {
1544
+ imgFrame.fills = [{ type: 'SOLID', color: { r: 0.878, g: 0.894, b: 0.914 }, opacity: 1 }];
1545
+ try {
1546
+ const placeholderSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
1547
+ const iconNode = figma.createNodeFromSvg(placeholderSvg);
1548
+ if (iconNode) {
1549
+ iconNode.x = Math.max(0, Math.round((w - 24) / 2));
1550
+ iconNode.y = Math.max(0, Math.round((h - 24) / 2));
1551
+ imgFrame.appendChild(iconNode);
1552
+ }
1553
+ } catch (_e) { /* createNodeFromSvg not available in all contexts */ }
1554
+ }
1555
+ return maybeWrapMxAuto(imgFrame, classes, context);
1556
+ }
1557
+
1558
+ if (node.kind === 'element') {
1559
+ const canTreatAsIcon = !node.children || node.children.length === 0;
1560
+ if (canTreatAsIcon) {
1561
+ const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme, node.props);
1562
+ const registryIcon = renderRegistryIcon(node.tagName, classes, node.props, fgColor);
1563
+ if (registryIcon) {
1564
+ return maybeWrapMxAuto(registryIcon, classes, context);
1565
+ }
1566
+
1567
+ const mappedIcon = renderMappedIcon(
1568
+ node.tagName,
1569
+ classes,
1570
+ node.props,
1571
+ fgColor,
1572
+ !!nextContext.accordionItemIsOpen
1573
+ );
1574
+ if (mappedIcon) {
1575
+ return maybeWrapMxAuto(mappedIcon, classes, context);
1576
+ }
1577
+ }
1578
+ }
1579
+
1580
+ // Check for React component (uppercase) - try to find definition
1581
+ if (node.kind === 'component') {
1582
+ const expanded = maybeExpandTextLineComponent(node);
1583
+ if (expanded) {
1584
+ return buildFigmaNode(expanded, colorGroup, radiusGroup, theme, depth, context);
1585
+ }
1586
+ // Check if this is a React Icon we can render.
1587
+ // Guard: if a component already has explicit children, preserve normal component layout.
1588
+ // This prevents primitives like Close (with nested icon + sr-only label) from being
1589
+ // collapsed into a single icon node.
1590
+ const canTreatAsIcon = !node.children || node.children.length === 0;
1591
+ if (canTreatAsIcon) {
1592
+ const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme, node.props);
1593
+ const registryIcon = renderRegistryIcon(node.tagName, classes, node.props, fgColor);
1594
+ if (registryIcon) {
1595
+ return maybeWrapMxAuto(registryIcon, classes, context);
1596
+ }
1597
+
1598
+ const mappedIcon = renderMappedIcon(
1599
+ node.tagName,
1600
+ classes,
1601
+ node.props,
1602
+ fgColor,
1603
+ !!nextContext.accordionItemIsOpen
1604
+ );
1605
+ if (mappedIcon) {
1606
+ return maybeWrapMxAuto(mappedIcon, classes, context);
1607
+ }
1608
+ }
1609
+
1610
+ let compDef = getComponentDefByName(node.tagName);
1611
+ const inferredCompDef = !compDef && node.tagName === 'Comp'
1612
+ ? inferCvaDefFromClasses(classes)
1613
+ : null;
1614
+ if (!compDef && inferredCompDef) {
1615
+ compDef = inferredCompDef;
1616
+ }
1617
+ if (compDef) {
1618
+ const analysis = normalizeComponentDef(compDef);
1619
+ // The scanner inlines shadcn primitives: <Button variant="x"> becomes
1620
+ // <ButtonPrimitive className="<resolved classes>">, <Checkbox/> becomes
1621
+ // <CheckboxPrimitive.Root className="<base+state classes>">. The baked
1622
+ // className trips `analyzeSymbolPolicy`'s class_override check and
1623
+ // forces tree fallback. When the lookup resolved via a non-exact name
1624
+ // (tagName !== def.name), strip/rebuild instance props so the symbol
1625
+ // path can succeed.
1626
+ const resolvedViaPrimitive = analysis.name !== node.tagName
1627
+ && typeof node.props?.className === 'string'
1628
+ && String(node.props.className).trim().length > 0;
1629
+ const shouldInferFromClasses = analysis.type === 'cva'
1630
+ && (!!inferredCompDef || resolvedViaPrimitive);
1631
+ let instanceProps: Record<string, string>;
1632
+ if (shouldInferFromClasses) {
1633
+ instanceProps = buildCvaInstanceProps(analysis, node.props, classes, true);
1634
+ } else if (resolvedViaPrimitive && (analysis.type === 'state' || analysis.type === 'simple')) {
1635
+ // For STATE / SIMPLE primitives: drop the scanner-resolved className
1636
+ // so the symbol-instance policy doesn't reject it for class_override.
1637
+ // The master already bakes the same base/state classes — the resolved
1638
+ // className is redundant in that case.
1639
+ //
1640
+ // COMPOUND components are intentionally excluded: their consumers
1641
+ // routinely customize the per-invocation className (`<Avatar
1642
+ // className="size-8">`, `<Card className="bg-muted">`, ...). The
1643
+ // master can only represent ONE baked-in size/style, so silently
1644
+ // stripping the className routes every instance through the master
1645
+ // and ignores the consumer's customization. Keep the className → the
1646
+ // symbol-instance policy correctly returns class_override and the
1647
+ // caller falls back to wrapper-frame rendering, where the consumer
1648
+ // className is honoured per-invocation.
1649
+ instanceProps = Object.assign({}, node.props);
1650
+ delete instanceProps.className;
1651
+ } else {
1652
+ instanceProps = Object.assign({}, node.props);
1653
+ }
1654
+ // Propagate JSX-element children into the synthetic instance's props as
1655
+ // `__jsxChildren` so `tryCreateCvaComponentInstance`'s
1656
+ // `cvaInstanceHasOverridingJsxChildren` guard fires for nested CVA
1657
+ // rendering too. Without this, only the story-ROOT scanner instance
1658
+ // carried `__jsxChildren` (and our guard only fired for that path);
1659
+ // every nested `<Toggle><Icon /></Toggle>` inside another component
1660
+ // silently went through the symbol-master path and inherited the
1661
+ // master's icon. See `cvaInstanceHasOverridingJsxChildren` docstring.
1662
+ // node.children at this point is NodeIR[] (post resolveNodeIR), so the
1663
+ // discriminator is `kind`, not `type`. NodeIR's 'element' / 'component'
1664
+ // both represent a JSX-element child — text-only siblings use 'text'
1665
+ // and don't count. Earlier this check used `c.type === 'element'`
1666
+ // which never matched and silently broke the guard.
1667
+ const nodeChildren = Array.isArray(node.children) ? node.children : [];
1668
+ const hasElementChild = nodeChildren.some((c: unknown) => {
1669
+ if (!c || typeof c !== 'object') return false;
1670
+ const k = (c as { kind?: string }).kind;
1671
+ return k === 'element' || k === 'component';
1672
+ });
1673
+ // The CVA fall-back guard in tryCreateCvaComponentInstance keys off
1674
+ // `__jsxChildren` of element type. NodeIR's `kind` doesn't survive
1675
+ // a round-trip into that helper, so synthesise a JSX-shape payload
1676
+ // (`{ type: 'element' }`) the guard understands.
1677
+ const syntheticJsxChildren = hasElementChild
1678
+ ? nodeChildren.map((c: unknown) => {
1679
+ if (!c || typeof c !== 'object') return c;
1680
+ const k = (c as { kind?: string }).kind;
1681
+ if (k === 'element' || k === 'component') {
1682
+ return Object.assign({}, c, { type: 'element' });
1683
+ }
1684
+ return c;
1685
+ })
1686
+ : nodeChildren;
1687
+ const instanceProps2 = hasElementChild
1688
+ ? Object.assign({}, instanceProps, { __jsxChildren: syntheticJsxChildren })
1689
+ : instanceProps;
1690
+ const instance = {
1691
+ props: instanceProps2,
1692
+ children: collectTextContent(node),
1693
+ };
1694
+
1695
+ const symbolInstance = tryRenderSharedSymbolInstance(analysis, instance, theme);
1696
+ if (symbolInstance) {
1697
+ return maybeWrapMxAuto(symbolInstance, classes, context);
1698
+ }
1699
+ if (!node.children || node.children.length === 0) {
1700
+ // For simple components used inline in jsxTree (e.g. <Box className="h-4 w-48" />),
1701
+ // render as a styled frame using the instance's className merged with base visual classes
1702
+ if (analysis.type === 'simple' && node.props.className) {
1703
+ const instanceClasses = node.classes.slice();
1704
+ const compClasses = analysis.classes || [];
1705
+ const baseVisualClasses = compClasses.filter((c: string) =>
1706
+ c.startsWith('bg-') || (c.startsWith('rounded') && !c.includes('/'))
1707
+ );
1708
+ const allClasses = mergeClasses(baseVisualClasses, instanceClasses);
1709
+
1710
+ const simpleFrame = figma.createFrame();
1711
+ simpleFrame.name = node.tagName;
1712
+ simpleFrame.layoutMode = 'VERTICAL';
1713
+ simpleFrame.primaryAxisSizingMode = 'FIXED';
1714
+ simpleFrame.counterAxisSizingMode = 'FIXED';
1715
+ simpleFrame.fills = [];
1716
+ applyClipBehavior(simpleFrame, allClasses);
1717
+ if (allClasses.length > 0) {
1718
+ applyTailwindStylesToFrame(simpleFrame, allClasses, colorGroup, radiusGroup, theme);
1719
+ }
1720
+ return maybeWrapMxAuto(simpleFrame, allClasses, context);
1721
+ }
1722
+
1723
+ // If the component has a jsxTree (from its own implementation), render it directly
1724
+ // (e.g. <HeroSection /> inline with no children expands to the full component structure)
1725
+ if (analysis.type === 'simple' && analysis.jsxTree) {
1726
+ const result = renderComponentWithJsxTree(
1727
+ analysis.jsxTree,
1728
+ [],
1729
+ node.props,
1730
+ colorGroup,
1731
+ radiusGroup,
1732
+ theme,
1733
+ depth,
1734
+ nextContext
1735
+ );
1736
+ if (result) {
1737
+ return maybeWrapMxAuto(result, classes, context);
1738
+ }
1739
+ }
1740
+
1741
+ const frame = figma.createFrame();
1742
+ frame.name = node.tagName;
1743
+ frame.layoutMode = 'VERTICAL';
1744
+ frame.primaryAxisSizingMode = 'AUTO';
1745
+ frame.counterAxisSizingMode = 'AUTO';
1746
+ frame.fills = [];
1747
+ applyClipBehavior(frame, classes);
1748
+
1749
+ const comp = createCompoundComponent(frame, analysis, theme);
1750
+ if (comp) return maybeWrapMxAuto(comp, classes, context);
1751
+ }
1752
+ }
1753
+
1754
+ // If this component has children in the jsxTree, render them
1755
+ // (wrapper components should show their content)
1756
+ if (node.children && node.children.length > 0) {
1757
+ // Transparent context-provider wrappers (e.g. PopoverPrimitive.Root,
1758
+ // TooltipPrimitive.Provider, SelectPrimitive.Root) have no className and
1759
+ // no component definition — they exist only to provide React context.
1760
+ // Skip the wrapper and render the single visual child directly so it
1761
+ // is not buried inside an unsized frame.
1762
+ const isTriggerWrapper = node.tagName.toLowerCase().endsWith('trigger');
1763
+ if (!compDef && classes.length === 0 && node.children.length === 1 && !isTriggerWrapper) {
1764
+ return buildFigmaNode(node.children[0], colorGroup, radiusGroup, theme, depth, context);
1765
+ }
1766
+ // Get compound classes - try own definition first, then parent's subComponents
1767
+ let compoundClasses: string[] = [];
1768
+ let currentCompoundDef: ComponentDef | null = null;
1769
+ if (compDef) {
1770
+ const normalizedDef = normalizeComponentDef(compDef);
1771
+ // Get subComponent classes if this is a compound component
1772
+ compoundClasses = getCompoundClasses(normalizedDef, node.tagName);
1773
+ if (normalizedDef.type === 'compound') {
1774
+ currentCompoundDef = normalizedDef;
1775
+ }
1776
+ // Check if this component has a jsxTree structure (for components with decorative elements)
1777
+ if (normalizedDef.type === 'simple' && normalizedDef.jsxTree) {
1778
+ const hasInstanceChildren = (node.children?.length ?? 0) > 0;
1779
+ const canRenderTree = !hasInstanceChildren || jsxTreeHasChildrenSlot(normalizedDef.jsxTree);
1780
+ if (canRenderTree) {
1781
+ // Render the component's internal structure from jsxTree
1782
+ const result = renderComponentWithJsxTree(
1783
+ normalizedDef.jsxTree,
1784
+ node.children,
1785
+ node.props,
1786
+ colorGroup,
1787
+ radiusGroup,
1788
+ theme,
1789
+ depth,
1790
+ nextContext
1791
+ );
1792
+ if (result) {
1793
+ return maybeWrapMxAuto(result, classes, context);
1794
+ }
1795
+ }
1796
+ }
1797
+ // Use only container-level classes from the definition
1798
+ const defClasses = normalizedDef.baseClasses || normalizedDef.classes || [];
1799
+ if (defClasses.length > 0 && shouldMergeDefBaseClassesForTag(normalizedDef, node.tagName)) {
1800
+ // Keep wrapper/container semantic classes from base definitions.
1801
+ // This preserves layout behavior (grid/flex/gap) for primitives like RadioGroup,
1802
+ // while still avoiding most decorative implementation internals.
1803
+ const containerClasses = pickPreservedWrapperBaseClasses(defClasses);
1804
+ compoundClasses = mergeClasses(containerClasses, compoundClasses);
1805
+ }
1806
+ }
1807
+ // If no classes found and we have a parent compound def, look up as subComponent
1808
+ if (compoundClasses.length === 0 && context.parentCompoundDef) {
1809
+ compoundClasses = getCompoundClasses(context.parentCompoundDef, node.tagName);
1810
+ }
1811
+
1812
+ const mergedClasses = compoundClasses.length > 0
1813
+ ? mergeClasses(compoundClasses, classes)
1814
+ : classes;
1815
+ const responsiveMergedClasses = mergedClasses === classes
1816
+ ? classes
1817
+ : resolveClassesForBreakpoint(mergedClasses, breakpointIndex);
1818
+ const activeMergedClasses = expandActiveConditionalVariants(responsiveMergedClasses, node);
1819
+
1820
+ const wrapperFrame = figma.createFrame();
1821
+ wrapperFrame.name = node.tagName;
1822
+ wrapperFrame.fills = [];
1823
+
1824
+ // Apply layout using LayoutParser IR
1825
+ const wrapperIR = activeMergedClasses === classes ? layoutComputed.layoutIR : LayoutParser.parseToIR(activeMergedClasses);
1826
+ LayoutParser.applyToFrame(wrapperFrame, wrapperIR);
1827
+
1828
+ // Apply styles to the frame
1829
+ if (activeMergedClasses.length > 0) {
1830
+ applyTailwindStylesToFrame(wrapperFrame, activeMergedClasses, colorGroup, radiusGroup, theme);
1831
+ }
1832
+ applyClipBehavior(wrapperFrame, activeMergedClasses);
1833
+ // Component wrappers that the scanner emits as Radix primitives
1834
+ // (`<DropdownMenuPrimitive.Item>`, `<TabsPrimitive.Trigger>`, …) reach
1835
+ // this branch with `kind: 'component'`. The `kind: 'element'` branch
1836
+ // applies `shouldStretchToParentWidth` to stretch block-level children
1837
+ // to a vertical parent's content width; without the same check here a
1838
+ // DropdownMenuItem inside a `w-56` Content stays HUG-width. Treat the
1839
+ // wrapper as a div — Radix/shadcn primitives render <div> by default
1840
+ // unless `asChild` is used (handled separately) — so block-flow stretch
1841
+ // applies. The `__fromPortal` guard below intentionally runs after so
1842
+ // portal panels still tag themselves.
1843
+ if (
1844
+ context.parentLayout === 'VERTICAL'
1845
+ && shouldStretchToParentWidth('div', activeMergedClasses)
1846
+ ) {
1847
+ try { wrapperFrame.layoutAlign = 'STRETCH'; } catch (_err) { /* ignore */ }
1848
+ // Mirror the `kind: 'element'` branch's resize: layoutAlign alone may
1849
+ // not grow the frame when the parent isn't sized yet, so when we know
1850
+ // context.maxWidth, set the axis we'd otherwise hug to FIXED at that
1851
+ // width. Without this the Item stays at its content width even after
1852
+ // the STRETCH mark.
1853
+ if (context.maxWidth && context.maxWidth > 0) {
1854
+ try {
1855
+ if (
1856
+ wrapperFrame.layoutMode === 'HORIZONTAL'
1857
+ && wrapperFrame.primaryAxisSizingMode !== 'FIXED'
1858
+ ) {
1859
+ wrapperFrame.resize(context.maxWidth, wrapperFrame.height);
1860
+ wrapperFrame.primaryAxisSizingMode = 'FIXED';
1861
+ } else if (
1862
+ wrapperFrame.layoutMode === 'VERTICAL'
1863
+ && wrapperFrame.counterAxisSizingMode !== 'FIXED'
1864
+ ) {
1865
+ wrapperFrame.resize(context.maxWidth, wrapperFrame.height);
1866
+ wrapperFrame.counterAxisSizingMode = 'FIXED';
1867
+ }
1868
+ } catch (_err) { /* ignore resize errors */ }
1869
+ }
1870
+ }
1871
+ // `ml-auto` on any direct child → parent uses SPACE_BETWEEN along the
1872
+ // primary axis. See `hasAutoMarginChild` above for why the parent has
1873
+ // to detect this itself (inline-text children never carry a frame
1874
+ // mark). Only flip when the consumer hasn't already set a `justify-*`
1875
+ // (the default Figma value is `MIN`, which is also what we get for a
1876
+ // flex with no `justify-*`).
1877
+ if (
1878
+ wrapperFrame.layoutMode === 'HORIZONTAL'
1879
+ && wrapperFrame.primaryAxisAlignItems === 'MIN'
1880
+ && hasAutoMarginChild(node.children)
1881
+ ) {
1882
+ try { wrapperFrame.primaryAxisAlignItems = 'SPACE_BETWEEN'; } catch (_err) { /* ignore */ }
1883
+ }
1884
+ // Tag portal panels so story-builder can grow Story Layout past narrow wrapper
1885
+ // classes (e.g. `w-56`) that would otherwise clip the panel. See portal-panel.ts.
1886
+ if (node.props && node.props.__fromPortal) {
1887
+ try { wrapperFrame.setPluginData('inkbridge:portal-panel', '1'); } catch { /* ignore */ }
1888
+ }
1889
+ // Calculate content width for children (accounting for padding)
1890
+ // This is needed for justify-between to work in Figma SPACE_BETWEEN mode
1891
+ const wrapperPadding = (wrapperFrame.paddingLeft || 0) + (wrapperFrame.paddingRight || 0);
1892
+ const hasFixedWidth = wrapperFrame.layoutMode === 'HORIZONTAL'
1893
+ ? wrapperFrame.primaryAxisSizingMode === 'FIXED'
1894
+ : wrapperFrame.counterAxisSizingMode === 'FIXED';
1895
+ const wrapperContentWidth = hasFixedWidth && wrapperFrame.width > 0
1896
+ ? wrapperFrame.width - wrapperPadding
1897
+ : undefined;
1898
+ // Cap wrapperContentWidth by the parent context's max-width so that a story-level
1899
+ // max-w constraint (e.g. max-w-md = 448px) overrides a wider component-level
1900
+ // max-w (e.g. max-w-lg = 512px) that LayoutParser may have picked from the merged
1901
+ // class list. Without this, children receive childCtx.maxWidth = 464 (from the
1902
+ // component's max-w-lg frame width minus padding) instead of ~400 (from max-w-md).
1903
+ const contextContentWidth = context.maxWidth != null && context.maxWidth > 0
1904
+ ? Math.max(0, context.maxWidth - wrapperPadding)
1905
+ : undefined;
1906
+ const childMaxWidth = wrapperContentWidth != null && contextContentWidth != null
1907
+ ? Math.min(wrapperContentWidth, contextContentWidth)
1908
+ : (wrapperContentWidth ?? contextContentWidth);
1909
+ const childCtx: RenderContext = Object.assign({}, nextContext, {
1910
+ parentLayout: wrapperFrame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
1911
+ textColor: nextContext.textColor,
1912
+ textColorToken: nextContext.textColorToken,
1913
+ maxWidth: childMaxWidth || context.maxWidth,
1914
+ parentCompoundDef: currentCompoundDef || nextContext.parentCompoundDef,
1915
+ });
1916
+ const skipWrapperFullWidth = shouldSkipFullWidthForClasses(activeMergedClasses);
1917
+ const accordionItemOrdinal = { value: 0 };
1918
+ if (!isSemanticTableContainer(node)) {
1919
+ applyVerticalMarginSpacing(wrapperFrame, node.children || []);
1920
+ }
1921
+
1922
+ for (const rawChild of node.children) {
1923
+ const child = unwrapTransparentWrapper(rawChild);
1924
+ const perChildCtx = createPerChildRenderContext(childCtx, node, child, accordionItemOrdinal);
1925
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
1926
+ if (childNode) {
1927
+ wrapperFrame.appendChild(childNode);
1928
+
1929
+ // Apply child-specific layout properties
1930
+ if (isElementLikeNode(child)) {
1931
+ LayoutParser.applyChildProperties(childNode, child.classes, wrapperFrame);
1932
+ }
1933
+ applyAbsoluteIfPossible(childNode, wrapperFrame);
1934
+ stabilizeHorizontalStretchChild(childNode, wrapperFrame);
1935
+ constrainSingleHorizontalTextChild(childNode);
1936
+ const fullWidthOptions = (skipWrapperFullWidth || childCtx.maxWidth != null)
1937
+ ? { skipFullWidth: skipWrapperFullWidth, widthOverride: childCtx.maxWidth }
1938
+ : undefined;
1939
+ applyFullWidthIfPossible(childNode, wrapperFrame, fullWidthOptions);
1940
+ applyRingIfPossible(childNode, wrapperFrame);
1941
+ applyAspectRatioIfPossible(childNode);
1942
+ enforceTabsChildSizing(node, child, childNode, childCtx.maxWidth);
1943
+ if (isElementLikeNode(child)) {
1944
+ enforceFixedBoxSizingAfterLayout(childNode, child.classes);
1945
+ }
1946
+ // Aspect ratio / full width may have given this child its final
1947
+ // dimensions; resolve any deferred percent positions on its children.
1948
+ if ('layoutMode' in childNode) {
1949
+ applyDeferredPercentPositioning(childNode as FrameNode);
1950
+ }
1951
+ }
1952
+ }
1953
+ applyDeferredBottomPositioning(wrapperFrame);
1954
+ applyDeferredCenterYPositioning(wrapperFrame);
1955
+ applyDeferredPercentPositioning(wrapperFrame);
1956
+ reflowMxAutoChildren(wrapperFrame);
1957
+ // Apply grid columns after all children are added
1958
+ const wrapperGridWidth = resolveGridWidthOverride(wrapperFrame, childCtx.maxWidth);
1959
+ applyGridColumnsWithReflow(wrapperFrame, wrapperGridWidth);
1960
+
1961
+ return maybeWrapMxAuto(wrapperFrame, activeMergedClasses, context);
1962
+ }
1963
+
1964
+ // Components like SelectValue, Input etc. that expose a placeholder prop but have no
1965
+ // renderable children — render the placeholder as muted text so triggers show content.
1966
+ if ((!node.children || node.children.length === 0) && node.props && node.props.placeholder) {
1967
+ const phOpts: CreateTextOptions & { theme: string; opacity: number } = { fontSize: context.textFontSize || 14, theme, opacity: 0.4 };
1968
+ if (context.textFontStyle != null) phOpts.fontStyle = context.textFontStyle;
1969
+ else if (context.textBold != null) phOpts.bold = context.textBold;
1970
+ if (context.textColor) phOpts.fill = context.textColor;
1971
+ return createTextNode(node.props.placeholder, phOpts);
1972
+ }
1973
+
1974
+ // Unknown component without children - render styled frame
1975
+ const compFrame = figma.createFrame();
1976
+ compFrame.name = node.tagName;
1977
+ compFrame.layoutMode = 'VERTICAL';
1978
+ compFrame.primaryAxisSizingMode = 'AUTO';
1979
+ compFrame.counterAxisSizingMode = 'AUTO';
1980
+ compFrame.fills = [];
1981
+ applyClipBehavior(compFrame, classes);
1982
+ if (classes.length > 0) {
1983
+ applyTailwindStylesToFrame(compFrame, classes, colorGroup, radiusGroup, theme);
1984
+ }
1985
+ // applyTailwindStylesToFrame may have flipped layoutMode based on flex/grid classes,
1986
+ // so read it via a string-compare that doesn't narrow against the literal assignment.
1987
+ const compIsHorizontal = String(compFrame.layoutMode) === 'HORIZONTAL';
1988
+ const compChildCtx: RenderContext = Object.assign({}, nextContext, {
1989
+ textColor: nextContext.textColor,
1990
+ textColorToken: nextContext.textColorToken,
1991
+ maxWidth: compIsHorizontal ? undefined : context.maxWidth,
1992
+ parentLayout: compIsHorizontal ? 'HORIZONTAL' : 'VERTICAL',
1993
+ });
1994
+ const skipCompFullWidth = shouldSkipFullWidthForClasses(classes);
1995
+ const accordionItemOrdinal = { value: 0 };
1996
+ for (const rawChild of node.children || []) {
1997
+ const child = unwrapTransparentWrapper(rawChild);
1998
+ const perChildCtx = createPerChildRenderContext(compChildCtx, node, child, accordionItemOrdinal);
1999
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
2000
+ if (childNode) {
2001
+ compFrame.appendChild(childNode);
2002
+ // Apply child layout properties based on parent context
2003
+ if (isElementLikeNode(child)) {
2004
+ LayoutParser.applyChildProperties(childNode, child.classes, compFrame);
2005
+ }
2006
+ applyAbsoluteIfPossible(childNode, compFrame);
2007
+ stabilizeHorizontalStretchChild(childNode, compFrame);
2008
+ constrainSingleHorizontalTextChild(childNode);
2009
+ const fullWidthOptions = (skipCompFullWidth || compChildCtx.maxWidth != null)
2010
+ ? { skipFullWidth: skipCompFullWidth, widthOverride: compChildCtx.maxWidth }
2011
+ : undefined;
2012
+ applyFullWidthIfPossible(childNode, compFrame, fullWidthOptions);
2013
+ applyRingIfPossible(childNode, compFrame);
2014
+ applyAspectRatioIfPossible(childNode);
2015
+ if (isElementLikeNode(child)) {
2016
+ enforceFixedBoxSizingAfterLayout(childNode, child.classes);
2017
+ }
2018
+ if ('layoutMode' in childNode) {
2019
+ applyDeferredPercentPositioning(childNode as FrameNode);
2020
+ }
2021
+ }
2022
+ }
2023
+ applyDeferredBottomPositioning(compFrame);
2024
+ applyDeferredCenterYPositioning(compFrame);
2025
+ applyDeferredPercentPositioning(compFrame);
2026
+ reflowMxAutoChildren(compFrame);
2027
+ // Apply grid columns after all children are added
2028
+ applyGridColumnsWithReflow(compFrame);
2029
+ return maybeWrapMxAuto(compFrame, classes, context);
2030
+ }
2031
+
2032
+ // HTML elements - create frame or text
2033
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'label', 'li', 'small'].includes(tagLower)) {
2034
+ const hasElementChildren = (node.children || []).some(child => isElementLikeNode(child));
2035
+ const hasLayoutClass = layoutComputed.hasLayoutClass;
2036
+ const hasFlexChildClass = layoutComputed.hasFlexChildClass;
2037
+ const hasExplicitSize = layoutComputed.hasExplicitSize;
2038
+ const inlineOnlyChildren = hasElementChildren && (node.children || []).every(isInlineTextNode);
2039
+ // Any solid background on a text-category element requires a frame to be visible
2040
+ const hasBgClass = classes.some(cls => {
2041
+ if (!cls.startsWith('bg-')) return false;
2042
+ const rest = cls.slice(3);
2043
+ return !rest.startsWith('gradient') && !rest.startsWith('linear') && !rest.startsWith('radial') && rest !== 'transparent';
2044
+ });
2045
+
2046
+ if (inlineOnlyChildren && !hasLayoutClass && !hasFlexChildClass && !hasBgClass) {
2047
+ const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed);
2048
+ if (inlineText) return maybeWrapMxAuto(inlineText, classes, context);
2049
+ }
2050
+
2051
+ // Create a frame container for elements with layout, explicit sizing, flex child classes, or a background fill
2052
+ if (hasElementChildren || hasLayoutClass || hasFlexChildClass || hasExplicitSize || hasBgClass) {
2053
+ const frame = figma.createFrame();
2054
+ frame.name = node.tagName;
2055
+ frame.fills = [];
2056
+
2057
+ // Apply layout using LayoutParser IR
2058
+ LayoutParser.applyToFrame(frame, layoutComputed.layoutIR);
2059
+
2060
+ // Apply visual styles
2061
+ if (classes.length > 0) {
2062
+ applyTailwindStylesToFrame(frame, classes, colorGroup, radiusGroup, theme);
2063
+ }
2064
+ applyClipBehavior(frame, classes);
2065
+ // `ml-auto` on any direct child → flip parent to SPACE_BETWEEN. Mirror
2066
+ // of the wrapper-branch detection above; needed here because inline
2067
+ // text children (e.g. `<span className="ml-auto">`) render as text
2068
+ // nodes and never carry a frame mark for deferred-layout to read.
2069
+ if (
2070
+ frame.layoutMode === 'HORIZONTAL'
2071
+ && frame.primaryAxisAlignItems === 'MIN'
2072
+ && hasAutoMarginChild(node.children)
2073
+ ) {
2074
+ try { frame.primaryAxisAlignItems = 'SPACE_BETWEEN'; } catch (_err) { /* ignore */ }
2075
+ }
2076
+ if (
2077
+ context.parentLayout === 'VERTICAL'
2078
+ && shouldStretchToParentWidth(tagLower, classes)
2079
+ ) {
2080
+ frame.layoutAlign = 'STRETCH';
2081
+ } else if (context.parentLayout === 'VERTICAL') {
2082
+ // Inline display elements (inline-flex, inline-block, etc.) must not stretch to parent width.
2083
+ // Figma's default layoutAlign 'INHERIT' = STRETCH in a VERTICAL parent — override it.
2084
+ const hasInlineDisplay = classes.some(c => {
2085
+ const base = getBaseClass(c);
2086
+ return base === 'inline' || base === 'inline-flex' || base === 'inline-block' || base === 'inline-grid';
2087
+ });
2088
+ if (hasInlineDisplay) {
2089
+ try { frame.layoutSizingHorizontal = 'HUG'; } catch (_e) { /* ignore if frame has no auto-layout */ }
2090
+ }
2091
+ }
2092
+ if (
2093
+ frame.layoutMode === 'HORIZONTAL'
2094
+ && context.maxWidth
2095
+ && frame.layoutAlign === 'STRETCH'
2096
+ && frame.primaryAxisSizingMode !== 'FIXED'
2097
+ ) {
2098
+ frame.resize(context.maxWidth, frame.height);
2099
+ frame.primaryAxisSizingMode = 'FIXED';
2100
+ } else if (
2101
+ frame.layoutMode === 'VERTICAL'
2102
+ && context.maxWidth
2103
+ && frame.layoutAlign === 'STRETCH'
2104
+ && frame.counterAxisSizingMode !== 'FIXED'
2105
+ ) {
2106
+ // Block-level vertical containers should fill the known parent content width.
2107
+ // Without an explicit width here, nested mx-auto/max-w content can collapse
2108
+ // the whole chain back to HUG width.
2109
+ frame.resize(context.maxWidth, frame.height);
2110
+ frame.counterAxisSizingMode = 'FIXED';
2111
+ }
2112
+ // Handle text content or children
2113
+ if (hasElementChildren) {
2114
+ const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
2115
+ ? frame.primaryAxisSizingMode === 'FIXED'
2116
+ : frame.counterAxisSizingMode === 'FIXED';
2117
+ const resolvedMaxWidth = frameHasFixedWidth
2118
+ ? (nextContext.maxWidth ? Math.min(nextContext.maxWidth, frame.width) : frame.width)
2119
+ : nextContext.maxWidth;
2120
+ const skipElementFullWidth = shouldSkipFullWidthForClasses(classes);
2121
+ const contentWidth = resolvedMaxWidth
2122
+ ? Math.max(0, resolvedMaxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
2123
+ : undefined;
2124
+ const gridWidthOverride = resolveGridWidthOverride(frame, contentWidth);
2125
+ const inheritedChildMaxWidth = frame.layoutMode === 'HORIZONTAL' ? undefined : contentWidth;
2126
+ const childContext: RenderContext = {
2127
+ ...nextContext,
2128
+ textAlign: nextContext.textAlign,
2129
+ maxWidth: inheritedChildMaxWidth,
2130
+ parentLayout: frame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
2131
+ textColor: nextContext.textColor,
2132
+ textColorToken: nextContext.textColorToken,
2133
+ };
2134
+ const accordionItemOrdinal = { value: 0 };
2135
+ for (const rawChild of node.children || []) {
2136
+ const child = unwrapTransparentWrapper(rawChild);
2137
+ const perChildCtx = createPerChildRenderContext(childContext, node, child, accordionItemOrdinal);
2138
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
2139
+ if (childNode) {
2140
+ frame.appendChild(childNode);
2141
+ // Apply child layout properties based on parent context
2142
+ if (isElementLikeNode(child)) {
2143
+ LayoutParser.applyChildProperties(childNode, child.classes, frame);
2144
+ }
2145
+ applyAbsoluteIfPossible(childNode, frame);
2146
+ stabilizeHorizontalStretchChild(childNode, frame);
2147
+ constrainSingleHorizontalTextChild(childNode);
2148
+ const fullWidthOptions = (skipElementFullWidth || childContext.maxWidth != null)
2149
+ ? { skipFullWidth: skipElementFullWidth, widthOverride: childContext.maxWidth }
2150
+ : undefined;
2151
+ applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
2152
+ applyRingIfPossible(childNode, frame);
2153
+ applyAspectRatioIfPossible(childNode);
2154
+ if (isElementLikeNode(child)) {
2155
+ enforceFixedBoxSizingAfterLayout(childNode, child.classes);
2156
+ }
2157
+ if ('layoutMode' in childNode) {
2158
+ applyDeferredPercentPositioning(childNode as FrameNode);
2159
+ }
2160
+ }
2161
+ }
2162
+ applyDeferredBottomPositioning(frame);
2163
+ applyDeferredCenterYPositioning(frame);
2164
+ applyDeferredPercentPositioning(frame);
2165
+ reflowMxAutoChildren(frame);
2166
+ constrainSingleHorizontalTextChild(frame);
2167
+ // Apply grid columns after all children are added
2168
+ applyGridColumnsWithReflow(frame, gridWidthOverride);
2169
+ } else {
2170
+ // Text-only element with sizing - create text node and append.
2171
+ // Center the text on the frame's main axis so that when the frame
2172
+ // ends up row-synced to a taller sibling (e.g. a `<label>` next to
2173
+ // an `h-9` input in a grid with items-center), the text sits at
2174
+ // vertical middle instead of top. Safe for the hug case too: with
2175
+ // a single child, primaryAxisAlignItems has no visible effect when
2176
+ // the frame hugs to content height.
2177
+ try {
2178
+ if (frame.layoutMode === 'VERTICAL' || frame.layoutMode === 'HORIZONTAL') {
2179
+ if (frame.layoutMode === 'VERTICAL') {
2180
+ frame.primaryAxisAlignItems = 'CENTER';
2181
+ } else {
2182
+ frame.counterAxisAlignItems = 'CENTER';
2183
+ }
2184
+ }
2185
+ } catch (_err) { /* ignore */ }
2186
+ const textContent = collectTextContent(node);
2187
+ if (textContent.trim()) {
2188
+ const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
2189
+ const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
2190
+ const textOpts: CreateTextOptions & { theme: string } = {
2191
+ fontSize: typo.fontSize,
2192
+ bold: typo.bold,
2193
+ lineHeight: typo.lineHeight,
2194
+ theme,
2195
+ fontRole: isHeadingTag ? 'heading' : 'sans',
2196
+ };
2197
+ if (textStyle.text) {
2198
+ textOpts.fill = textStyle.text;
2199
+ } else if (nextContext.textColor) {
2200
+ textOpts.fill = nextContext.textColor;
2201
+ }
2202
+ const resolvedBold = getResolvedBoldFromClasses(classes, textOpts.bold);
2203
+ if (resolvedBold != null) textOpts.bold = resolvedBold;
2204
+ const resolvedFontStyle = getFontStyleFromClasses(classes, nextContext.textFontStyle);
2205
+ if (resolvedFontStyle != null) textOpts.fontStyle = resolvedFontStyle;
2206
+ const resolvedSize = getResolvedTextSizeFromClasses(classes, textOpts.fontSize);
2207
+ if (resolvedSize != null) textOpts.fontSize = resolvedSize;
2208
+ // Apply text alignment: element's own class wins, otherwise inherit
2209
+ // from parent context (text-align is an inherited CSS property, and
2210
+ // this frame branch is also reached for text-only elements that get
2211
+ // a synthetic w-[Npx] via the width solver — without the fallback
2212
+ // they silently drop their parent's `text-center`).
2213
+ if (classes.includes('text-center')) {
2214
+ textOpts.textAlignHorizontal = 'CENTER';
2215
+ } else if (classes.includes('text-right')) {
2216
+ textOpts.textAlignHorizontal = 'RIGHT';
2217
+ } else if (classes.includes('text-left')) {
2218
+ textOpts.textAlignHorizontal = 'LEFT';
2219
+ } else if (nextContext.textAlign) {
2220
+ textOpts.textAlignHorizontal = nextContext.textAlign;
2221
+ }
2222
+ const textNode = createTextNode(textContent, textOpts);
2223
+ // Decision lives in `resolveTextResizeWidth` so the truth table is
2224
+ // testable as a pure helper (site (c) of the recurring inline-flex
2225
+ // pill bug — see tools/figma-plugin/.ai/troubleshooting.md). Skips
2226
+ // HORIZONTAL+CENTER (numbered-circle case) and HORIZONTAL+HUG
2227
+ // (inline-flex pill case); resizes otherwise.
2228
+ const textContentWidth = resolveTextResizeWidth(frame, nextContext.maxWidth);
2229
+ if (textContentWidth != null) {
2230
+ try {
2231
+ textNode.textAutoResize = 'HEIGHT';
2232
+ textNode.resize(textContentWidth, textNode.height);
2233
+ } catch (_err) {
2234
+ // ignore resize errors
2235
+ }
2236
+ }
2237
+ frame.appendChild(textNode);
2238
+ }
2239
+ }
2240
+ return maybeWrapMxAuto(frame, classes, context);
2241
+ }
2242
+
2243
+ // Text-like elements - gather all text content
2244
+ const textContent = collectTextContent(node);
2245
+ if (!textContent.trim()) return null;
2246
+
2247
+ const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
2248
+ const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
2249
+ const textOpts: CreateTextOptions & { theme: string } = {
2250
+ fontSize: typo.fontSize,
2251
+ bold: typo.bold,
2252
+ lineHeight: typo.lineHeight,
2253
+ theme,
2254
+ fontRole: isHeadingTag ? 'heading' : 'sans',
2255
+ };
2256
+
2257
+ // Extract text color from classes
2258
+ if (textStyle.text) {
2259
+ textOpts.fill = textStyle.text;
2260
+ } else if (nextContext.textColor) {
2261
+ textOpts.fill = nextContext.textColor;
2262
+ }
2263
+
2264
+ if (nextContext.textAlign) {
2265
+ textOpts.textAlignHorizontal = nextContext.textAlign;
2266
+ }
2267
+
2268
+ // Check for bold/font-weight class
2269
+ const resolvedBold = getResolvedBoldFromClasses(classes, textOpts.bold);
2270
+ if (resolvedBold != null) textOpts.bold = resolvedBold;
2271
+ const resolvedFontStyle = getFontStyleFromClasses(classes, nextContext.textFontStyle);
2272
+ if (resolvedFontStyle != null) textOpts.fontStyle = resolvedFontStyle;
2273
+
2274
+ // Extract font size from classes
2275
+ const resolvedSize = getResolvedTextSizeFromClasses(classes, textOpts.fontSize);
2276
+ if (resolvedSize != null) textOpts.fontSize = resolvedSize;
2277
+
2278
+ const lineHeight = getLineHeightFromClasses(classes, textOpts.fontSize || 14);
2279
+ if (lineHeight) {
2280
+ textOpts.lineHeight = lineHeight;
2281
+ }
2282
+
2283
+ const textNode = createTextNode(textContent, textOpts);
2284
+ const isBlockText = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'].includes(tagLower);
2285
+ // When mx-auto is present, extract the element's own max-w-* constraint so the text
2286
+ // node is constrained to that width even when the parent maxWidth is wider.
2287
+ const hasMxAuto = classes.includes('mx-auto');
2288
+ let ownMaxW: number | null = null;
2289
+ if (hasMxAuto) {
2290
+ for (const cls of classes) {
2291
+ if (cls.startsWith('max-w-')) {
2292
+ const v = resolveMaxWidth(cls);
2293
+ if (v != null) { ownMaxW = v; break; }
2294
+ }
2295
+ }
2296
+ }
2297
+ const effectiveMaxWidth = ownMaxW != null
2298
+ ? (nextContext.maxWidth != null ? Math.min(nextContext.maxWidth, ownMaxW) : ownMaxW)
2299
+ : nextContext.maxWidth;
2300
+ const shouldConstrain = effectiveMaxWidth
2301
+ && nextContext.parentLayout !== 'HORIZONTAL'
2302
+ && (isBlockText || classes.includes('block') || classes.includes('w-full'));
2303
+ const hasTruncateClass = classes.includes('truncate');
2304
+ let lineClampN: number | null = null;
2305
+ for (const cls of classes) {
2306
+ const m = cls.match(/^line-clamp-(\d+)$/);
2307
+ if (m) { lineClampN = parseInt(m[1], 10); break; }
2308
+ }
2309
+ if ((hasTruncateClass || lineClampN != null) && effectiveMaxWidth) {
2310
+ try {
2311
+ const maxLines = lineClampN ?? 1;
2312
+ const fs = textOpts.fontSize || 14;
2313
+ const lh = textOpts.lineHeight || Math.ceil(fs * 1.5);
2314
+ textNode.textTruncation = 'ENDING';
2315
+ textNode.maxLines = maxLines;
2316
+ textNode.resize(effectiveMaxWidth, Math.max(lh, lh * maxLines));
2317
+ } catch (_err) { /* ignore */ }
2318
+ } else if (shouldConstrain) {
2319
+ try {
2320
+ textNode.textAutoResize = 'HEIGHT';
2321
+ textNode.resize(effectiveMaxWidth, textNode.height);
2322
+ } catch (_err) {
2323
+ // ignore resize errors
2324
+ }
2325
+ }
2326
+ return maybeWrapMxAuto(textNode, classes, context);
2327
+ }
2328
+
2329
+ // <hr> - horizontal rule / separator
2330
+ if (tagLower === 'hr') {
2331
+ const hr = figma.createFrame();
2332
+ hr.name = 'hr';
2333
+ hr.layoutMode = 'HORIZONTAL';
2334
+ hr.resize(200, 1);
2335
+ hr.primaryAxisSizingMode = 'FIXED';
2336
+ hr.counterAxisSizingMode = 'FIXED';
2337
+ const borderColor = colorGroup.border ? parseColor(colorGroup.border) : { r: 0.85, g: 0.85, b: 0.85 };
2338
+ hr.fills = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
2339
+ if (classes.includes('w-full')) {
2340
+ markFullWidthNode(hr);
2341
+ }
2342
+ return maybeWrapMxAuto(hr, classes, context);
2343
+ }
2344
+
2345
+ // Container elements (div, nav, section, header, footer, ul, ol, etc.)
2346
+
2347
+ // Check for clip-path in style prop - create vector shape instead of frame
2348
+ const clipPathPolygon = parseClipPathFromStyle(node.props.style);
2349
+ if (clipPathPolygon && node.children.length === 0) {
2350
+ const decorativeNode = buildDecorativeClipPathNode({
2351
+ clipPathPolygon,
2352
+ classes,
2353
+ contextMaxWidth: context.maxWidth,
2354
+ colorGroup,
2355
+ radiusGroup,
2356
+ theme,
2357
+ });
2358
+ if (decorativeNode) return maybeWrapMxAuto(decorativeNode, classes, context);
2359
+ }
2360
+
2361
+ const frame = figma.createFrame();
2362
+ frame.name = node.tagName;
2363
+ frame.fills = [];
2364
+
2365
+ // Apply layout using LayoutParser IR (handles flex, grid, alignment, spacing, dimensions)
2366
+ LayoutParser.applyToFrame(frame, layoutComputed.layoutIR);
2367
+
2368
+ // Apply visual styles (colors, borders, radius) from Tailwind classes
2369
+ // Resolve sm: breakpoint overrides when container is >= 640px (matches CSS cascade)
2370
+ const effectiveClasses = (context.maxWidth != null && context.maxWidth >= 640)
2371
+ ? getClassesForBreakpoint(classes, 'sm')
2372
+ : classes;
2373
+ if (effectiveClasses.length > 0) {
2374
+ applyTailwindStylesToFrame(frame, effectiveClasses, colorGroup, radiusGroup, theme);
2375
+ }
2376
+ applyClipBehavior(frame, effectiveClasses);
2377
+ // Hero-like containers (`relative isolate`) with decorative clip-path blobs should
2378
+ // clip to their own bounds so gradients never spill outside the component frame.
2379
+ if (effectiveClasses.includes('relative') && effectiveClasses.includes('isolate') && nodeHasClipPathDescendant(node)) {
2380
+ frame.clipsContent = true;
2381
+ }
2382
+
2383
+ // NOTE: text-left/center/right only affects TEXT content in CSS, not block layout.
2384
+ // Do NOT apply alignFromClasses to frame.counterAxisAlignItems here.
2385
+ // The textAlign context is propagated to child text nodes instead.
2386
+
2387
+ // A depth=0 root with an explicit width class (size-N, w-N, w-[N], size-full,
2388
+ // ...) must keep that intrinsic size — overriding it with the page-canvas
2389
+ // width turns a 40×40 avatar into a 900-wide pill. The override is only meant
2390
+ // for unsized page-level containers that act as a story root.
2391
+ const shouldApplyContextWidth = depth === 0 && context.maxWidth && !layoutComputed.hasExplicitSize;
2392
+ if (shouldApplyContextWidth || (nextContext.maxWidth && nextContext.maxWidth !== context.maxWidth)) {
2393
+ const targetWidth = shouldApplyContextWidth ? context.maxWidth : nextContext.maxWidth;
2394
+ if (targetWidth) {
2395
+ if (frame.layoutMode === 'HORIZONTAL') {
2396
+ frame.resize(targetWidth, frame.height);
2397
+ frame.primaryAxisSizingMode = 'FIXED';
2398
+ } else {
2399
+ frame.resize(targetWidth, frame.height);
2400
+ frame.counterAxisSizingMode = 'FIXED';
2401
+ }
2402
+ }
2403
+ // Clip the story root frame so absolute-positioned children (e.g. gradient blobs)
2404
+ // don't extend visually outside the component's bounds in Figma.
2405
+ if (shouldApplyContextWidth) {
2406
+ frame.clipsContent = true;
2407
+ }
2408
+ }
2409
+
2410
+ // Block-level elements in VERTICAL parents should stretch to fill width
2411
+ // Note: We set layoutAlign even before the frame is appended - Figma will respect it when appended
2412
+ if (
2413
+ context.parentLayout === 'VERTICAL'
2414
+ && shouldStretchToParentWidth(tagLower, classes)
2415
+ ) {
2416
+ frame.layoutAlign = 'STRETCH';
2417
+ } else if (context.parentLayout === 'VERTICAL') {
2418
+ const hasInlineDisplay = classes.some(c => {
2419
+ const base = getBaseClass(c);
2420
+ return base === 'inline' || base === 'inline-flex' || base === 'inline-block' || base === 'inline-grid';
2421
+ });
2422
+ if (hasInlineDisplay) {
2423
+ try { frame.layoutSizingHorizontal = 'HUG'; } catch (_e) { /* ignore if frame has no auto-layout */ }
2424
+ }
2425
+ }
2426
+
2427
+ if (
2428
+ frame.layoutMode === 'HORIZONTAL'
2429
+ && context.maxWidth
2430
+ && frame.layoutAlign === 'STRETCH'
2431
+ && frame.primaryAxisSizingMode !== 'FIXED'
2432
+ ) {
2433
+ frame.resize(context.maxWidth, frame.height);
2434
+ frame.primaryAxisSizingMode = 'FIXED';
2435
+ }
2436
+
2437
+ const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
2438
+ ? frame.primaryAxisSizingMode === 'FIXED'
2439
+ : frame.counterAxisSizingMode === 'FIXED';
2440
+ const resolvedMaxWidth = frameHasFixedWidth
2441
+ ? (nextContext.maxWidth ? Math.min(nextContext.maxWidth, frame.width) : frame.width)
2442
+ : nextContext.maxWidth;
2443
+ const gridCols = extractGridColumns(classes, resolvedMaxWidth || context.maxWidth);
2444
+ const hasMultiColumnGrid = typeof gridCols === 'number' && gridCols > 1;
2445
+ const gridWidth = resolvedMaxWidth || context.maxWidth || (hasMultiColumnGrid ? defaultGridWidth(gridCols) : undefined);
2446
+ const skipChildFullWidth = !!gridCols || classes.includes('grid') || classes.includes('inline-grid');
2447
+ if (hasMultiColumnGrid && gridWidth && 'resize' in frame) {
2448
+ try {
2449
+ if (frame.layoutMode !== 'HORIZONTAL') frame.layoutMode = 'HORIZONTAL';
2450
+ if (frame.layoutWrap !== undefined) {
2451
+ frame.layoutWrap = 'WRAP';
2452
+ }
2453
+ frame.resize(gridWidth, frame.height);
2454
+ frame.primaryAxisSizingMode = 'FIXED';
2455
+ frame.counterAxisSizingMode = 'AUTO';
2456
+ } catch (_err) {
2457
+ // ignore resize errors
2458
+ }
2459
+ }
2460
+
2461
+ // Render children
2462
+ const contentWidth = resolvedMaxWidth
2463
+ ? Math.max(0, resolvedMaxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
2464
+ : undefined;
2465
+ const inheritedChildMaxWidth = frame.layoutMode === 'HORIZONTAL' ? undefined : contentWidth;
2466
+ const childContext: RenderContext = {
2467
+ ...nextContext,
2468
+ textAlign: nextContext.textAlign,
2469
+ maxWidth: inheritedChildMaxWidth,
2470
+ parentLayout: frame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
2471
+ textColor: nextContext.textColor,
2472
+ textColorToken: nextContext.textColorToken,
2473
+ };
2474
+ const renderChildren = getRenderableChildren(node, classes);
2475
+ const accordionItemOrdinal = { value: 0 };
2476
+
2477
+ // Convert child mt-* margins to auto-layout spacing in vertical stacks.
2478
+ if (!isSemanticTableContainer(node)) {
2479
+ applyVerticalMarginSpacing(frame, renderChildren);
2480
+ }
2481
+
2482
+ // Inline-only div: all children are text/span/a nodes (no block layout).
2483
+ // Collapse into a single text node — like a CSS inline container (e.g. pill labels).
2484
+ const hasOnlyInlineChildren = !layoutComputed.hasLayoutClass
2485
+ && renderChildren.length > 0
2486
+ && renderChildren.every(isInlineTextNode);
2487
+ if (hasOnlyInlineChildren) {
2488
+ const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed);
2489
+ if (inlineText) {
2490
+ frame.appendChild(inlineText);
2491
+ return maybeWrapMxAuto(frame, classes, context);
2492
+ }
2493
+ }
2494
+
2495
+ for (const rawChild of renderChildren) {
2496
+ // Transparent wrappers (e.g. Radix `<SheetClose asChild>`) render as a
2497
+ // passthrough to their single inner child. Unwrap here so that
2498
+ // `applyChildProperties` / stretch / full-width apply the inner element's
2499
+ // own classes (e.g. `w-[Npx]`, `self-*`, `flex-1`) to the Figma node that
2500
+ // was actually built for it.
2501
+ const child = unwrapTransparentWrapper(rawChild);
2502
+ const perChildCtx = createPerChildRenderContext(childContext, node, child, accordionItemOrdinal);
2503
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
2504
+ if (childNode) {
2505
+ frame.appendChild(childNode);
2506
+
2507
+ // Apply child-specific layout properties (flex-1, self-*, etc.)
2508
+ if (isElementLikeNode(child)) {
2509
+ LayoutParser.applyChildProperties(childNode, child.classes, frame);
2510
+ }
2511
+ applyAbsoluteIfPossible(childNode, frame);
2512
+ stabilizeHorizontalStretchChild(childNode, frame);
2513
+ constrainSingleHorizontalTextChild(childNode);
2514
+ const fullWidthOptions = (skipChildFullWidth || contentWidth != null)
2515
+ ? { skipFullWidth: skipChildFullWidth, widthOverride: contentWidth }
2516
+ : undefined;
2517
+ applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
2518
+ applyRingIfPossible(childNode, frame);
2519
+ applyAspectRatioIfPossible(childNode);
2520
+ enforceGrowChildPrimaryFixed(childNode, frame);
2521
+ enforceTabsChildSizing(node, child, childNode, childContext.maxWidth);
2522
+ if (isElementLikeNode(child)) {
2523
+ enforceFixedBoxSizingAfterLayout(childNode, child.classes);
2524
+ }
2525
+ // Aspect-ratio / full-width may have given child its final dimensions;
2526
+ // resolve any deferred percent positions on its children now.
2527
+ if ('layoutMode' in childNode) {
2528
+ applyDeferredPercentPositioning(childNode as FrameNode);
2529
+ }
2530
+ if (
2531
+ node.kind === 'element'
2532
+ && node.tagLower === 'table'
2533
+ && child.kind === 'element'
2534
+ && child.tagLower === 'tfoot'
2535
+ ) {
2536
+ // CSS table border-collapse merges adjacent borders to 1px.
2537
+ // In Figma stacked frames accumulate strokes, so clear tfoot top stroke.
2538
+ clearTopBorder(childNode);
2539
+ }
2540
+ if (
2541
+ node.kind === 'element'
2542
+ && node.tagLower === 'tfoot'
2543
+ && child.kind === 'element'
2544
+ && child.tagLower === 'tr'
2545
+ ) {
2546
+ // Matches [&>tr]:last:border-b-0 on TableFooter.
2547
+ clearBottomBorder(childNode);
2548
+ }
2549
+ if (
2550
+ node.kind === 'element'
2551
+ && node.tagLower === 'tr'
2552
+ && (child.kind === 'element' || child.kind === 'component')
2553
+ && (child.tagLower === 'td' || child.tagLower === 'th')
2554
+ && !hasTableCellSizeOverride(child.classes)
2555
+ ) {
2556
+ const colSpan = getNumericColSpan(child.props?.colSpan);
2557
+ if ('layoutGrow' in childNode) {
2558
+ try {
2559
+ childNode.layoutGrow = colSpan;
2560
+ } catch (_err) {
2561
+ // ignore if node cannot grow in this parent
2562
+ }
2563
+ }
2564
+ }
2565
+ }
2566
+ }
2567
+
2568
+ // Resolve any deferred percent positions on this frame's direct children
2569
+ // now that all of them are appended and (most likely) sized.
2570
+ if ('layoutMode' in frame) {
2571
+ applyDeferredPercentPositioning(frame as FrameNode);
2572
+ }
2573
+
2574
+ // For input/textarea elements with no children, render defaultValue or placeholder as text.
2575
+ if ((tagLower === 'input' || tagLower === 'textarea') && (!node.children || node.children.length === 0)) {
2576
+ const inputText = node.props.defaultValue || node.props.value;
2577
+ const inputPlaceholder = node.props.placeholder;
2578
+ if (inputText || inputPlaceholder) {
2579
+ const inputTextOpts: CreateTextOptions & { theme: string } = { fontSize: context.textFontSize || 14, theme };
2580
+ if (inputText) {
2581
+ if (context.textColor) inputTextOpts.fill = context.textColor;
2582
+ } else {
2583
+ inputTextOpts.opacity = 0.4; // placeholder muted
2584
+ }
2585
+ const inputTextNode = createTextNode(inputText || inputPlaceholder, inputTextOpts);
2586
+ const isTextarea = tagLower === 'textarea';
2587
+ if (isTextarea) {
2588
+ // Textareas wrap their text and grow vertically with content
2589
+ // (subject to the `min-h-N` floor parsed upstream). The scanner
2590
+ // emits `flex` in the class list, which leaves the frame as a
2591
+ // HORIZONTAL row — that's wrong for a top-anchored multi-line
2592
+ // text container. Switch to VERTICAL so the primary axis is
2593
+ // height: AUTO mode now hugs the wrapped text, MIN align
2594
+ // anchors content to the top edge (browsers do this).
2595
+ try {
2596
+ frame.layoutMode = 'VERTICAL';
2597
+ frame.counterAxisSizingMode = 'FIXED';
2598
+ frame.primaryAxisSizingMode = 'AUTO';
2599
+ frame.primaryAxisAlignItems = 'MIN';
2600
+ frame.counterAxisAlignItems = 'MIN';
2601
+ } catch { /* ignore */ }
2602
+ frame.appendChild(inputTextNode);
2603
+ try {
2604
+ const padX = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
2605
+ const contentWidth = Math.max(1, frame.width - padX);
2606
+ inputTextNode.textAutoResize = 'HEIGHT';
2607
+ inputTextNode.resize(contentWidth, inputTextNode.height);
2608
+ } catch { /* ignore */ }
2609
+ } else {
2610
+ frame.appendChild(inputTextNode);
2611
+ // Browsers render <input> text vertically centered by default. The
2612
+ // scanner classes only carry `px-*`/`h-*`, no `items-center`, so without
2613
+ // this the text sits at the top of the input box.
2614
+ try {
2615
+ if (frame.layoutMode === 'VERTICAL') {
2616
+ frame.primaryAxisAlignItems = 'CENTER';
2617
+ } else if (frame.layoutMode === 'HORIZONTAL') {
2618
+ frame.counterAxisAlignItems = 'CENTER';
2619
+ }
2620
+ } catch { /* ignore */ }
2621
+ }
2622
+ }
2623
+ }
2624
+
2625
+ applyDeferredBottomPositioning(frame);
2626
+ applyDeferredCenterYPositioning(frame);
2627
+ reflowMxAutoChildren(frame);
2628
+ applyGridColumnsWithReflow(frame, gridWidth || frame.width, hasMultiColumnGrid ? gridCols : undefined);
2629
+
2630
+ // Re-apply clipping after children are added, in case Figma resets it during layout ops.
2631
+ if (shouldClipContent(classes)) {
2632
+ frame.clipsContent = true;
2633
+ }
2634
+ // Re-apply directional border widths after layout/resize passes; Figma may normalize
2635
+ // stroke sides during downstream sizing operations.
2636
+ if (Array.isArray(frame.strokes) && frame.strokes.length > 0) {
2637
+ applyBorderWidthUtilities(frame, classes);
2638
+ }
2639
+
2640
+ return maybeWrapMxAuto(frame, classes, context);
2641
+ }
2642
+
2643
+
2644
+ function applyLayoutClasses(
2645
+ frame: FrameNode,
2646
+ classes: string[] | undefined,
2647
+ colorGroup: Record<string, string>,
2648
+ radiusGroup: Record<string, string> | null,
2649
+ theme: string
2650
+ ): void {
2651
+ const list = classes || [];
2652
+ const layoutIR = LayoutParser.parseToIR(list);
2653
+ LayoutParser.applyToFrame(frame, layoutIR);
2654
+ frame.fills = frame.fills || [];
2655
+ if (list.length > 0) {
2656
+ applyTailwindStylesToFrame(frame, list, colorGroup, radiusGroup, theme);
2657
+ }
2658
+ applyClipBehavior(frame, list);
2659
+
2660
+ if (!frame.itemSpacing || frame.itemSpacing === 0) {
2661
+ frame.itemSpacing = 12;
2662
+ }
2663
+ }
2664
+
2665
+ function applyAbsoluteIfAllowed(child: SceneNode, parent: FrameNode, allowAbsolute: boolean): void {
2666
+ if (!allowAbsolute) return;
2667
+ applyAbsoluteIfPossible(child, parent);
2668
+ }
2669
+
2670
+ export function createUIComponents(parent: FrameNode | PageNode, opts?: CreateUIComponentsOptions): Promise<FrameNode> {
2671
+ return createUIComponentsFromStory(parent, opts || {}, getStoryBuilderContext());
2672
+ }
2673
+
2674
+ export function pruneGeneratedComponentLibrary(themeNames: string[]): void {
2675
+ pruneGeneratedComponentLibraryFromStory(themeNames, getStoryBuilderContext());
2676
+ }