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.
- package/README.md +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /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
|
+
}
|