inkhouse 0.1.0-beta.0

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 (62) hide show
  1. package/README.md +201 -0
  2. package/bin/inkhouse.mjs +171 -0
  3. package/code.js +11802 -0
  4. package/manifest.json +30 -0
  5. package/package.json +45 -0
  6. package/scanner/blob-placement-regression.ts +132 -0
  7. package/scanner/class-collector.ts +69 -0
  8. package/scanner/cli.ts +336 -0
  9. package/scanner/component-scanner.ts +2876 -0
  10. package/scanner/css-patch-regression.ts +112 -0
  11. package/scanner/css-token-reader-regression.ts +92 -0
  12. package/scanner/css-token-reader.ts +477 -0
  13. package/scanner/font-style-resolver-regression.ts +32 -0
  14. package/scanner/index.ts +9 -0
  15. package/scanner/radial-gradient-regression.ts +53 -0
  16. package/scanner/style-map.ts +145 -0
  17. package/scanner/tailwind-parser.ts +644 -0
  18. package/scanner/transform-math-regression.ts +42 -0
  19. package/scanner/types.ts +298 -0
  20. package/src/blob-placement.ts +111 -0
  21. package/src/change-detection.ts +204 -0
  22. package/src/class-utils.ts +105 -0
  23. package/src/clip-path-decorative.ts +194 -0
  24. package/src/color-resolver.ts +98 -0
  25. package/src/colors.ts +196 -0
  26. package/src/component-defs.ts +54 -0
  27. package/src/component-gen.ts +561 -0
  28. package/src/component-lookup.ts +82 -0
  29. package/src/config.ts +115 -0
  30. package/src/design-system.ts +59 -0
  31. package/src/dev-server.ts +173 -0
  32. package/src/figma-globals.d.ts +3 -0
  33. package/src/font-style-resolver.ts +171 -0
  34. package/src/github.ts +1465 -0
  35. package/src/icon-builder.ts +607 -0
  36. package/src/image-cache.ts +22 -0
  37. package/src/inline-text.ts +271 -0
  38. package/src/layout-parser.ts +667 -0
  39. package/src/layout-utils.ts +155 -0
  40. package/src/main.ts +687 -0
  41. package/src/node-ir.ts +595 -0
  42. package/src/pack-provider.ts +148 -0
  43. package/src/packs.ts +126 -0
  44. package/src/radial-gradient.ts +84 -0
  45. package/src/render-context.ts +138 -0
  46. package/src/responsive-analyzer.ts +139 -0
  47. package/src/state-analyzer.ts +143 -0
  48. package/src/story-builder.ts +1706 -0
  49. package/src/story-layout.ts +38 -0
  50. package/src/tailwind.ts +2379 -0
  51. package/src/text-builder.ts +116 -0
  52. package/src/text-line.ts +42 -0
  53. package/src/token-source.ts +43 -0
  54. package/src/tokens.ts +717 -0
  55. package/src/transform-math.ts +44 -0
  56. package/src/ui-builder.ts +1996 -0
  57. package/src/utility-resolver.ts +125 -0
  58. package/src/variables.ts +1042 -0
  59. package/src/width-solver.ts +466 -0
  60. package/templates/patch-tokens-route.ts +165 -0
  61. package/templates/scan-components-route.ts +57 -0
  62. package/ui.html +1222 -0
@@ -0,0 +1,1996 @@
1
+ import { parseColor, type RGB } from './colors';
2
+ import { pxFromSizeToken } from './variables';
3
+ import { applyTailwindStylesToFrame, applyBorderWidthUtilities, applyAbsoluteIfPossible, applyFullWidthIfPossible, markFullWidthNode, applyDeferredBottomPositioning, applyDeferredCenterYPositioning } from './tailwind';
4
+ import { createCompoundComponent } from './component-gen';
5
+ import { LayoutParser } from './layout-parser';
6
+ import { extractTextColorToken, resolveTextColorFallback, resolveTextColorValue } from './color-resolver';
7
+ import { TAG_TYPOGRAPHY, createTextNode, getLineHeightFromClasses, getNodeTextStyle, getTextAlignFromClasses } from './text-builder';
8
+ import { defaultGridWidth } from './story-layout';
9
+ import { getCompoundClasses, mergeClasses } from './class-utils';
10
+ import { getClassesForBreakpoint } from './responsive-analyzer';
11
+ import {
12
+ type StoryBuilderContext,
13
+ createCVAStoryInstance,
14
+ createStateStoryInstance,
15
+ createUIComponents as createUIComponentsFromStory,
16
+ } from './story-builder';
17
+ import { applyClipBehavior, shouldClipContent, applyGridColumnsWithReflow, maybeWrapMxAuto, resolveGridWidthOverride } from './layout-utils';
18
+ import { getComponentDefByName, getIconRegistryEntry, getReactIconKey } from './component-lookup';
19
+ import {
20
+ type RenderContext,
21
+ hasWidthHintInClasses,
22
+ propsContainWidthHint,
23
+ hasExplicitHeight,
24
+ } from './render-context';
25
+ import { createInlineTextNode, isInlineTextNode } from './inline-text';
26
+ import { maybeExpandTextLineComponent } from './text-line';
27
+ import {
28
+ extractGridColumns,
29
+ getBaseClass,
30
+ getNodeLayoutComputed,
31
+ shouldSkipFullWidthForClasses,
32
+ shouldStretchToParentWidth,
33
+ solveLayoutWidths,
34
+ } from './width-solver';
35
+ import { parseUtilityClass } from './utility-resolver';
36
+ import { getImageHash, getSvgString } from './image-cache';
37
+ import { buildDecorativeClipPathNode, parseClipPathFromStyle } from './clip-path-decorative';
38
+ import {
39
+ type JsxNode,
40
+ type JsxElement,
41
+ type JsxText,
42
+ type NodeIR,
43
+ type NodeIRElement,
44
+ type NodeIRHelpers,
45
+ resolveNodeIR,
46
+ applyNodeTransforms,
47
+ isElementLikeNode,
48
+ } from './node-ir';
49
+ // Icon handling extracted to separate module
50
+ import {
51
+ ICON_PATHS,
52
+ createIcon,
53
+ createIconFromSvg,
54
+ nodeIrToSvg,
55
+ resizeSvgNodeTo,
56
+ wrapIconNode,
57
+ resolveIconSizeFromClasses,
58
+ getVectorPaintUsage,
59
+ } from './icon-builder';
60
+
61
+ declare const figma: any;
62
+
63
+ const RESPONSIVE_BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', '2xl'] as const;
64
+ const RESPONSIVE_BREAKPOINT_MIN_WIDTH: Record<typeof RESPONSIVE_BREAKPOINT_ORDER[number], number> = {
65
+ sm: 640,
66
+ md: 768,
67
+ lg: 1024,
68
+ xl: 1280,
69
+ '2xl': 1536,
70
+ };
71
+
72
+ function resolveBreakpointIndexForWidth(width?: number): number {
73
+ if (!Number.isFinite(width) || (width as number) <= 0) return -1;
74
+ let idx = -1;
75
+ for (let i = 0; i < RESPONSIVE_BREAKPOINT_ORDER.length; i++) {
76
+ const bp = RESPONSIVE_BREAKPOINT_ORDER[i];
77
+ if ((width as number) >= RESPONSIVE_BREAKPOINT_MIN_WIDTH[bp]) {
78
+ idx = i;
79
+ }
80
+ }
81
+ return idx;
82
+ }
83
+
84
+ function isAccordionRootTag(tagName: string): boolean {
85
+ return tagName === 'Accordion' || tagName === 'AccordionPrimitive.Root';
86
+ }
87
+
88
+ function isAccordionItemTag(tagName: string): boolean {
89
+ return tagName === 'AccordionItem' || tagName === 'AccordionPrimitive.Item';
90
+ }
91
+
92
+ function isAccordionContentTag(tagName: string): boolean {
93
+ return tagName === 'AccordionContent' || tagName === 'AccordionPrimitive.Content';
94
+ }
95
+
96
+ function isAccordionHeaderTag(tagName: string): boolean {
97
+ return tagName === 'AccordionHeader' || tagName === 'AccordionPrimitive.Header';
98
+ }
99
+
100
+ function isAccordionChevronTag(tagName: string): boolean {
101
+ return tagName === 'ChevronDownIcon' || tagName === 'ChevronDown';
102
+ }
103
+
104
+ function normalizeAccordionDefaultValue(value: unknown): string | null {
105
+ if (typeof value !== 'string') return null;
106
+ const trimmed = value.trim();
107
+ if (!trimmed || trimmed === 'defaultValue' || trimmed === 'undefined' || trimmed === 'null') {
108
+ return null;
109
+ }
110
+ return trimmed;
111
+ }
112
+
113
+ function resolveAccordionItemValue(value: unknown, itemIndex?: number): string | null {
114
+ if (typeof value === 'string') {
115
+ const trimmed = value.trim();
116
+ if (trimmed) {
117
+ if (trimmed.includes('${i}')) {
118
+ return Number.isFinite(itemIndex) ? `item-${itemIndex}` : null;
119
+ }
120
+ return trimmed;
121
+ }
122
+ }
123
+ return Number.isFinite(itemIndex) ? `item-${itemIndex}` : null;
124
+ }
125
+
126
+ function createPerChildRenderContext(
127
+ baseContext: RenderContext,
128
+ parentNode: NodeIR,
129
+ child: NodeIR,
130
+ accordionItemOrdinal: { value: number }
131
+ ): RenderContext {
132
+ const childContext: RenderContext = { ...baseContext };
133
+ if (
134
+ (parentNode.kind === 'element' || parentNode.kind === 'component') &&
135
+ isAccordionRootTag(parentNode.tagName) &&
136
+ (child.kind === 'element' || child.kind === 'component') &&
137
+ isAccordionItemTag(child.tagName)
138
+ ) {
139
+ childContext.accordionItemIndex = accordionItemOrdinal.value++;
140
+ }
141
+ return childContext;
142
+ }
143
+
144
+ function getRenderableChildren(node: NodeIR, classes: string[]): NodeIR[] {
145
+ if (node.kind !== 'element' && node.kind !== 'component') return [];
146
+ const children = node.children.slice();
147
+ if (node.kind !== 'element' || node.tagLower !== 'table') return children;
148
+ if (!classes.includes('caption-bottom')) return children;
149
+
150
+ const captions: NodeIR[] = [];
151
+ const others: NodeIR[] = [];
152
+ for (const child of children) {
153
+ if ((child.kind === 'element' || child.kind === 'component') && child.tagLower === 'caption') captions.push(child);
154
+ else others.push(child);
155
+ }
156
+ return captions.length > 0 ? [...others, ...captions] : children;
157
+ }
158
+
159
+ function hasTableCellSizeOverride(classes: string[]): boolean {
160
+ for (const cls of classes) {
161
+ const base = getBaseClass(cls);
162
+ if (!base) continue;
163
+ if (base === 'flex-1' || base === 'grow' || base === 'grow-0') return true;
164
+ if (base.startsWith('w-') || base.startsWith('min-w-') || base.startsWith('max-w-')) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ function getNumericColSpan(value: unknown): number {
170
+ if (typeof value === 'number' && Number.isFinite(value)) return Math.max(1, Math.floor(value));
171
+ if (typeof value === 'string') {
172
+ const parsed = parseInt(value, 10);
173
+ if (Number.isFinite(parsed)) return Math.max(1, parsed);
174
+ }
175
+ return 1;
176
+ }
177
+
178
+ function isSemanticTableContainer(node: NodeIR): boolean {
179
+ if (node.kind !== 'element' && node.kind !== 'component') return false;
180
+ return node.tagLower === 'table' || node.tagLower === 'thead' || node.tagLower === 'tbody' || node.tagLower === 'tfoot';
181
+ }
182
+
183
+ function clearTopBorder(node: SceneNode): void {
184
+ try {
185
+ (node as any).strokeTopWeight = 0;
186
+ } catch (_err) {
187
+ // ignore if unsupported
188
+ }
189
+ }
190
+
191
+ function clearBottomBorder(node: SceneNode): void {
192
+ try {
193
+ (node as any).strokeBottomWeight = 0;
194
+ } catch (_err) {
195
+ // ignore if unsupported
196
+ }
197
+ }
198
+
199
+ function constrainSingleHorizontalTextChild(frame: any): void {
200
+ if (
201
+ frame.layoutMode !== 'HORIZONTAL'
202
+ || !Array.isArray(frame.children)
203
+ || frame.children.length < 2
204
+ ) {
205
+ return;
206
+ }
207
+
208
+ // Keep naturally centered CTA/action rows intact.
209
+ // Constraining text width in CENTER rows makes one text child consume the
210
+ // remaining width, which shifts sibling controls off-center.
211
+ if (frame.primaryAxisAlignItems === 'CENTER') {
212
+ return;
213
+ }
214
+
215
+ // Width constraints only make sense once the row width is fixed.
216
+ if (frame.primaryAxisSizingMode !== 'FIXED') {
217
+ return;
218
+ }
219
+
220
+ const textChildren = frame.children.filter((child: any) => child?.type === 'TEXT');
221
+ if (textChildren.length !== 1) return;
222
+
223
+ const nonTextWidth = frame.children
224
+ .filter((child: any) => child?.type !== 'TEXT')
225
+ .reduce((sum: number, child: any) => sum + (child?.width || 0), 0);
226
+
227
+ const availableWidth = Math.max(
228
+ 0,
229
+ frame.width
230
+ - (frame.paddingLeft || 0)
231
+ - (frame.paddingRight || 0)
232
+ - (frame.itemSpacing || 0) * Math.max(0, frame.children.length - 1)
233
+ - nonTextWidth,
234
+ );
235
+
236
+ if (availableWidth <= 0) return;
237
+
238
+ const textChild = textChildren[0];
239
+ try {
240
+ textChild.textAutoResize = 'HEIGHT';
241
+ textChild.resize(availableWidth, textChild.height);
242
+ } catch (_err) {
243
+ // ignore resize errors
244
+ }
245
+ }
246
+
247
+ function stabilizeHorizontalStretchChild(child: any, parent: any): void {
248
+ if (
249
+ !child
250
+ || child.type !== 'FRAME'
251
+ || child.layoutMode !== 'HORIZONTAL'
252
+ || child.layoutAlign !== 'STRETCH'
253
+ || child.primaryAxisSizingMode === 'FIXED'
254
+ || !parent
255
+ || parent.layoutMode !== 'VERTICAL'
256
+ ) {
257
+ return;
258
+ }
259
+
260
+ const parentContentWidth = Math.max(
261
+ 0,
262
+ (parent.width || 0) - (parent.paddingLeft || 0) - (parent.paddingRight || 0),
263
+ );
264
+ if (parentContentWidth <= 0) return;
265
+
266
+ try {
267
+ child.resize(parentContentWidth, Math.max(1, child.height || 1));
268
+ child.primaryAxisSizingMode = 'FIXED';
269
+ } catch (_err) {
270
+ // ignore resize errors
271
+ }
272
+ }
273
+
274
+ const TEXT_SIZE_PX: Record<string, number> = {
275
+ 'text-xs': 12,
276
+ 'text-sm': 14,
277
+ 'text-base': 16,
278
+ 'text-lg': 18,
279
+ 'text-xl': 20,
280
+ 'text-2xl': 24,
281
+ 'text-3xl': 30,
282
+ 'text-4xl': 36,
283
+ 'text-5xl': 48,
284
+ };
285
+
286
+ const FONT_WEIGHT_RANK: Record<string, number> = {
287
+ 'font-thin': 1,
288
+ 'font-extralight': 2,
289
+ 'font-light': 3,
290
+ 'font-normal': 4,
291
+ 'font-medium': 5,
292
+ 'font-semibold': 6,
293
+ 'font-bold': 7,
294
+ 'font-extrabold': 8,
295
+ 'font-black': 9,
296
+ };
297
+
298
+ const FONT_WEIGHT_TO_STYLE: Record<string, string> = {
299
+ 'font-thin': 'Thin',
300
+ 'font-extralight': 'ExtraLight',
301
+ 'font-light': 'Light',
302
+ 'font-normal': 'Regular',
303
+ 'font-medium': 'Medium',
304
+ 'font-semibold': 'SemiBold',
305
+ 'font-bold': 'Bold',
306
+ 'font-extrabold': 'ExtraBold',
307
+ 'font-black': 'Black',
308
+ };
309
+
310
+ function getFontStyleFromClasses(classes: string[], fallback?: string): string | undefined {
311
+ let found: string | undefined;
312
+ for (const cls of classes) {
313
+ const style = FONT_WEIGHT_TO_STYLE[cls];
314
+ if (style) { found = style; }
315
+ }
316
+ if (found == null && fallback == null) return undefined;
317
+ return found != null ? found : fallback;
318
+ }
319
+
320
+ function getResolvedTextSizeFromClasses(classes: string[], fallback?: number): number | undefined {
321
+ let resolved = fallback;
322
+ for (const cls of classes) {
323
+ if (TEXT_SIZE_PX[cls] != null) resolved = TEXT_SIZE_PX[cls];
324
+ }
325
+ return resolved;
326
+ }
327
+
328
+ function getResolvedBoldFromClasses(classes: string[], fallback?: boolean): boolean | undefined {
329
+ let rank = fallback ? 7 : 4;
330
+ let seenWeight = false;
331
+ for (const cls of classes) {
332
+ if (FONT_WEIGHT_RANK[cls] != null) {
333
+ rank = FONT_WEIGHT_RANK[cls];
334
+ seenWeight = true;
335
+ }
336
+ }
337
+ if (!seenWeight && fallback == null) return undefined;
338
+ return rank >= FONT_WEIGHT_RANK['font-semibold'];
339
+ }
340
+
341
+ function getResponsiveConflictGroup(utility: string): string | null {
342
+ if (
343
+ utility === 'flex-row' ||
344
+ utility === 'flex-row-reverse' ||
345
+ utility === 'flex-col' ||
346
+ utility === 'flex-col-reverse'
347
+ ) {
348
+ return 'layout-direction';
349
+ }
350
+
351
+ if (
352
+ utility === 'justify-start' ||
353
+ utility === 'justify-center' ||
354
+ utility === 'justify-end' ||
355
+ utility === 'justify-between' ||
356
+ utility === 'justify-around' ||
357
+ utility === 'justify-evenly'
358
+ ) {
359
+ return 'main-align';
360
+ }
361
+
362
+ if (
363
+ utility === 'items-start' ||
364
+ utility === 'items-center' ||
365
+ utility === 'items-end' ||
366
+ utility === 'items-stretch' ||
367
+ utility === 'items-baseline'
368
+ ) {
369
+ return 'cross-align';
370
+ }
371
+
372
+ if (
373
+ utility === 'text-left' ||
374
+ utility === 'text-center' ||
375
+ utility === 'text-right' ||
376
+ utility === 'text-justify'
377
+ ) {
378
+ return 'text-align';
379
+ }
380
+
381
+ return null;
382
+ }
383
+
384
+ function resolveClassesForBreakpoint(classes: string[], breakpointIndex: number): string[] {
385
+ if (!classes || classes.length === 0) return classes || [];
386
+
387
+ const out: string[] = [];
388
+ const conflictIndex = new Map<string, number>();
389
+ for (let i = 0; i < classes.length; i++) {
390
+ const atom = parseUtilityClass(classes[i]);
391
+ if (!atom.utility) continue;
392
+
393
+ let requiredBreakpoint = -1;
394
+ const remainingVariants: string[] = [];
395
+ for (let j = 0; j < atom.variants.length; j++) {
396
+ const variant = atom.variants[j];
397
+ const responsiveIdx = RESPONSIVE_BREAKPOINT_ORDER.indexOf(variant as any);
398
+ if (responsiveIdx !== -1) {
399
+ if (responsiveIdx > requiredBreakpoint) requiredBreakpoint = responsiveIdx;
400
+ } else {
401
+ remainingVariants.push(variant);
402
+ }
403
+ }
404
+
405
+ if (requiredBreakpoint !== -1 && breakpointIndex < requiredBreakpoint) {
406
+ continue;
407
+ }
408
+
409
+ const rebuilt = remainingVariants.length > 0
410
+ ? remainingVariants.join(':') + ':' + atom.utility
411
+ : atom.utility;
412
+ const finalClass = atom.important ? '!' + rebuilt : rebuilt;
413
+ const conflictGroup = getResponsiveConflictGroup(atom.utility);
414
+ if (conflictGroup) {
415
+ const variantScope = remainingVariants.join(':');
416
+ const conflictKey = `${variantScope}|${conflictGroup}`;
417
+ const existingIndex = conflictIndex.get(conflictKey);
418
+ if (existingIndex !== undefined) {
419
+ out[existingIndex] = finalClass;
420
+ } else {
421
+ conflictIndex.set(conflictKey, out.length);
422
+ out.push(finalClass);
423
+ }
424
+ continue;
425
+ }
426
+
427
+ out.push(finalClass);
428
+ }
429
+ return out;
430
+ }
431
+
432
+ function resolveNodeResponsiveClasses(node: NodeIR, width?: number): NodeIR {
433
+ const breakpointIndex = resolveBreakpointIndexForWidth(width);
434
+ if (breakpointIndex === -1) return node;
435
+
436
+ if (node.kind === 'text' || node.kind === 'divider') return node;
437
+
438
+ if (node.kind === 'fragment') {
439
+ return Object.assign({}, node, {
440
+ children: node.children.map(child => resolveNodeResponsiveClasses(child, width)),
441
+ });
442
+ }
443
+
444
+ if (node.kind === 'ring') {
445
+ return Object.assign({}, node, {
446
+ classes: resolveClassesForBreakpoint(node.classes, breakpointIndex),
447
+ child: resolveNodeResponsiveClasses(node.child, width),
448
+ });
449
+ }
450
+
451
+ return Object.assign({}, node, {
452
+ classes: resolveClassesForBreakpoint(node.classes, breakpointIndex),
453
+ children: node.children.map(child => resolveNodeResponsiveClasses(child, width)),
454
+ });
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Constants (Icon code moved to icon-builder.ts)
459
+ // ---------------------------------------------------------------------------
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Functions
463
+ // ---------------------------------------------------------------------------
464
+
465
+ function resolveContextTextColorValue(
466
+ context: RenderContext,
467
+ colorGroup: Record<string, string>,
468
+ theme: string
469
+ ): string | RGB | null {
470
+ return resolveTextColorValue(context.textColor, context.textColorToken, colorGroup, theme);
471
+ }
472
+
473
+ // SVG/icon functions moved to icon-builder.ts
474
+
475
+ function resolveIconForegroundColor(
476
+ context: RenderContext,
477
+ colorGroup: Record<string, string>,
478
+ theme: string
479
+ ): RGB {
480
+ const resolvedTextColor = resolveContextTextColorValue(context, colorGroup, theme);
481
+ if (resolvedTextColor) return parseColor(resolvedTextColor);
482
+ if (colorGroup.foreground) return parseColor(colorGroup.foreground);
483
+ return { r: 0.1, g: 0.1, b: 0.1 };
484
+ }
485
+
486
+ function wrapSvgIcon(
487
+ icon: SceneNode,
488
+ classes: string[],
489
+ props: Record<string, string>,
490
+ name: string
491
+ ): FrameNode {
492
+ const size = resolveIconSizeFromClasses(classes, props);
493
+ resizeSvgNodeTo(icon, size.width, size.height);
494
+ const usage = getVectorPaintUsage(icon);
495
+ const pad = usage.hasStrokes ? 1 : 0;
496
+ return wrapIconNode(icon, size.width, size.height, pad, name);
497
+ }
498
+
499
+ function wrapMappedIcon(
500
+ icon: SceneNode,
501
+ iconKey: string,
502
+ tagName: string,
503
+ classes: string[],
504
+ props: Record<string, string>,
505
+ rotateAccordionChevron: boolean
506
+ ): FrameNode {
507
+ const size = resolveIconSizeFromClasses(classes, props);
508
+ resizeSvgNodeTo(icon, size.width, size.height);
509
+ const pad = ICON_PATHS[iconKey].stroke
510
+ ? Math.max(1, Math.ceil((ICON_PATHS[iconKey].strokeWidth || 1.5) / 2))
511
+ : 0;
512
+ const wrapped = wrapIconNode(icon, size.width, size.height, pad, `icon/${iconKey}`);
513
+ if (rotateAccordionChevron && isAccordionChevronTag(tagName)) {
514
+ wrapped.rotation = 180;
515
+ }
516
+ return wrapped;
517
+ }
518
+
519
+ function renderRegistryIcon(
520
+ tagName: string,
521
+ classes: string[],
522
+ props: Record<string, string>,
523
+ fgColor: RGB
524
+ ): FrameNode | null {
525
+ const iconEntry = getIconRegistryEntry(tagName);
526
+ if (!iconEntry || !iconEntry.svg) return null;
527
+ const icon = createIconFromSvg(iconEntry.svg, fgColor);
528
+ if (!icon) return null;
529
+ return wrapSvgIcon(icon, classes, props, `icon/${tagName}`);
530
+ }
531
+
532
+ function renderMappedIcon(
533
+ tagName: string,
534
+ classes: string[],
535
+ props: Record<string, string>,
536
+ fgColor: RGB,
537
+ rotateAccordionChevron: boolean
538
+ ): FrameNode | null {
539
+ const iconKey = getReactIconKey(tagName);
540
+ if (!iconKey || !ICON_PATHS[iconKey]) return null;
541
+ const icon = createIcon(iconKey, fgColor);
542
+ if (!icon) return null;
543
+ return wrapMappedIcon(icon, iconKey, tagName, classes, props, rotateAccordionChevron);
544
+ }
545
+
546
+ function normalizeComponentDef(raw: any): any {
547
+ if (!raw || !raw.analysis) return raw;
548
+
549
+ // Scanner output stores component metadata (stories, layout, responsive, colorScheme)
550
+ // alongside the nested `analysis`. Many render paths want the analysis shape but still
551
+ // need the attached metadata, so preserve it when normalizing.
552
+ return {
553
+ ...raw.analysis,
554
+ stories: Array.isArray(raw.analysis.stories)
555
+ ? raw.analysis.stories
556
+ : (Array.isArray(raw.stories) ? raw.stories : []),
557
+ hasStory: typeof raw.analysis.hasStory === 'boolean'
558
+ ? raw.analysis.hasStory
559
+ : !!raw.hasStory,
560
+ layout: raw.layout,
561
+ responsive: raw.responsive,
562
+ colorScheme: raw.colorScheme,
563
+ };
564
+ }
565
+
566
+ function nodeHasClipPathDescendant(node: NodeIR): boolean {
567
+ if (node.kind === 'text' || node.kind === 'divider') return false;
568
+ if (node.kind === 'ring') return nodeHasClipPathDescendant(node.child);
569
+ if (node.kind === 'fragment') {
570
+ for (let i = 0; i < node.children.length; i++) {
571
+ if (nodeHasClipPathDescendant(node.children[i])) return true;
572
+ }
573
+ return false;
574
+ }
575
+ if (node.kind === 'element') {
576
+ if (parseClipPathFromStyle(node.props.style)) return true;
577
+ }
578
+ for (let i = 0; i < node.children.length; i++) {
579
+ if (nodeHasClipPathDescendant(node.children[i])) return true;
580
+ }
581
+ return false;
582
+ }
583
+
584
+ /**
585
+ * Render a component using its internal jsxTree structure.
586
+ * This handles components with decorative elements (gradient backgrounds, blurred shapes, etc.)
587
+ * by rendering the component's actual DOM structure instead of a simple wrapper.
588
+ */
589
+ function renderComponentWithJsxTree(
590
+ jsxTree: JsxNode,
591
+ actualChildren: NodeIR[],
592
+ colorGroup: Record<string, string>,
593
+ radiusGroup: Record<string, string> | null,
594
+ theme: string,
595
+ depth: number,
596
+ context: RenderContext
597
+ ): SceneNode | null {
598
+ // Convert the component's jsxTree to NodeIR and render it
599
+ // When we encounter {children} text, substitute with actualChildren
600
+ const substitutedTree = substituteChildrenInJsxTree(jsxTree, actualChildren);
601
+ if (!substitutedTree) return null;
602
+
603
+ // Resolve and render the substituted tree
604
+ const resolved = resolveNodeIR(substitutedTree);
605
+ if (!resolved) return null;
606
+
607
+ const nodeHelpers: NodeIRHelpers = {
608
+ getComponentDefByName: getComponentDefByName,
609
+ normalizeComponentDef: normalizeComponentDef,
610
+ getCompoundClasses: getCompoundClasses,
611
+ mergeClasses: mergeClasses,
612
+ };
613
+ const transformed = applyNodeTransforms(resolved, colorGroup, nodeHelpers);
614
+ const responsiveResolved = resolveNodeResponsiveClasses(transformed, context.maxWidth);
615
+ const solved = solveLayoutWidths(responsiveResolved, context.maxWidth);
616
+ return buildFigmaNode(solved, colorGroup, radiusGroup, theme, depth, context);
617
+ }
618
+
619
+ /**
620
+ * Recursively substitute {children} text nodes with actual children from the story.
621
+ */
622
+ function substituteChildrenInJsxTree(
623
+ jsxNode: JsxNode,
624
+ actualChildren: NodeIR[]
625
+ ): JsxNode | null {
626
+ if (jsxNode.type === 'text') {
627
+ const textNode = jsxNode as JsxText;
628
+ // Check if this is a {children} placeholder
629
+ if (textNode.content.trim() === '{children}') {
630
+ // Return a fragment-like structure - we'll handle this in element processing
631
+ return null; // Will be handled specially in element processing
632
+ }
633
+ return jsxNode;
634
+ }
635
+
636
+ const element = jsxNode as JsxElement;
637
+ const newChildren: JsxNode[] = [];
638
+
639
+ for (const child of element.children || []) {
640
+ if (child.type === 'text') {
641
+ const textChild = child as JsxText;
642
+ if (textChild.content.trim() === '{children}') {
643
+ // Convert NodeIR children back to JsxNode format for consistency
644
+ for (const actualChild of actualChildren) {
645
+ const jsxChild = nodeIRToJsxNode(actualChild);
646
+ if (jsxChild) {
647
+ newChildren.push(jsxChild);
648
+ }
649
+ }
650
+ continue;
651
+ }
652
+ }
653
+ const substituted = substituteChildrenInJsxTree(child, actualChildren);
654
+ if (substituted) {
655
+ newChildren.push(substituted);
656
+ }
657
+ }
658
+
659
+ return {
660
+ type: 'element',
661
+ tagName: element.tagName,
662
+ isComponent: element.isComponent,
663
+ props: element.props,
664
+ children: newChildren,
665
+ } as JsxElement;
666
+ }
667
+
668
+ /**
669
+ * Check whether a component jsxTree explicitly exposes a {children} slot.
670
+ * If it does not, rendering the tree for an instance with children would drop
671
+ * those children, so callers should use the wrapper fallback instead.
672
+ */
673
+ function jsxTreeHasChildrenSlot(jsxNode: JsxNode | null | undefined): boolean {
674
+ if (!jsxNode) return false;
675
+ if (jsxNode.type === 'text') {
676
+ const content = (jsxNode as JsxText).content.trim();
677
+ return /^\{\s*children\s*\}$/.test(content);
678
+ }
679
+ const element = jsxNode as JsxElement;
680
+ for (const child of element.children || []) {
681
+ if (jsxTreeHasChildrenSlot(child)) return true;
682
+ }
683
+ return false;
684
+ }
685
+
686
+ /**
687
+ * Convert a NodeIR node back to JsxNode format.
688
+ */
689
+ function nodeIRToJsxNode(node: NodeIR): JsxNode | null {
690
+ if (node.kind === 'text') {
691
+ return { type: 'text', content: node.text } as JsxText;
692
+ }
693
+
694
+ if (node.kind === 'element' || node.kind === 'component') {
695
+ const children: JsxNode[] = [];
696
+ for (const child of node.children || []) {
697
+ const jsxChild = nodeIRToJsxNode(child);
698
+ if (jsxChild) {
699
+ children.push(jsxChild);
700
+ }
701
+ }
702
+ return {
703
+ type: 'element',
704
+ tagName: node.tagName,
705
+ isComponent: node.kind === 'component',
706
+ props: Object.assign({}, node.props, { className: node.classes.join(' ') }),
707
+ children,
708
+ } as JsxElement;
709
+ }
710
+
711
+ if (node.kind === 'fragment') {
712
+ // Wrap fragment children in a div
713
+ const children: JsxNode[] = [];
714
+ for (const child of node.children || []) {
715
+ const jsxChild = nodeIRToJsxNode(child);
716
+ if (jsxChild) {
717
+ children.push(jsxChild);
718
+ }
719
+ }
720
+ return {
721
+ type: 'element',
722
+ tagName: 'div',
723
+ isComponent: false,
724
+ props: {},
725
+ children,
726
+ } as JsxElement;
727
+ }
728
+
729
+ return null;
730
+ }
731
+
732
+ // ---------------------------------------------------------------------------
733
+ // JSX Tree Rendering
734
+ // ---------------------------------------------------------------------------
735
+
736
+ /**
737
+ * Create a separator node for divide-y/divide-x
738
+ */
739
+ function createDivideSeparator(
740
+ direction: 'HORIZONTAL' | 'VERTICAL',
741
+ color: RGB,
742
+ parentWidth: number
743
+ ): FrameNode {
744
+ const sep = figma.createFrame();
745
+ sep.name = 'divider';
746
+ applyClipBehavior(sep, []);
747
+
748
+ if (direction === 'HORIZONTAL') {
749
+ // Horizontal line (for divide-y in vertical list)
750
+ sep.resize(Math.max(parentWidth, 100), 1);
751
+ sep.layoutAlign = 'STRETCH';
752
+ } else {
753
+ // Vertical line (for divide-x in horizontal list)
754
+ sep.resize(1, 24); // Height will adjust
755
+ sep.layoutGrow = 0;
756
+ sep.layoutAlign = 'STRETCH';
757
+ }
758
+
759
+ sep.fills = [{ type: 'SOLID', color: { r: color.r, g: color.g, b: color.b }, opacity: 1 }];
760
+ return sep;
761
+ }
762
+ function resolveCornerRadiusFromClasses(
763
+ classes: string[],
764
+ radiusGroup: Record<string, string> | null
765
+ ): number | null {
766
+ if (classes.includes('rounded-full')) return 9999;
767
+ if (classes.includes('rounded-none')) return 0;
768
+
769
+ for (const cls of classes) {
770
+ const match = cls.match(/^rounded-\[(\d+(?:\.\d+)?)px\]$/);
771
+ if (match) return parseFloat(match[1]);
772
+ }
773
+
774
+ const tokenMap: Record<string, string> = {
775
+ 'rounded-sm': 'sm',
776
+ 'rounded': 'base',
777
+ 'rounded-md': 'md',
778
+ 'rounded-lg': 'lg',
779
+ 'rounded-xl': 'xl',
780
+ 'rounded-2xl': '2xl',
781
+ 'rounded-3xl': '3xl',
782
+ };
783
+ const fallbackMap: Record<string, number> = {
784
+ sm: 2,
785
+ base: 4,
786
+ md: 6,
787
+ lg: 8,
788
+ xl: 12,
789
+ '2xl': 16,
790
+ '3xl': 24,
791
+ };
792
+
793
+ for (const cls of classes) {
794
+ const tokenKey = tokenMap[cls];
795
+ if (!tokenKey) continue;
796
+ if (radiusGroup && radiusGroup[tokenKey]) {
797
+ return pxFromSizeToken(radiusGroup[tokenKey]);
798
+ }
799
+ if (fallbackMap[tokenKey] != null) return fallbackMap[tokenKey];
800
+ }
801
+
802
+ return null;
803
+ }
804
+
805
+ /**
806
+ * Renders a JSX tree from the scanner into Figma frames
807
+ */
808
+ function getStoryBuilderContext(): StoryBuilderContext {
809
+ return {
810
+ getComponentDefByName: getComponentDefByName,
811
+ normalizeComponentDef: normalizeComponentDef,
812
+ applyClipBehavior: applyClipBehavior,
813
+ applyLayoutClasses: applyLayoutClasses,
814
+ hasExplicitHeight: hasExplicitHeight,
815
+ renderJsxTree: renderJsxTree,
816
+ applyAbsoluteIfAllowed: applyAbsoluteIfAllowed,
817
+ applyGridColumnsWithReflow: applyGridColumnsWithReflow,
818
+ hasWidthHintInClasses: hasWidthHintInClasses,
819
+ propsContainWidthHint: propsContainWidthHint,
820
+ };
821
+ }
822
+
823
+
824
+ export function renderJsxTree(
825
+ node: JsxNode,
826
+ colorGroup: Record<string, string>,
827
+ radiusGroup: Record<string, string> | null,
828
+ theme: string,
829
+ depth: number = 0,
830
+ context: RenderContext = {}
831
+ ): SceneNode | null {
832
+ const resolved = resolveNodeIR(node);
833
+ if (!resolved) return null;
834
+ const nodeHelpers: NodeIRHelpers = {
835
+ getComponentDefByName: getComponentDefByName,
836
+ normalizeComponentDef: normalizeComponentDef,
837
+ getCompoundClasses: getCompoundClasses,
838
+ mergeClasses: mergeClasses,
839
+ };
840
+ const transformed = applyNodeTransforms(resolved, colorGroup, nodeHelpers);
841
+ const responsiveResolved = resolveNodeResponsiveClasses(transformed, context.maxWidth);
842
+ const solved = solveLayoutWidths(responsiveResolved, context.maxWidth);
843
+ return buildFigmaNode(solved, colorGroup, radiusGroup, theme, depth, context);
844
+ }
845
+
846
+ function buildFigmaNode(
847
+ node: NodeIR,
848
+ colorGroup: Record<string, string>,
849
+ radiusGroup: Record<string, string> | null,
850
+ theme: string,
851
+ depth: number,
852
+ context: RenderContext
853
+ ): SceneNode | null {
854
+ if (node.kind === 'text') {
855
+ const opts: any = { fontSize: context.textFontSize || 14, theme };
856
+ if (context.textFontStyle != null) {
857
+ opts.fontStyle = context.textFontStyle;
858
+ } else if (context.textBold != null) {
859
+ opts.bold = context.textBold;
860
+ }
861
+ if (context.textLineHeight != null) {
862
+ opts.lineHeight = context.textLineHeight;
863
+ }
864
+ if (context.textColor) {
865
+ opts.fill = context.textColor;
866
+ }
867
+ if (context.textAlign) {
868
+ opts.textAlignHorizontal = context.textAlign;
869
+ }
870
+ const textNode = createTextNode(node.text, opts);
871
+ if (context.textTruncate && context.maxWidth) {
872
+ try {
873
+ const maxLines = context.textMaxLines || 1;
874
+ const fontSize = context.textFontSize || 14;
875
+ const lineH = context.textLineHeight || Math.ceil(fontSize * 1.5);
876
+ (textNode as any).textTruncation = 'ENDING';
877
+ (textNode as any).maxLines = maxLines;
878
+ textNode.resize(context.maxWidth, Math.max(lineH, lineH * maxLines));
879
+ } catch (_err) {
880
+ // ignore — truncation not supported in all plugin contexts
881
+ }
882
+ } else if (context.maxWidth && context.parentLayout !== 'HORIZONTAL') {
883
+ try {
884
+ textNode.textAutoResize = 'HEIGHT';
885
+ textNode.resize(context.maxWidth, textNode.height);
886
+ } catch (_err) {
887
+ // ignore resize errors
888
+ }
889
+ }
890
+ return textNode;
891
+ }
892
+
893
+ if (node.kind === 'fragment') {
894
+ if (node.children.length === 0) return null;
895
+ const wrapper = figma.createFrame();
896
+ wrapper.name = 'Fragment';
897
+ wrapper.layoutMode = 'VERTICAL';
898
+ wrapper.primaryAxisSizingMode = 'AUTO';
899
+ wrapper.counterAxisSizingMode = 'AUTO';
900
+ wrapper.fills = [];
901
+ applyClipBehavior(wrapper, []);
902
+ for (const child of node.children) {
903
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, context);
904
+ if (childNode) {
905
+ wrapper.appendChild(childNode);
906
+ if (isElementLikeNode(child)) {
907
+ LayoutParser.applyChildProperties(childNode, child.classes, wrapper);
908
+ }
909
+ stabilizeHorizontalStretchChild(childNode, wrapper);
910
+ constrainSingleHorizontalTextChild(childNode);
911
+ applyAbsoluteIfPossible(childNode, wrapper);
912
+ applyFullWidthIfPossible(childNode, wrapper, context.maxWidth != null ? { widthOverride: context.maxWidth } : undefined);
913
+ }
914
+ }
915
+ applyDeferredBottomPositioning(wrapper);
916
+ applyDeferredCenterYPositioning(wrapper);
917
+ return wrapper;
918
+ }
919
+
920
+ if (node.kind === 'divider') {
921
+ const parentWidth = context.maxWidth || 200;
922
+ return createDivideSeparator(node.direction, node.color, parentWidth);
923
+ }
924
+
925
+ if (node.kind === 'ring') {
926
+ const ring = figma.createFrame();
927
+ ring.name = 'ring';
928
+ ring.layoutMode = 'HORIZONTAL';
929
+ ring.primaryAxisSizingMode = 'AUTO';
930
+ ring.counterAxisSizingMode = 'AUTO';
931
+ applyClipBehavior(ring, []);
932
+ // Render ring as a stroke (CSS box-shadow equivalent), not a fill.
933
+ // A fill-based ring bleeds through transparent child backgrounds; strokes match CSS behavior.
934
+ ring.fills = [];
935
+ ring.strokes = [{
936
+ type: 'SOLID',
937
+ color: { r: node.ringColor.r, g: node.ringColor.g, b: node.ringColor.b },
938
+ opacity: node.ringOpacity,
939
+ }];
940
+ (ring as any).strokeWeight = node.ringWidth;
941
+ (ring as any).strokeAlign = 'OUTSIDE';
942
+ // No padding needed — stroke draws on the frame border
943
+
944
+ let baseRadius: number | null = null;
945
+ if (isElementLikeNode(node.child)) {
946
+ baseRadius = resolveCornerRadiusFromClasses(node.child.classes, radiusGroup);
947
+ }
948
+ if (baseRadius == null && radiusGroup && radiusGroup.base) {
949
+ baseRadius = pxFromSizeToken(radiusGroup.base);
950
+ }
951
+ if (baseRadius == null) baseRadius = 0;
952
+ ring.cornerRadius = baseRadius + node.offsetWidth;
953
+
954
+ let innerParent: FrameNode = ring;
955
+ if (node.offsetWidth > 0 && node.offsetColor) {
956
+ const offsetFrame = figma.createFrame();
957
+ offsetFrame.name = 'ring-offset';
958
+ offsetFrame.layoutMode = 'HORIZONTAL';
959
+ offsetFrame.primaryAxisSizingMode = 'AUTO';
960
+ offsetFrame.counterAxisSizingMode = 'AUTO';
961
+ applyClipBehavior(offsetFrame, []);
962
+ offsetFrame.fills = [{
963
+ type: 'SOLID',
964
+ color: { r: node.offsetColor.r, g: node.offsetColor.g, b: node.offsetColor.b },
965
+ opacity: 1,
966
+ }];
967
+ offsetFrame.strokes = [];
968
+ offsetFrame.paddingLeft = offsetFrame.paddingRight = offsetFrame.paddingTop = offsetFrame.paddingBottom = node.offsetWidth;
969
+ offsetFrame.cornerRadius = baseRadius + node.offsetWidth;
970
+ ring.appendChild(offsetFrame);
971
+ innerParent = offsetFrame;
972
+ }
973
+
974
+ const childNode = buildFigmaNode(node.child, colorGroup, radiusGroup, theme, depth + 1, context);
975
+ if (childNode) {
976
+ innerParent.appendChild(childNode);
977
+ if (isElementLikeNode(node.child)) {
978
+ LayoutParser.applyChildProperties(childNode, node.child.classes, innerParent);
979
+ }
980
+ stabilizeHorizontalStretchChild(childNode, innerParent);
981
+ constrainSingleHorizontalTextChild(childNode);
982
+ applyAbsoluteIfPossible(childNode, innerParent);
983
+ applyFullWidthIfPossible(childNode, innerParent, context.maxWidth != null ? { widthOverride: context.maxWidth } : undefined);
984
+ return maybeWrapMxAuto(ring, node.classes, context);
985
+ }
986
+
987
+ return null;
988
+ }
989
+
990
+ const tagLower = node.tagLower;
991
+ const breakpointIndex = resolveBreakpointIndexForWidth(
992
+ context.maxWidth != null ? context.maxWidth : defaultGridWidth(3)
993
+ );
994
+ let classes = resolveClassesForBreakpoint(node.classes, breakpointIndex);
995
+ if (isAccordionHeaderTag(node.tagName) && !classes.includes('w-full')) {
996
+ classes = mergeClasses(classes, ['w-full']);
997
+ }
998
+ const layoutComputed = getNodeLayoutComputed(node);
999
+ const alignFromClasses = getTextAlignFromClasses(classes);
1000
+ const textStyle = getNodeTextStyle(node, colorGroup);
1001
+ const textToken = textStyle.textToken || extractTextColorToken(classes) || context.textColorToken || null;
1002
+ const fallbackTextColor = resolveTextColorFallback(classes, colorGroup, theme);
1003
+ const ownTextColor = resolveTextColorValue(
1004
+ textStyle.text || fallbackTextColor,
1005
+ textToken,
1006
+ colorGroup,
1007
+ theme
1008
+ );
1009
+ const ownTextFontSize = getResolvedTextSizeFromClasses(classes, context.textFontSize);
1010
+ const ownTextBold = getResolvedBoldFromClasses(classes, context.textBold);
1011
+ const ownTextFontStyle = getFontStyleFromClasses(classes, context.textFontStyle);
1012
+ const ownTextLineHeight = ownTextFontSize != null
1013
+ ? (getLineHeightFromClasses(classes, ownTextFontSize) ?? context.textLineHeight)
1014
+ : context.textLineHeight;
1015
+ const inheritedTextColor = resolveTextColorValue(
1016
+ context.textColor,
1017
+ context.textColorToken,
1018
+ colorGroup,
1019
+ theme
1020
+ );
1021
+ const resolvedTextColor = ownTextColor || inheritedTextColor;
1022
+ let contextualMaxWidth = context.maxWidth;
1023
+ if (layoutComputed.explicitWidth != null) {
1024
+ contextualMaxWidth = layoutComputed.explicitWidth;
1025
+ } else if (layoutComputed.maxWidth != null) {
1026
+ contextualMaxWidth = context.maxWidth != null
1027
+ ? Math.min(layoutComputed.maxWidth, context.maxWidth)
1028
+ : layoutComputed.maxWidth;
1029
+ }
1030
+ // Detect text truncation from classes on this element; reset per-element (don't inherit)
1031
+ let _truncate: boolean | undefined;
1032
+ let _maxLines: number | undefined;
1033
+ if (classes.includes('truncate')) {
1034
+ _truncate = true; _maxLines = 1;
1035
+ } else {
1036
+ for (const cls of classes) {
1037
+ const m = cls.match(/^line-clamp-(\d+)$/);
1038
+ if (m) { _truncate = true; _maxLines = parseInt(m[1], 10); break; }
1039
+ }
1040
+ }
1041
+ const nextContext: RenderContext = {
1042
+ ...context,
1043
+ textAlign: alignFromClasses || context.textAlign,
1044
+ maxWidth: contextualMaxWidth,
1045
+ textFontSize: ownTextFontSize,
1046
+ textBold: ownTextBold,
1047
+ textFontStyle: ownTextFontStyle,
1048
+ textLineHeight: ownTextLineHeight,
1049
+ textColor: resolvedTextColor,
1050
+ textColorToken: textToken || context.textColorToken,
1051
+ textTruncate: _truncate,
1052
+ textMaxLines: _maxLines,
1053
+ };
1054
+
1055
+ if (isAccordionRootTag(node.tagName)) {
1056
+ nextContext.accordionOpenValue = normalizeAccordionDefaultValue(node.props?.defaultValue);
1057
+ nextContext.accordionItemIndex = undefined;
1058
+ nextContext.accordionItemValue = null;
1059
+ nextContext.accordionItemIsOpen = false;
1060
+ } else if (isAccordionItemTag(node.tagName)) {
1061
+ const accordionItemValue = resolveAccordionItemValue(node.props?.value, context.accordionItemIndex);
1062
+ nextContext.accordionItemValue = accordionItemValue;
1063
+ nextContext.accordionItemIsOpen = !!accordionItemValue &&
1064
+ !!nextContext.accordionOpenValue &&
1065
+ accordionItemValue === nextContext.accordionOpenValue;
1066
+ }
1067
+
1068
+ if (isAccordionContentTag(node.tagName) && !context.accordionItemIsOpen) {
1069
+ return null;
1070
+ }
1071
+
1072
+ // Check for hidden classes - skip rendering unless overridden by a display class
1073
+ // In responsive contexts "hidden sm:flex" produces ["hidden","flex"]; the last display
1074
+ // class wins (same semantics as CSS), so we must not skip when a display override follows.
1075
+ if (classes.includes('hidden') || classes.includes('sr-only')) {
1076
+ 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']);
1077
+ const hiddenIdx = Math.max(classes.lastIndexOf('hidden'), classes.lastIndexOf('sr-only'));
1078
+ const overridden = classes.slice(hiddenIdx + 1).some(c => DISPLAY_OVERRIDES.has(c));
1079
+ if (!overridden) {
1080
+ return null;
1081
+ }
1082
+ }
1083
+
1084
+ if (node.kind === 'element' && tagLower === 'svg') {
1085
+ const svgString = nodeIrToSvg(node);
1086
+ if (svgString) {
1087
+ const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme);
1088
+ const icon = createIconFromSvg(svgString, fgColor);
1089
+ if (icon) {
1090
+ const wrapped = wrapSvgIcon(icon, classes, node.props, 'icon/svg');
1091
+ return maybeWrapMxAuto(wrapped, classes, context);
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // Image placeholder: <img> and Next.js <Image>
1097
+ if (tagLower === 'img' || node.tagName === 'Image') {
1098
+ const ir = layoutComputed.layoutIR;
1099
+ // Prefer Tailwind-derived fixed sizes, then explicit width/height props, then defaults
1100
+ const w = ir.fixedWidth
1101
+ ?? (node.props.width != null ? parseFloat(String(node.props.width)) : null)
1102
+ ?? 200;
1103
+ const h = ir.fixedHeight
1104
+ ?? (node.props.height != null ? parseFloat(String(node.props.height)) : null)
1105
+ ?? 150;
1106
+ const imgFrame = figma.createFrame();
1107
+ imgFrame.name = node.props.alt ? String(node.props.alt) : 'image';
1108
+ // layoutMode must stay NONE (fixed-size image frame); don't set sizing mode properties
1109
+ imgFrame.layoutMode = 'NONE';
1110
+ // Apply border radius from Tailwind classes (fills set below)
1111
+ applyTailwindStylesToFrame(imgFrame, classes, colorGroup, radiusGroup, theme);
1112
+ // Resize after applyTailwindStylesToFrame in case it modifies the frame
1113
+ imgFrame.resize(Math.max(1, w), Math.max(1, h));
1114
+
1115
+ const src = node.props.src ? String(node.props.src) : null;
1116
+ const svgString = src ? getSvgString(src) : null;
1117
+ const imageHash = src ? getImageHash(src) : null;
1118
+
1119
+ if (svgString) {
1120
+ // SVG: render as native vector node via createNodeFromSvg
1121
+ try {
1122
+ const svgNode = figma.createNodeFromSvg(svgString);
1123
+ svgNode.name = node.props.alt ? String(node.props.alt) : 'image';
1124
+ svgNode.resize(Math.max(1, w), Math.max(1, h));
1125
+ imgFrame.remove();
1126
+ return maybeWrapMxAuto(svgNode, classes, context);
1127
+ } catch (_e) { /* fall through to placeholder */ }
1128
+ }
1129
+
1130
+ if (imageHash) {
1131
+ imgFrame.fills = [{ type: 'IMAGE', scaleMode: 'FILL', imageHash } as ImagePaint];
1132
+ } else {
1133
+ imgFrame.fills = [{ type: 'SOLID', color: { r: 0.878, g: 0.894, b: 0.914 }, opacity: 1 }];
1134
+ try {
1135
+ 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>`;
1136
+ const iconNode = figma.createNodeFromSvg(placeholderSvg);
1137
+ if (iconNode) {
1138
+ iconNode.x = Math.max(0, Math.round((w - 24) / 2));
1139
+ iconNode.y = Math.max(0, Math.round((h - 24) / 2));
1140
+ imgFrame.appendChild(iconNode);
1141
+ }
1142
+ } catch (_e) { /* createNodeFromSvg not available in all contexts */ }
1143
+ }
1144
+ return maybeWrapMxAuto(imgFrame, classes, context);
1145
+ }
1146
+
1147
+ if (node.kind === 'element') {
1148
+ const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme);
1149
+ const registryIcon = renderRegistryIcon(node.tagName, classes, node.props, fgColor);
1150
+ if (registryIcon) {
1151
+ return maybeWrapMxAuto(registryIcon, classes, context);
1152
+ }
1153
+
1154
+ const mappedIcon = renderMappedIcon(
1155
+ node.tagName,
1156
+ classes,
1157
+ node.props,
1158
+ fgColor,
1159
+ !!nextContext.accordionItemIsOpen
1160
+ );
1161
+ if (mappedIcon) {
1162
+ return maybeWrapMxAuto(mappedIcon, classes, context);
1163
+ }
1164
+ }
1165
+
1166
+ // Check for React component (uppercase) - try to find definition
1167
+ if (node.kind === 'component') {
1168
+ const expanded = maybeExpandTextLineComponent(node);
1169
+ if (expanded) {
1170
+ return buildFigmaNode(expanded, colorGroup, radiusGroup, theme, depth, context);
1171
+ }
1172
+ // Check if this is a React Icon we can render
1173
+ const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme);
1174
+ const registryIcon = renderRegistryIcon(node.tagName, classes, node.props, fgColor);
1175
+ if (registryIcon) {
1176
+ return maybeWrapMxAuto(registryIcon, classes, context);
1177
+ }
1178
+
1179
+ const mappedIcon = renderMappedIcon(
1180
+ node.tagName,
1181
+ classes,
1182
+ node.props,
1183
+ fgColor,
1184
+ !!nextContext.accordionItemIsOpen
1185
+ );
1186
+ if (mappedIcon) {
1187
+ return maybeWrapMxAuto(mappedIcon, classes, context);
1188
+ }
1189
+
1190
+ const compDef = getComponentDefByName(node.tagName);
1191
+ const storyContext = getStoryBuilderContext();
1192
+
1193
+ if (compDef) {
1194
+ const analysis = normalizeComponentDef(compDef);
1195
+ if (analysis.type === 'cva') {
1196
+ const instance = {
1197
+ props: Object.assign({}, node.props),
1198
+ children: collectTextContent(node),
1199
+ };
1200
+ const cva = createCVAStoryInstance(analysis, instance, theme, storyContext);
1201
+ return maybeWrapMxAuto(cva, classes, context);
1202
+ }
1203
+ if (analysis.type === 'state') {
1204
+ const instance = {
1205
+ props: Object.assign({}, node.props),
1206
+ children: collectTextContent(node),
1207
+ };
1208
+ const state = createStateStoryInstance(analysis, instance, theme, storyContext);
1209
+ return maybeWrapMxAuto(state, classes, context);
1210
+ }
1211
+ if (!node.children || node.children.length === 0) {
1212
+ // For simple components used inline in jsxTree (e.g. <Box className="h-4 w-48" />),
1213
+ // render as a styled frame using the instance's className merged with base visual classes
1214
+ if (analysis.type === 'simple' && node.props.className) {
1215
+ const instanceClasses = node.classes.slice();
1216
+ const compClasses = analysis.classes || [];
1217
+ const baseVisualClasses = compClasses.filter((c: string) =>
1218
+ c.startsWith('bg-') || (c.startsWith('rounded') && !c.includes('/'))
1219
+ );
1220
+ const allClasses = mergeClasses(baseVisualClasses, instanceClasses);
1221
+
1222
+ const simpleFrame = figma.createFrame();
1223
+ simpleFrame.name = node.tagName;
1224
+ simpleFrame.layoutMode = 'VERTICAL';
1225
+ simpleFrame.primaryAxisSizingMode = 'FIXED';
1226
+ simpleFrame.counterAxisSizingMode = 'FIXED';
1227
+ simpleFrame.fills = [];
1228
+ applyClipBehavior(simpleFrame, allClasses);
1229
+ if (allClasses.length > 0) {
1230
+ applyTailwindStylesToFrame(simpleFrame, allClasses, colorGroup, radiusGroup, theme);
1231
+ }
1232
+ return maybeWrapMxAuto(simpleFrame, allClasses, context);
1233
+ }
1234
+
1235
+ // If the component has a jsxTree (from its own implementation), render it directly
1236
+ // (e.g. <HeroSection /> inline with no children expands to the full component structure)
1237
+ if (analysis.type === 'simple' && analysis.jsxTree) {
1238
+ const result = renderComponentWithJsxTree(
1239
+ analysis.jsxTree,
1240
+ [],
1241
+ colorGroup,
1242
+ radiusGroup,
1243
+ theme,
1244
+ depth,
1245
+ context
1246
+ );
1247
+ if (result) {
1248
+ return maybeWrapMxAuto(result, classes, context);
1249
+ }
1250
+ }
1251
+
1252
+ const frame = figma.createFrame();
1253
+ frame.name = node.tagName;
1254
+ frame.layoutMode = 'VERTICAL';
1255
+ frame.primaryAxisSizingMode = 'AUTO';
1256
+ frame.counterAxisSizingMode = 'AUTO';
1257
+ frame.fills = [];
1258
+ applyClipBehavior(frame, classes);
1259
+
1260
+ const comp = createCompoundComponent(frame, analysis, theme);
1261
+ if (comp) return maybeWrapMxAuto(comp, classes, context);
1262
+ }
1263
+ }
1264
+
1265
+ // If this component has children in the jsxTree, render them
1266
+ // (wrapper components should show their content)
1267
+ if (node.children && node.children.length > 0) {
1268
+ // Get compound classes - try own definition first, then parent's subComponents
1269
+ let compoundClasses: string[] = [];
1270
+ let currentCompoundDef: any = null;
1271
+ if (compDef) {
1272
+ const normalizedDef = normalizeComponentDef(compDef);
1273
+ // Get subComponent classes if this is a compound component
1274
+ compoundClasses = getCompoundClasses(normalizedDef, node.tagName);
1275
+ if (normalizedDef.type === 'compound') {
1276
+ currentCompoundDef = normalizedDef;
1277
+ }
1278
+ // Check if this component has a jsxTree structure (for components with decorative elements)
1279
+ if (normalizedDef.type === 'simple' && normalizedDef.jsxTree) {
1280
+ const hasInstanceChildren = (node.children?.length ?? 0) > 0;
1281
+ const canRenderTree = !hasInstanceChildren || jsxTreeHasChildrenSlot(normalizedDef.jsxTree);
1282
+ if (canRenderTree) {
1283
+ // Render the component's internal structure from jsxTree
1284
+ const result = renderComponentWithJsxTree(
1285
+ normalizedDef.jsxTree,
1286
+ node.children,
1287
+ colorGroup,
1288
+ radiusGroup,
1289
+ theme,
1290
+ depth,
1291
+ context
1292
+ );
1293
+ if (result) {
1294
+ return maybeWrapMxAuto(result, classes, context);
1295
+ }
1296
+ }
1297
+ }
1298
+ // Use only container-level classes from the definition
1299
+ const defClasses = normalizedDef.baseClasses || normalizedDef.classes || [];
1300
+ if (defClasses.length > 0) {
1301
+ // Filter to only container classes (no decorative element classes)
1302
+ const containerClasses = defClasses.filter((c: string) =>
1303
+ c === 'relative' || c === 'isolate' ||
1304
+ c.startsWith('py-') || c.startsWith('px-') || c.startsWith('p-') ||
1305
+ c.startsWith('rounded')
1306
+ );
1307
+ compoundClasses = mergeClasses(containerClasses, compoundClasses);
1308
+ }
1309
+ }
1310
+ // If no classes found and we have a parent compound def, look up as subComponent
1311
+ if (compoundClasses.length === 0 && context.parentCompoundDef) {
1312
+ compoundClasses = getCompoundClasses(context.parentCompoundDef, node.tagName);
1313
+ }
1314
+
1315
+ const mergedClasses = compoundClasses.length > 0
1316
+ ? mergeClasses(compoundClasses, classes)
1317
+ : classes;
1318
+ const activeMergedClasses = mergedClasses === classes
1319
+ ? classes
1320
+ : resolveClassesForBreakpoint(mergedClasses, breakpointIndex);
1321
+
1322
+ const wrapperFrame = figma.createFrame();
1323
+ wrapperFrame.name = node.tagName;
1324
+ wrapperFrame.fills = [];
1325
+
1326
+ // Apply layout using LayoutParser IR
1327
+ const wrapperIR = activeMergedClasses === classes ? layoutComputed.layoutIR : LayoutParser.parseToIR(activeMergedClasses);
1328
+ LayoutParser.applyToFrame(wrapperFrame, wrapperIR);
1329
+
1330
+ // Apply styles to the frame
1331
+ if (activeMergedClasses.length > 0) {
1332
+ applyTailwindStylesToFrame(wrapperFrame, activeMergedClasses, colorGroup, radiusGroup, theme);
1333
+ }
1334
+ applyClipBehavior(wrapperFrame, activeMergedClasses);
1335
+ // Calculate content width for children (accounting for padding)
1336
+ // This is needed for justify-between to work in Figma SPACE_BETWEEN mode
1337
+ const wrapperPadding = (wrapperFrame.paddingLeft || 0) + (wrapperFrame.paddingRight || 0);
1338
+ const hasFixedWidth = wrapperFrame.layoutMode === 'HORIZONTAL'
1339
+ ? wrapperFrame.primaryAxisSizingMode === 'FIXED'
1340
+ : wrapperFrame.counterAxisSizingMode === 'FIXED';
1341
+ const wrapperContentWidth = hasFixedWidth && wrapperFrame.width > 0
1342
+ ? wrapperFrame.width - wrapperPadding
1343
+ : undefined;
1344
+ const childCtx: RenderContext = Object.assign({}, nextContext, {
1345
+ parentLayout: wrapperFrame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
1346
+ textColor: nextContext.textColor,
1347
+ textColorToken: nextContext.textColorToken,
1348
+ maxWidth: wrapperContentWidth || context.maxWidth,
1349
+ parentCompoundDef: currentCompoundDef || nextContext.parentCompoundDef,
1350
+ });
1351
+ const skipWrapperFullWidth = shouldSkipFullWidthForClasses(activeMergedClasses);
1352
+ const accordionItemOrdinal = { value: 0 };
1353
+
1354
+ for (const child of node.children) {
1355
+ const perChildCtx = createPerChildRenderContext(childCtx, node, child, accordionItemOrdinal);
1356
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
1357
+ if (childNode) {
1358
+ wrapperFrame.appendChild(childNode);
1359
+
1360
+ // Apply child-specific layout properties
1361
+ if (isElementLikeNode(child)) {
1362
+ LayoutParser.applyChildProperties(childNode, child.classes, wrapperFrame);
1363
+ }
1364
+ stabilizeHorizontalStretchChild(childNode, wrapperFrame);
1365
+ constrainSingleHorizontalTextChild(childNode);
1366
+
1367
+ applyAbsoluteIfPossible(childNode, wrapperFrame);
1368
+ const fullWidthOptions = (skipWrapperFullWidth || childCtx.maxWidth != null)
1369
+ ? { skipFullWidth: skipWrapperFullWidth, widthOverride: childCtx.maxWidth }
1370
+ : undefined;
1371
+ applyFullWidthIfPossible(childNode, wrapperFrame, fullWidthOptions);
1372
+ }
1373
+ }
1374
+ applyDeferredBottomPositioning(wrapperFrame);
1375
+ applyDeferredCenterYPositioning(wrapperFrame);
1376
+ // Apply grid columns after all children are added
1377
+ const wrapperGridWidth = resolveGridWidthOverride(wrapperFrame, childCtx.maxWidth);
1378
+ applyGridColumnsWithReflow(wrapperFrame, wrapperGridWidth);
1379
+
1380
+ return maybeWrapMxAuto(wrapperFrame, activeMergedClasses, context);
1381
+ }
1382
+
1383
+ // Components like SelectValue, Input etc. that expose a placeholder prop but have no
1384
+ // renderable children — render the placeholder as muted text so triggers show content.
1385
+ if ((!node.children || node.children.length === 0) && node.props && node.props.placeholder) {
1386
+ const phOpts: any = { fontSize: context.textFontSize || 14, theme, opacity: 0.4 };
1387
+ if (context.textFontStyle != null) phOpts.fontStyle = context.textFontStyle;
1388
+ else if (context.textBold != null) phOpts.bold = context.textBold;
1389
+ if (context.textColor) phOpts.fill = context.textColor;
1390
+ return createTextNode(node.props.placeholder, phOpts);
1391
+ }
1392
+
1393
+ // Unknown component without children - render styled frame
1394
+ const compFrame = figma.createFrame();
1395
+ compFrame.name = node.tagName;
1396
+ compFrame.layoutMode = 'VERTICAL';
1397
+ compFrame.primaryAxisSizingMode = 'AUTO';
1398
+ compFrame.counterAxisSizingMode = 'AUTO';
1399
+ compFrame.fills = [];
1400
+ applyClipBehavior(compFrame, classes);
1401
+ if (classes.length > 0) {
1402
+ applyTailwindStylesToFrame(compFrame, classes, colorGroup, radiusGroup, theme);
1403
+ }
1404
+ const compChildCtx: RenderContext = Object.assign({}, nextContext, {
1405
+ textColor: nextContext.textColor,
1406
+ textColorToken: nextContext.textColorToken,
1407
+ maxWidth: compFrame.layoutMode === 'HORIZONTAL' ? undefined : context.maxWidth,
1408
+ parentLayout: compFrame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
1409
+ });
1410
+ const skipCompFullWidth = shouldSkipFullWidthForClasses(classes);
1411
+ const accordionItemOrdinal = { value: 0 };
1412
+ for (const child of node.children || []) {
1413
+ const perChildCtx = createPerChildRenderContext(compChildCtx, node, child, accordionItemOrdinal);
1414
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
1415
+ if (childNode) {
1416
+ compFrame.appendChild(childNode);
1417
+ // Apply child layout properties based on parent context
1418
+ if (isElementLikeNode(child)) {
1419
+ LayoutParser.applyChildProperties(childNode, child.classes, compFrame);
1420
+ }
1421
+ stabilizeHorizontalStretchChild(childNode, compFrame);
1422
+ constrainSingleHorizontalTextChild(childNode);
1423
+ applyAbsoluteIfPossible(childNode, compFrame);
1424
+ const fullWidthOptions = (skipCompFullWidth || compChildCtx.maxWidth != null)
1425
+ ? { skipFullWidth: skipCompFullWidth, widthOverride: compChildCtx.maxWidth }
1426
+ : undefined;
1427
+ applyFullWidthIfPossible(childNode, compFrame, fullWidthOptions);
1428
+ }
1429
+ }
1430
+ applyDeferredBottomPositioning(compFrame);
1431
+ applyDeferredCenterYPositioning(compFrame);
1432
+ // Apply grid columns after all children are added
1433
+ applyGridColumnsWithReflow(compFrame);
1434
+ return maybeWrapMxAuto(compFrame, classes, context);
1435
+ }
1436
+
1437
+ // HTML elements - create frame or text
1438
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'label', 'li', 'small'].includes(tagLower)) {
1439
+ const hasElementChildren = (node.children || []).some(child => isElementLikeNode(child));
1440
+ const hasLayoutClass = layoutComputed.hasLayoutClass;
1441
+ const hasFlexChildClass = layoutComputed.hasFlexChildClass;
1442
+ const hasExplicitSize = layoutComputed.hasExplicitSize;
1443
+ const inlineOnlyChildren = hasElementChildren && (node.children || []).every(isInlineTextNode);
1444
+ // Any solid background on a text-category element requires a frame to be visible
1445
+ const hasBgClass = classes.some(cls => {
1446
+ if (!cls.startsWith('bg-')) return false;
1447
+ const rest = cls.slice(3);
1448
+ return !rest.startsWith('gradient') && !rest.startsWith('linear') && !rest.startsWith('radial') && rest !== 'transparent';
1449
+ });
1450
+
1451
+ if (inlineOnlyChildren && !hasLayoutClass && !hasFlexChildClass && !hasBgClass) {
1452
+ const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed);
1453
+ if (inlineText) return maybeWrapMxAuto(inlineText, classes, context);
1454
+ }
1455
+
1456
+ // Create a frame container for elements with layout, explicit sizing, flex child classes, or a background fill
1457
+ if (hasElementChildren || hasLayoutClass || hasFlexChildClass || hasExplicitSize || hasBgClass) {
1458
+ const frame = figma.createFrame();
1459
+ frame.name = node.tagName;
1460
+ frame.fills = [];
1461
+
1462
+ // Apply layout using LayoutParser IR
1463
+ LayoutParser.applyToFrame(frame, layoutComputed.layoutIR);
1464
+
1465
+ // Apply visual styles
1466
+ if (classes.length > 0) {
1467
+ applyTailwindStylesToFrame(frame, classes, colorGroup, radiusGroup, theme);
1468
+ }
1469
+ applyClipBehavior(frame, classes);
1470
+ if (
1471
+ context.parentLayout === 'VERTICAL'
1472
+ && shouldStretchToParentWidth(tagLower, classes)
1473
+ ) {
1474
+ (frame as any).layoutAlign = 'STRETCH';
1475
+ } else if (context.parentLayout === 'VERTICAL') {
1476
+ // Inline display elements (inline-flex, inline-block, etc.) must not stretch to parent width.
1477
+ // Figma's default layoutAlign 'INHERIT' = STRETCH in a VERTICAL parent — override it.
1478
+ const hasInlineDisplay = classes.some(c => {
1479
+ const base = getBaseClass(c);
1480
+ return base === 'inline' || base === 'inline-flex' || base === 'inline-block' || base === 'inline-grid';
1481
+ });
1482
+ if (hasInlineDisplay) {
1483
+ try { (frame as any).layoutSizingHorizontal = 'HUG'; } catch (_e) { /* ignore if frame has no auto-layout */ }
1484
+ }
1485
+ }
1486
+ if (
1487
+ frame.layoutMode === 'HORIZONTAL'
1488
+ && context.maxWidth
1489
+ && (frame as any).layoutAlign === 'STRETCH'
1490
+ && frame.primaryAxisSizingMode !== 'FIXED'
1491
+ ) {
1492
+ frame.resize(context.maxWidth, frame.height);
1493
+ frame.primaryAxisSizingMode = 'FIXED';
1494
+ }
1495
+ // Handle text content or children
1496
+ if (hasElementChildren) {
1497
+ const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
1498
+ ? frame.primaryAxisSizingMode === 'FIXED'
1499
+ : frame.counterAxisSizingMode === 'FIXED';
1500
+ const resolvedMaxWidth = frameHasFixedWidth
1501
+ ? (nextContext.maxWidth ? Math.min(nextContext.maxWidth, frame.width) : frame.width)
1502
+ : nextContext.maxWidth;
1503
+ const skipElementFullWidth = shouldSkipFullWidthForClasses(classes);
1504
+ const contentWidth = resolvedMaxWidth
1505
+ ? Math.max(0, resolvedMaxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
1506
+ : undefined;
1507
+ const gridWidthOverride = resolveGridWidthOverride(frame, contentWidth);
1508
+ const inheritedChildMaxWidth = frame.layoutMode === 'HORIZONTAL' ? undefined : contentWidth;
1509
+ const childContext: RenderContext = {
1510
+ ...nextContext,
1511
+ textAlign: nextContext.textAlign,
1512
+ maxWidth: inheritedChildMaxWidth,
1513
+ parentLayout: frame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
1514
+ textColor: nextContext.textColor,
1515
+ textColorToken: nextContext.textColorToken,
1516
+ };
1517
+ const accordionItemOrdinal = { value: 0 };
1518
+ for (const child of node.children || []) {
1519
+ const perChildCtx = createPerChildRenderContext(childContext, node, child, accordionItemOrdinal);
1520
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
1521
+ if (childNode) {
1522
+ frame.appendChild(childNode);
1523
+ // Apply child layout properties based on parent context
1524
+ if (isElementLikeNode(child)) {
1525
+ LayoutParser.applyChildProperties(childNode, child.classes, frame);
1526
+ }
1527
+ stabilizeHorizontalStretchChild(childNode, frame);
1528
+ constrainSingleHorizontalTextChild(childNode);
1529
+ applyAbsoluteIfPossible(childNode, frame);
1530
+ const fullWidthOptions = (skipElementFullWidth || childContext.maxWidth != null)
1531
+ ? { skipFullWidth: skipElementFullWidth, widthOverride: childContext.maxWidth }
1532
+ : undefined;
1533
+ applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
1534
+ }
1535
+ }
1536
+ applyDeferredBottomPositioning(frame);
1537
+ applyDeferredCenterYPositioning(frame);
1538
+ constrainSingleHorizontalTextChild(frame);
1539
+ // Apply grid columns after all children are added
1540
+ applyGridColumnsWithReflow(frame, gridWidthOverride);
1541
+ } else {
1542
+ // Text-only element with sizing - create text node and append
1543
+ const textContent = collectTextContent(node);
1544
+ if (textContent.trim()) {
1545
+ const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
1546
+ const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
1547
+ const textOpts: any = {
1548
+ fontSize: typo.fontSize,
1549
+ bold: typo.bold,
1550
+ lineHeight: typo.lineHeight,
1551
+ theme,
1552
+ fontRole: isHeadingTag ? 'heading' : 'sans',
1553
+ };
1554
+ if (textStyle.text) {
1555
+ textOpts.fill = textStyle.text;
1556
+ } else if (nextContext.textColor) {
1557
+ textOpts.fill = nextContext.textColor;
1558
+ }
1559
+ const resolvedBold = getResolvedBoldFromClasses(classes, textOpts.bold);
1560
+ if (resolvedBold != null) textOpts.bold = resolvedBold;
1561
+ const resolvedFontStyle = getFontStyleFromClasses(classes, nextContext.textFontStyle);
1562
+ if (resolvedFontStyle != null) textOpts.fontStyle = resolvedFontStyle;
1563
+ const resolvedSize = getResolvedTextSizeFromClasses(classes, textOpts.fontSize);
1564
+ if (resolvedSize != null) textOpts.fontSize = resolvedSize;
1565
+ // Apply text-center alignment if present
1566
+ if (classes.includes('text-center')) {
1567
+ textOpts.textAlignHorizontal = 'CENTER';
1568
+ } else if (classes.includes('text-right')) {
1569
+ textOpts.textAlignHorizontal = 'RIGHT';
1570
+ }
1571
+ const textNode = createTextNode(textContent, textOpts);
1572
+ // In a HORIZONTAL frame with CENTER alignment, don't force the text node
1573
+ // to a fixed width — let it stay HUG-sized so the frame's CENTER alignment
1574
+ // can position it (e.g. a numbered circle with justify-center).
1575
+ const isHorizontalCentered = frame.layoutMode === 'HORIZONTAL'
1576
+ && (frame as any).primaryAxisAlignItems === 'CENTER';
1577
+ const textContentWidth = (!isHorizontalCentered && nextContext.maxWidth)
1578
+ ? Math.max(0, nextContext.maxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
1579
+ : undefined;
1580
+ if (textContentWidth && textContentWidth > 0) {
1581
+ try {
1582
+ textNode.textAutoResize = 'HEIGHT';
1583
+ textNode.resize(textContentWidth, textNode.height);
1584
+ } catch (_err) {
1585
+ // ignore resize errors
1586
+ }
1587
+ }
1588
+ frame.appendChild(textNode);
1589
+ }
1590
+ }
1591
+ return maybeWrapMxAuto(frame, classes, context);
1592
+ }
1593
+
1594
+ // Text-like elements - gather all text content
1595
+ const textContent = collectTextContent(node);
1596
+ if (!textContent.trim()) return null;
1597
+
1598
+ const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
1599
+ const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
1600
+ const textOpts: any = {
1601
+ fontSize: typo.fontSize,
1602
+ bold: typo.bold,
1603
+ lineHeight: typo.lineHeight,
1604
+ theme,
1605
+ fontRole: isHeadingTag ? 'heading' : 'sans',
1606
+ };
1607
+
1608
+ // Extract text color from classes
1609
+ if (textStyle.text) {
1610
+ textOpts.fill = textStyle.text;
1611
+ } else if (nextContext.textColor) {
1612
+ textOpts.fill = nextContext.textColor;
1613
+ }
1614
+
1615
+ if (nextContext.textAlign) {
1616
+ textOpts.textAlignHorizontal = nextContext.textAlign;
1617
+ }
1618
+
1619
+ // Check for bold/font-weight class
1620
+ const resolvedBold = getResolvedBoldFromClasses(classes, textOpts.bold);
1621
+ if (resolvedBold != null) textOpts.bold = resolvedBold;
1622
+ const resolvedFontStyle = getFontStyleFromClasses(classes, nextContext.textFontStyle);
1623
+ if (resolvedFontStyle != null) textOpts.fontStyle = resolvedFontStyle;
1624
+
1625
+ // Extract font size from classes
1626
+ const resolvedSize = getResolvedTextSizeFromClasses(classes, textOpts.fontSize);
1627
+ if (resolvedSize != null) textOpts.fontSize = resolvedSize;
1628
+
1629
+ const lineHeight = getLineHeightFromClasses(classes, textOpts.fontSize);
1630
+ if (lineHeight) {
1631
+ textOpts.lineHeight = lineHeight;
1632
+ }
1633
+
1634
+ const textNode = createTextNode(textContent, textOpts);
1635
+ const isBlockText = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'].includes(tagLower);
1636
+ const shouldConstrain = nextContext.maxWidth
1637
+ && nextContext.parentLayout !== 'HORIZONTAL'
1638
+ && (isBlockText || classes.includes('block') || classes.includes('w-full'));
1639
+ const hasTruncateClass = classes.includes('truncate');
1640
+ let lineClampN: number | null = null;
1641
+ for (const cls of classes) {
1642
+ const m = cls.match(/^line-clamp-(\d+)$/);
1643
+ if (m) { lineClampN = parseInt(m[1], 10); break; }
1644
+ }
1645
+ if ((hasTruncateClass || lineClampN != null) && nextContext.maxWidth) {
1646
+ try {
1647
+ const maxLines = lineClampN ?? 1;
1648
+ const fs = textOpts.fontSize || 14;
1649
+ const lh = textOpts.lineHeight || Math.ceil(fs * 1.5);
1650
+ (textNode as any).textTruncation = 'ENDING';
1651
+ (textNode as any).maxLines = maxLines;
1652
+ textNode.resize(nextContext.maxWidth, Math.max(lh, lh * maxLines));
1653
+ } catch (_err) { /* ignore */ }
1654
+ } else if (shouldConstrain) {
1655
+ try {
1656
+ textNode.textAutoResize = 'HEIGHT';
1657
+ textNode.resize(nextContext.maxWidth, textNode.height);
1658
+ } catch (_err) {
1659
+ // ignore resize errors
1660
+ }
1661
+ }
1662
+ return maybeWrapMxAuto(textNode, classes, context);
1663
+ }
1664
+
1665
+ // <hr> - horizontal rule / separator
1666
+ if (tagLower === 'hr') {
1667
+ const hr = figma.createFrame();
1668
+ hr.name = 'hr';
1669
+ hr.layoutMode = 'HORIZONTAL';
1670
+ hr.resize(200, 1);
1671
+ hr.primaryAxisSizingMode = 'FIXED';
1672
+ hr.counterAxisSizingMode = 'FIXED';
1673
+ const borderColor = colorGroup.border ? parseColor(colorGroup.border) : { r: 0.85, g: 0.85, b: 0.85 };
1674
+ hr.fills = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
1675
+ if (classes.includes('w-full')) {
1676
+ markFullWidthNode(hr);
1677
+ }
1678
+ return maybeWrapMxAuto(hr, classes, context);
1679
+ }
1680
+
1681
+ // Container elements (div, nav, section, header, footer, ul, ol, etc.)
1682
+
1683
+ // Check for clip-path in style prop - create vector shape instead of frame
1684
+ const clipPathPolygon = parseClipPathFromStyle(node.props.style);
1685
+ if (clipPathPolygon && node.children.length === 0) {
1686
+ const decorativeNode = buildDecorativeClipPathNode({
1687
+ clipPathPolygon,
1688
+ classes,
1689
+ contextMaxWidth: context.maxWidth,
1690
+ colorGroup,
1691
+ radiusGroup,
1692
+ theme,
1693
+ });
1694
+ if (decorativeNode) return maybeWrapMxAuto(decorativeNode, classes, context);
1695
+ }
1696
+
1697
+ const frame = figma.createFrame();
1698
+ frame.name = node.tagName;
1699
+ frame.fills = [];
1700
+
1701
+ // Apply layout using LayoutParser IR (handles flex, grid, alignment, spacing, dimensions)
1702
+ LayoutParser.applyToFrame(frame, layoutComputed.layoutIR);
1703
+
1704
+ // Apply visual styles (colors, borders, radius) from Tailwind classes
1705
+ // Resolve sm: breakpoint overrides when container is >= 640px (matches CSS cascade)
1706
+ const effectiveClasses = (context.maxWidth != null && context.maxWidth >= 640)
1707
+ ? getClassesForBreakpoint(classes, 'sm')
1708
+ : classes;
1709
+ if (effectiveClasses.length > 0) {
1710
+ applyTailwindStylesToFrame(frame, effectiveClasses, colorGroup, radiusGroup, theme);
1711
+ }
1712
+ applyClipBehavior(frame, effectiveClasses);
1713
+ // Hero-like containers (`relative isolate`) with decorative clip-path blobs should
1714
+ // clip to their own bounds so gradients never spill outside the component frame.
1715
+ if (effectiveClasses.includes('relative') && effectiveClasses.includes('isolate') && nodeHasClipPathDescendant(node)) {
1716
+ frame.clipsContent = true;
1717
+ }
1718
+
1719
+ // NOTE: text-left/center/right only affects TEXT content in CSS, not block layout.
1720
+ // Do NOT apply alignFromClasses to frame.counterAxisAlignItems here.
1721
+ // The textAlign context is propagated to child text nodes instead.
1722
+
1723
+ const shouldApplyContextWidth = depth === 0 && context.maxWidth;
1724
+ if (shouldApplyContextWidth || (nextContext.maxWidth && nextContext.maxWidth !== context.maxWidth)) {
1725
+ const targetWidth = shouldApplyContextWidth ? context.maxWidth : nextContext.maxWidth;
1726
+ if (targetWidth) {
1727
+ if (frame.layoutMode === 'HORIZONTAL') {
1728
+ frame.resize(targetWidth, frame.height);
1729
+ frame.primaryAxisSizingMode = 'FIXED';
1730
+ } else {
1731
+ frame.resize(targetWidth, frame.height);
1732
+ frame.counterAxisSizingMode = 'FIXED';
1733
+ }
1734
+ }
1735
+ // Clip the story root frame so absolute-positioned children (e.g. gradient blobs)
1736
+ // don't extend visually outside the component's bounds in Figma.
1737
+ if (shouldApplyContextWidth) {
1738
+ frame.clipsContent = true;
1739
+ }
1740
+ }
1741
+
1742
+ // Block-level elements in VERTICAL parents should stretch to fill width
1743
+ // Note: We set layoutAlign even before the frame is appended - Figma will respect it when appended
1744
+ if (
1745
+ context.parentLayout === 'VERTICAL'
1746
+ && shouldStretchToParentWidth(tagLower, classes)
1747
+ ) {
1748
+ (frame as any).layoutAlign = 'STRETCH';
1749
+ } else if (context.parentLayout === 'VERTICAL') {
1750
+ const hasInlineDisplay = classes.some(c => {
1751
+ const base = getBaseClass(c);
1752
+ return base === 'inline' || base === 'inline-flex' || base === 'inline-block' || base === 'inline-grid';
1753
+ });
1754
+ if (hasInlineDisplay) {
1755
+ try { (frame as any).layoutSizingHorizontal = 'HUG'; } catch (_e) { /* ignore if frame has no auto-layout */ }
1756
+ }
1757
+ }
1758
+
1759
+ if (
1760
+ frame.layoutMode === 'HORIZONTAL'
1761
+ && context.maxWidth
1762
+ && (frame as any).layoutAlign === 'STRETCH'
1763
+ && frame.primaryAxisSizingMode !== 'FIXED'
1764
+ ) {
1765
+ frame.resize(context.maxWidth, frame.height);
1766
+ frame.primaryAxisSizingMode = 'FIXED';
1767
+ }
1768
+
1769
+ const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
1770
+ ? frame.primaryAxisSizingMode === 'FIXED'
1771
+ : frame.counterAxisSizingMode === 'FIXED';
1772
+ const resolvedMaxWidth = frameHasFixedWidth
1773
+ ? (nextContext.maxWidth ? Math.min(nextContext.maxWidth, frame.width) : frame.width)
1774
+ : nextContext.maxWidth;
1775
+ const gridCols = extractGridColumns(classes, resolvedMaxWidth || context.maxWidth);
1776
+ const gridWidth = resolvedMaxWidth || context.maxWidth || (gridCols ? defaultGridWidth(gridCols) : undefined);
1777
+ const skipChildFullWidth = !!gridCols || classes.includes('grid') || classes.includes('inline-grid');
1778
+ if (gridCols && gridWidth && 'resize' in frame) {
1779
+ try {
1780
+ if (frame.layoutMode !== 'HORIZONTAL') frame.layoutMode = 'HORIZONTAL';
1781
+ if ((frame as any).layoutWrap !== undefined) {
1782
+ (frame as any).layoutWrap = 'WRAP';
1783
+ }
1784
+ frame.resize(gridWidth, frame.height);
1785
+ frame.primaryAxisSizingMode = 'FIXED';
1786
+ frame.counterAxisSizingMode = 'AUTO';
1787
+ } catch (_err) {
1788
+ // ignore resize errors
1789
+ }
1790
+ }
1791
+
1792
+ // Render children
1793
+ const contentWidth = resolvedMaxWidth
1794
+ ? Math.max(0, resolvedMaxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
1795
+ : undefined;
1796
+ const inheritedChildMaxWidth = frame.layoutMode === 'HORIZONTAL' ? undefined : contentWidth;
1797
+ const childContext: RenderContext = {
1798
+ ...nextContext,
1799
+ textAlign: nextContext.textAlign,
1800
+ maxWidth: inheritedChildMaxWidth,
1801
+ parentLayout: frame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
1802
+ textColor: nextContext.textColor,
1803
+ textColorToken: nextContext.textColorToken,
1804
+ };
1805
+ const renderChildren = getRenderableChildren(node, classes);
1806
+ const accordionItemOrdinal = { value: 0 };
1807
+
1808
+ // For VERTICAL layouts, extract mt-* (margin-top) from children and use as itemSpacing
1809
+ // This converts CSS margins to Figma auto-layout gaps
1810
+ if (frame.layoutMode === 'VERTICAL' && renderChildren.length > 1 && !isSemanticTableContainer(node)) {
1811
+ let maxMarginTop = 0;
1812
+ for (const child of renderChildren) {
1813
+ if (!isElementLikeNode(child)) continue;
1814
+ const childClasses = child.classes;
1815
+ for (const cls of childClasses) {
1816
+ const mtMatch = cls.match(/^mt-(\d+)$/);
1817
+ if (mtMatch) {
1818
+ const mtValue = parseInt(mtMatch[1], 10) * 4; // Tailwind spacing scale
1819
+ if (mtValue > maxMarginTop) maxMarginTop = mtValue;
1820
+ }
1821
+ // Also handle mt-[Xpx] arbitrary values
1822
+ const mtArbitraryMatch = cls.match(/^mt-\[(\d+)px\]$/);
1823
+ if (mtArbitraryMatch) {
1824
+ const mtValue = parseInt(mtArbitraryMatch[1], 10);
1825
+ if (mtValue > maxMarginTop) maxMarginTop = mtValue;
1826
+ }
1827
+ }
1828
+ }
1829
+ if (maxMarginTop > 0 && (!frame.itemSpacing || frame.itemSpacing < maxMarginTop)) {
1830
+ frame.itemSpacing = maxMarginTop;
1831
+ }
1832
+ }
1833
+
1834
+ // Inline-only div: all children are text/span/a nodes (no block layout).
1835
+ // Collapse into a single text node — like a CSS inline container (e.g. pill labels).
1836
+ const hasOnlyInlineChildren = !layoutComputed.hasLayoutClass
1837
+ && renderChildren.length > 0
1838
+ && renderChildren.every(isInlineTextNode);
1839
+ if (hasOnlyInlineChildren) {
1840
+ const inlineText = createInlineTextNode(node as any, textStyle, nextContext, colorGroup, theme, layoutComputed);
1841
+ if (inlineText) {
1842
+ frame.appendChild(inlineText);
1843
+ return maybeWrapMxAuto(frame, classes, context);
1844
+ }
1845
+ }
1846
+
1847
+ for (const child of renderChildren) {
1848
+ const perChildCtx = createPerChildRenderContext(childContext, node, child, accordionItemOrdinal);
1849
+ const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
1850
+ if (childNode) {
1851
+ frame.appendChild(childNode);
1852
+
1853
+ // Apply child-specific layout properties (flex-1, self-*, etc.)
1854
+ if (isElementLikeNode(child)) {
1855
+ LayoutParser.applyChildProperties(childNode, child.classes, frame);
1856
+ }
1857
+ stabilizeHorizontalStretchChild(childNode, frame);
1858
+ constrainSingleHorizontalTextChild(childNode);
1859
+
1860
+ applyAbsoluteIfPossible(childNode, frame);
1861
+ const fullWidthOptions = (skipChildFullWidth || contentWidth != null)
1862
+ ? { skipFullWidth: skipChildFullWidth, widthOverride: contentWidth }
1863
+ : undefined;
1864
+ applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
1865
+ if (
1866
+ node.kind === 'element'
1867
+ && node.tagLower === 'table'
1868
+ && child.kind === 'element'
1869
+ && child.tagLower === 'tfoot'
1870
+ ) {
1871
+ // CSS table border-collapse merges adjacent borders to 1px.
1872
+ // In Figma stacked frames accumulate strokes, so clear tfoot top stroke.
1873
+ clearTopBorder(childNode);
1874
+ }
1875
+ if (
1876
+ node.kind === 'element'
1877
+ && node.tagLower === 'tfoot'
1878
+ && child.kind === 'element'
1879
+ && child.tagLower === 'tr'
1880
+ ) {
1881
+ // Matches [&>tr]:last:border-b-0 on TableFooter.
1882
+ clearBottomBorder(childNode);
1883
+ }
1884
+ if (
1885
+ node.kind === 'element'
1886
+ && node.tagLower === 'tr'
1887
+ && (child.kind === 'element' || child.kind === 'component')
1888
+ && (child.tagLower === 'td' || child.tagLower === 'th')
1889
+ && !hasTableCellSizeOverride(child.classes)
1890
+ ) {
1891
+ const colSpan = getNumericColSpan((child.props as any)?.colSpan);
1892
+ try {
1893
+ (childNode as any).layoutGrow = colSpan;
1894
+ } catch (_err) {
1895
+ // ignore if node cannot grow in this parent
1896
+ }
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ // For input/textarea elements with no children, render defaultValue or placeholder as text
1902
+ if ((tagLower === 'input' || tagLower === 'textarea') && (!node.children || node.children.length === 0)) {
1903
+ const inputText = node.props.defaultValue || node.props.value;
1904
+ const inputPlaceholder = node.props.placeholder;
1905
+ if (inputText || inputPlaceholder) {
1906
+ const inputTextOpts: any = { fontSize: context.textFontSize || 14, theme };
1907
+ if (inputText) {
1908
+ if (context.textColor) inputTextOpts.fill = context.textColor;
1909
+ } else {
1910
+ inputTextOpts.opacity = 0.4; // placeholder muted
1911
+ }
1912
+ const inputTextNode = createTextNode(inputText || inputPlaceholder, inputTextOpts);
1913
+ frame.appendChild(inputTextNode);
1914
+ }
1915
+ }
1916
+
1917
+ applyDeferredBottomPositioning(frame);
1918
+ applyDeferredCenterYPositioning(frame);
1919
+ applyGridColumnsWithReflow(frame, gridWidth || frame.width, gridCols || undefined);
1920
+
1921
+ // Re-apply clipping after children are added, in case Figma resets it during layout ops.
1922
+ if (shouldClipContent(classes)) {
1923
+ frame.clipsContent = true;
1924
+ }
1925
+ // Re-apply directional border widths after layout/resize passes; Figma may normalize
1926
+ // stroke sides during downstream sizing operations.
1927
+ if (Array.isArray(frame.strokes) && frame.strokes.length > 0) {
1928
+ applyBorderWidthUtilities(frame, classes);
1929
+ }
1930
+
1931
+ return maybeWrapMxAuto(frame, classes, context);
1932
+ }
1933
+
1934
+ /**
1935
+ * Recursively collect text content from a resolved node tree
1936
+ */
1937
+ function collectTextContent(node: NodeIR): string {
1938
+ const parts: string[] = [];
1939
+ const walk = (current: NodeIR): void => {
1940
+ if (current.kind === 'text') {
1941
+ parts.push(current.text);
1942
+ return;
1943
+ }
1944
+ if (current.kind === 'fragment') {
1945
+ for (const child of current.children) {
1946
+ walk(child);
1947
+ }
1948
+ return;
1949
+ }
1950
+ if (current.kind === 'divider') {
1951
+ return;
1952
+ }
1953
+ if (current.kind === 'ring') {
1954
+ walk(current.child);
1955
+ return;
1956
+ }
1957
+ if (current.classes.includes('hidden') || current.classes.includes('sr-only')) {
1958
+ return;
1959
+ }
1960
+ for (const child of current.children || []) {
1961
+ walk(child);
1962
+ }
1963
+ };
1964
+ walk(node);
1965
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
1966
+ }
1967
+
1968
+ function applyLayoutClasses(
1969
+ frame: any,
1970
+ classes: string[] | undefined,
1971
+ colorGroup: Record<string, string>,
1972
+ radiusGroup: Record<string, string> | null,
1973
+ theme: string
1974
+ ): void {
1975
+ const list = classes || [];
1976
+ const layoutIR = LayoutParser.parseToIR(list);
1977
+ LayoutParser.applyToFrame(frame, layoutIR);
1978
+ frame.fills = frame.fills || [];
1979
+ if (list.length > 0) {
1980
+ applyTailwindStylesToFrame(frame, list, colorGroup, radiusGroup, theme);
1981
+ }
1982
+ applyClipBehavior(frame, list);
1983
+
1984
+ if (!frame.itemSpacing || frame.itemSpacing === 0) {
1985
+ frame.itemSpacing = 12;
1986
+ }
1987
+ }
1988
+
1989
+ function applyAbsoluteIfAllowed(child: SceneNode, parent: FrameNode, allowAbsolute: boolean): void {
1990
+ if (!allowAbsolute) return;
1991
+ applyAbsoluteIfPossible(child, parent);
1992
+ }
1993
+
1994
+ export function createUIComponents(parent: any, opts?: any): any {
1995
+ return createUIComponentsFromStory(parent, opts, getStoryBuilderContext());
1996
+ }