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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -1,21 +1,45 @@
1
1
  // --- Tailwind utilities: class parsing, style conversion, and codegen ---
2
2
 
3
- import { COMPONENT_DEFS } from './tokens';
4
- import { parseColor, nearestColorToken } from './colors';
5
- import type { RGB } from './colors';
6
- import { getComponentDef } from './component-defs';
7
- import { bindColorVariable } from './variables';
3
+ import { COMPONENT_DEFS } from '../tokens';
4
+ import { parseColor, nearestColorToken } from '../tokens';
5
+ import type { RGB } from '../tokens';
6
+ import { getComponentDef } from '../components';
7
+ import { bindColorVariable } from '../tokens';
8
8
  import {
9
9
  parseUtilityClass,
10
- hasResponsiveVariant as hasResponsiveVariantSemantic,
11
- variantState as variantStateSemantic,
12
10
  spacingValue,
13
11
  resolveMaxWidth,
14
12
  resolveRadius,
15
13
  parseLength as parseLengthSemantic,
16
14
  } from './utility-resolver';
17
- import { parseRadialAnchorFromUtility, radialGradientTransformFromAnchor, type RadialAnchor } from './radial-gradient';
15
+ import { parseRadialAnchorFromUtility, radialGradientTransformFromAnchor, type RadialAnchor } from '../effects';
18
16
  import { rotationTransformAroundPointRadians } from './transform-math';
17
+ // Internal mark functions used during CSS processing — resolved post-append by deferred-layout.ts
18
+ import {
19
+ markAbsoluteNode,
20
+ markAspectRatio,
21
+ markPositionInfo,
22
+ markFullWidthNode,
23
+ markFullHeightNode,
24
+ markFixedHeightNode,
25
+ markFixedWidthNode,
26
+ markSelfAlignmentNode,
27
+ markFlexBasisNode,
28
+ markMinWidthNode,
29
+ markMaxWidthNode,
30
+ markGridColumnsNode,
31
+ markColSpanNode,
32
+ markFractionWidthNode,
33
+ markCssGridVerticalFrame,
34
+ parseFractionToken,
35
+ setFrameCrossAlign,
36
+ setFrameInlineAlign,
37
+ shouldApplyAtom,
38
+ BORDER_WIDTH_CLASSES,
39
+ DEFERRED_TOP_RELATIVE_NODES,
40
+ getRingInfoFromClasses,
41
+ markRingNode,
42
+ } from '../layout';
19
43
 
20
44
  // ---------------------------------------------------------------------------
21
45
  // Types
@@ -72,583 +96,15 @@ const FALLBACK_COLOR_TOKENS: Record<string, string> = {
72
96
  type StyleMapEntry = { declarations: Record<string, string>; media?: string };
73
97
  type StyleMap = Record<string, StyleMapEntry[]>;
74
98
 
75
- const ABSOLUTE_NODES = new WeakSet<SceneNode>();
76
- const CSS_GRID_VERTICAL_FRAMES = new WeakSet<SceneNode>(); // grid without grid-cols-N → single-column, children stretch
77
- const FULL_WIDTH_NODES = new WeakSet<SceneNode>();
78
- const FULL_HEIGHT_NODES = new WeakSet<SceneNode>();
79
- const FRACTION_WIDTH_NODES = new WeakMap<SceneNode, number>();
80
- const FIXED_WIDTH_NODES = new WeakSet<SceneNode>();
81
- const SELF_ALIGNMENT_NODES = new WeakMap<SceneNode, 'MIN' | 'CENTER' | 'MAX' | 'STRETCH'>();
82
- const FLEX_BASIS_NODES = new WeakMap<SceneNode, number>();
83
- const MIN_WIDTH_NODES = new WeakMap<SceneNode, number>();
84
- const GRID_COLUMNS_NODES = new WeakMap<SceneNode, number>();
85
- const COL_SPAN_NODES = new WeakMap<SceneNode, number>(); // child → number of columns spanned
86
- const POSITION_INFO_NODES = new WeakMap<SceneNode, { top?: number; bottom?: number; left?: number; right?: number; hintParentHeight?: number; inset?: number }>();
87
- const DEFERRED_BOTTOM_NODES = new WeakMap<SceneNode, number>(); // child → bottom pixel offset
88
- const DEFERRED_TOP_RELATIVE_NODES = new WeakMap<SceneNode, number>(); // child → (parentHeight - N) top offset
89
- const DEFERRED_CENTER_Y_NODES = new WeakSet<SceneNode>(); // absolute children needing cross-axis centering
90
- const BORDER_WIDTH_CLASSES = new WeakMap<SceneNode, string[]>();
91
99
 
92
100
  function getStyleMap(): StyleMap | null {
93
- const defs: any = COMPONENT_DEFS as any;
101
+ const defs = COMPONENT_DEFS;
94
102
  return defs && defs.styleMap ? (defs.styleMap as StyleMap) : null;
95
103
  }
96
104
 
97
105
  function getPaletteTokens(): Record<string, string> {
98
- const defs: any = COMPONENT_DEFS as any;
99
- return defs && defs.paletteTokens ? (defs.paletteTokens as Record<string, string>) : {};
100
- }
101
-
102
- export function markAbsoluteNode(node: SceneNode): void {
103
- ABSOLUTE_NODES.add(node);
104
- }
105
-
106
- export function markPositionInfo(node: SceneNode, info: { top?: number; bottom?: number; left?: number; right?: number; hintParentHeight?: number; inset?: number }): void {
107
- const existing = POSITION_INFO_NODES.get(node) || {};
108
- POSITION_INFO_NODES.set(node, { ...existing, ...info });
109
- }
110
-
111
- export function applyAbsoluteIfPossible(child: SceneNode, parent: FrameNode): void {
112
- if (!ABSOLUTE_NODES.has(child)) return;
113
- if ('layoutMode' in parent && parent.layoutMode && parent.layoutMode !== 'NONE') {
114
- try {
115
- (child as any).layoutPositioning = 'ABSOLUTE';
116
-
117
- // Apply positioning from stored position info
118
- const posInfo = POSITION_INFO_NODES.get(child);
119
- if (posInfo) {
120
- // If the child hints a minimum parent height (e.g. gradient-blob inside collapsed container)
121
- if (posInfo.hintParentHeight != null && 'resize' in parent) {
122
- try {
123
- const ph = posInfo.hintParentHeight;
124
- const pw = Math.max(1, parent.width);
125
- // Respect explicit fixed-height parents. Only auto-expand when the
126
- // parent is effectively unconstrained/collapsed.
127
- let heightIsFixed = false;
128
- if ('layoutMode' in parent && 'primaryAxisSizingMode' in parent && 'counterAxisSizingMode' in parent) {
129
- if ((parent as any).layoutMode === 'VERTICAL') {
130
- heightIsFixed = (parent as any).primaryAxisSizingMode === 'FIXED';
131
- } else if ((parent as any).layoutMode === 'HORIZONTAL') {
132
- heightIsFixed = (parent as any).counterAxisSizingMode === 'FIXED';
133
- }
134
- }
135
- const parentCollapsed = parent.height <= 1;
136
- if (!heightIsFixed && parentCollapsed && parent.height < ph) {
137
- parent.resize(pw, ph);
138
- if ('primaryAxisSizingMode' in parent) (parent as any).primaryAxisSizingMode = 'FIXED';
139
- }
140
- } catch (_e) { /* ignore */ }
141
- }
142
- if ('x' in child && 'y' in child) {
143
- const childFrame = child as FrameNode;
144
- // inset-{n}: position at (n, n) and resize to fill parent minus inset on all sides
145
- if (posInfo.inset != null) {
146
- const inset = posInfo.inset;
147
- childFrame.x = inset;
148
- childFrame.y = inset;
149
- const targetW = Math.max(1, parent.width - inset * 2);
150
- const targetH = Math.max(1, parent.height - inset * 2);
151
- try {
152
- (childFrame as any).resize(targetW, targetH);
153
- if ('primaryAxisSizingMode' in childFrame) (childFrame as any).primaryAxisSizingMode = 'FIXED';
154
- if ('counterAxisSizingMode' in childFrame) (childFrame as any).counterAxisSizingMode = 'FIXED';
155
- } catch (_e) { /* ignore */ }
156
- } else {
157
- if (posInfo.left != null) {
158
- childFrame.x = posInfo.left;
159
- } else if (posInfo.right != null) {
160
- childFrame.x = parent.width - childFrame.width - posInfo.right;
161
- }
162
- if (posInfo.top != null) {
163
- childFrame.y = posInfo.top;
164
- } else if (posInfo.bottom != null) {
165
- // Defer: parent height may not be final yet (flow children added after this call)
166
- DEFERRED_BOTTOM_NODES.set(child, posInfo.bottom);
167
- } else {
168
- // No explicit top/bottom: CSS places absolute children at the cross-axis
169
- // static position. For items-center parents this means vertically centered.
170
- if ((parent as any).counterAxisAlignItems === 'CENTER') {
171
- DEFERRED_CENTER_Y_NODES.add(child);
172
- }
173
- }
174
- }
175
- }
176
- }
177
- } catch (_err) {
178
- // ignore
179
- }
180
- }
181
- ABSOLUTE_NODES.delete(child);
182
- POSITION_INFO_NODES.delete(child);
183
- }
184
-
185
- /** Call after all children have been appended to a frame to apply bottom-anchored positions. */
186
- export function applyDeferredBottomPositioning(parent: FrameNode): void {
187
- for (const child of (parent.children as SceneNode[])) {
188
- const bottom = DEFERRED_BOTTOM_NODES.get(child);
189
- if (bottom != null) {
190
- try {
191
- (child as any).y = parent.height - (child as any).height - bottom;
192
- } catch (_e) { /* ignore */ }
193
- DEFERRED_BOTTOM_NODES.delete(child);
194
- }
195
- // Handle top-[calc(100%-Nrem)]: top = parentHeight - N
196
- const topRelative = DEFERRED_TOP_RELATIVE_NODES.get(child);
197
- if (topRelative != null) {
198
- try {
199
- (child as any).y = parent.height - topRelative;
200
- } catch (_e) { /* ignore */ }
201
- DEFERRED_TOP_RELATIVE_NODES.delete(child);
202
- }
203
- }
204
- }
205
-
206
- /** Call after all children have been appended to center absolute children with no explicit top/bottom. */
207
- export function applyDeferredCenterYPositioning(parent: FrameNode): void {
208
- const parentHeight = parent.height;
209
- if (parentHeight <= 0) return;
210
- for (const child of (parent.children as SceneNode[])) {
211
- if (DEFERRED_CENTER_Y_NODES.has(child)) {
212
- try {
213
- const childHeight = (child as any).height || 0;
214
- (child as any).y = Math.round((parentHeight - childHeight) / 2);
215
- } catch (_e) { /* ignore */ }
216
- DEFERRED_CENTER_Y_NODES.delete(child);
217
- }
218
- }
219
- }
220
-
221
- export function markFullWidthNode(node: SceneNode): void {
222
- FULL_WIDTH_NODES.add(node);
223
- }
224
-
225
- function markFullHeightNode(node: SceneNode): void {
226
- FULL_HEIGHT_NODES.add(node);
227
- }
228
-
229
- function markFixedWidthNode(node: SceneNode): void {
230
- FIXED_WIDTH_NODES.add(node);
231
- }
232
-
233
- function markSelfAlignmentNode(node: SceneNode, align: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH'): void {
234
- SELF_ALIGNMENT_NODES.set(node, align);
235
- }
236
-
237
- function markFlexBasisNode(node: SceneNode, basis: number): void {
238
- if (!Number.isFinite(basis)) return;
239
- FLEX_BASIS_NODES.set(node, basis);
240
- }
241
-
242
- function markMinWidthNode(node: SceneNode, minWidth: number): void {
243
- if (!Number.isFinite(minWidth)) return;
244
- MIN_WIDTH_NODES.set(node, minWidth);
245
- }
246
-
247
- function markGridColumnsNode(node: SceneNode, cols: number): void {
248
- if (!Number.isFinite(cols) || cols <= 0) return;
249
- GRID_COLUMNS_NODES.set(node, cols);
250
- }
251
-
252
- function markColSpanNode(node: SceneNode, span: number): void {
253
- if (!Number.isFinite(span) || span <= 0) return;
254
- COL_SPAN_NODES.set(node, span);
255
- }
256
-
257
- export function getColSpanNode(node: SceneNode): number {
258
- return COL_SPAN_NODES.get(node) ?? 1;
259
- }
260
-
261
- function markFractionWidthNode(node: SceneNode, fraction: number): void {
262
- if (!Number.isFinite(fraction) || fraction <= 0) return;
263
- FRACTION_WIDTH_NODES.set(node, fraction);
264
- }
265
-
266
- function parseFractionToken(token: string): number | null {
267
- if (!token.includes('/')) return null;
268
- const [rawNum, rawDen] = token.split('/');
269
- const num = Number(rawNum);
270
- const den = Number(rawDen);
271
- if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null;
272
- return num / den;
273
- }
274
-
275
- function reapplyDirectionalBordersIfNeeded(node: SceneNode): void {
276
- const classes = BORDER_WIDTH_CLASSES.get(node);
277
- if (!classes || classes.length === 0) return;
278
- if (!('strokes' in node)) return;
279
- applyBorderWidthUtilities(node as FrameNode, classes);
280
- }
281
-
282
- export function applyFullWidthIfPossible(
283
- child: SceneNode,
284
- parent: FrameNode,
285
- options?: { skipFullWidth?: boolean; widthOverride?: number }
286
- ): void {
287
- const align = SELF_ALIGNMENT_NODES.get(child);
288
- const hasSelfAlign = align != null;
289
- if (align) {
290
- if (align === 'STRETCH') {
291
- if ('layoutAlign' in child) {
292
- try { (child as any).layoutAlign = align; } catch (_err) { /* ignore */ }
293
- }
294
- } else {
295
- // MIN / CENTER / MAX: layoutAlign is deprecated for these; use layoutSizingHorizontal = 'HUG' as the best proxy
296
- try { (child as any).layoutSizingHorizontal = 'HUG'; } catch (_err) { /* ignore if no auto-layout */ }
297
- }
298
- SELF_ALIGNMENT_NODES.delete(child);
299
- }
300
-
301
- const basis = FLEX_BASIS_NODES.get(child);
302
- if (basis != null && basis > 0 && 'resize' in child) {
303
- try {
304
- if (parent.layoutMode === 'HORIZONTAL') {
305
- (child as any).resize(basis, (child as any).height);
306
- if ('primaryAxisSizingMode' in child) (child as any).primaryAxisSizingMode = 'FIXED';
307
- } else if (parent.layoutMode === 'VERTICAL') {
308
- (child as any).resize((child as any).width, basis);
309
- if ('primaryAxisSizingMode' in child) (child as any).primaryAxisSizingMode = 'FIXED';
310
- }
311
- } catch (_err) {
312
- // ignore
313
- }
314
- }
315
- if (basis != null) {
316
- FLEX_BASIS_NODES.delete(child);
317
- }
318
-
319
- const minWidth = MIN_WIDTH_NODES.get(child);
320
- if (minWidth != null && 'resize' in child) {
321
- const currentWidth = (child as any).width as number;
322
- if (!Number.isFinite(currentWidth) || currentWidth < minWidth) {
323
- try {
324
- (child as any).resize(minWidth, (child as any).height);
325
- if (parent.layoutMode === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
326
- (child as any).primaryAxisSizingMode = 'FIXED';
327
- } else if (parent.layoutMode === 'VERTICAL' && 'counterAxisSizingMode' in child) {
328
- (child as any).counterAxisSizingMode = 'FIXED';
329
- }
330
- } catch (_err) {
331
- // ignore
332
- }
333
- }
334
- MIN_WIDTH_NODES.delete(child);
335
- }
336
-
337
- const skipFullWidth = !!(options && options.skipFullWidth);
338
- const widthOverride = options && typeof options.widthOverride === 'number' && Number.isFinite(options.widthOverride)
339
- ? options.widthOverride
340
- : null;
341
- const widthBase = widthOverride != null ? widthOverride : parent.width;
342
- // CSS grid (single-column) children stretch to fill the column by default (align-items: stretch).
343
- // Don't stretch absolute-positioned children — they're handled separately.
344
- const isGridStretchChild = !skipFullWidth && CSS_GRID_VERTICAL_FRAMES.has(parent) && !ABSOLUTE_NODES.has(child);
345
- const hasFullWidth = skipFullWidth ? false : (FULL_WIDTH_NODES.has(child) || isGridStretchChild);
346
- const fractionWidth = skipFullWidth ? null : FRACTION_WIDTH_NODES.get(child);
347
- const hasFixedWidth = FIXED_WIDTH_NODES.has(child);
348
- const hasFullHeight = FULL_HEIGHT_NODES.has(child);
349
- if (!hasFullWidth && fractionWidth == null && !hasFullHeight && !hasFixedWidth) return;
350
- if (hasFixedWidth && parent.layoutMode === 'VERTICAL' && !hasSelfAlign && 'layoutAlign' in child) {
351
- try {
352
- (child as any).layoutAlign = 'INHERIT';
353
- } catch (_err) {
354
- // ignore
355
- }
356
- }
357
- try {
358
- if (!hasFullWidth && fractionWidth != null && 'resize' in child && widthBase > 0) {
359
- // widthOverride is already content-width; only subtract padding when using parent.width
360
- const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
361
- const targetWidth = Math.max(0, widthBase - padding) * fractionWidth;
362
- try {
363
- (child as any).resize(targetWidth, (child as any).height);
364
- // Prevent fractional width children from expanding
365
- if ('layoutGrow' in child) {
366
- (child as any).layoutGrow = 0;
367
- }
368
- if (parent.layoutMode === 'VERTICAL' && !hasSelfAlign && 'layoutAlign' in child) {
369
- (child as any).layoutAlign = 'INHERIT';
370
- }
371
- // Set sizing mode to FIXED so the width is respected
372
- if ('layoutMode' in child) {
373
- const childLayout = (child as any).layoutMode;
374
- if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
375
- (child as any).primaryAxisSizingMode = 'FIXED';
376
- } else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
377
- (child as any).counterAxisSizingMode = 'FIXED';
378
- }
379
- } else if (parent.layoutMode === 'VERTICAL' && 'counterAxisSizingMode' in child) {
380
- // For non-layout children in vertical parent, fix the width
381
- (child as any).counterAxisSizingMode = 'FIXED';
382
- }
383
- } catch (_err) {
384
- // ignore resize errors
385
- }
386
- }
387
-
388
- if (hasFullWidth) {
389
- if (parent.layoutMode === 'HORIZONTAL') {
390
- // When parent uses SPACE_BETWEEN, don't apply layoutGrow=1 because that would
391
- // make this child consume all space, leaving nothing for siblings.
392
- // In CSS flexbox, width:100% with justify-content:space-between still spaces items at edges.
393
- // In Figma, we need to explicitly set layoutGrow=0 and let SPACE_BETWEEN distribute items.
394
- const isSpaceBetween = (parent as any).primaryAxisAlignItems === 'SPACE_BETWEEN';
395
- if (isSpaceBetween) {
396
- // Explicitly set to 0 to prevent any expansion
397
- (child as any).layoutGrow = 0;
398
- // Also ensure the child sizes to its content, not to fill
399
- if ('primaryAxisSizingMode' in child) {
400
- (child as any).primaryAxisSizingMode = 'AUTO';
401
- }
402
- } else {
403
- (child as any).layoutGrow = 1;
404
- }
405
- if (
406
- (('primaryAxisSizingMode' in parent && (parent as any).primaryAxisSizingMode === 'FIXED') || widthOverride != null)
407
- && 'resize' in child
408
- && !isSpaceBetween
409
- && widthBase > 0
410
- ) {
411
- try {
412
- (child as any).resize(widthBase, (child as any).height);
413
- if ('primaryAxisSizingMode' in child) {
414
- (child as any).primaryAxisSizingMode = 'FIXED';
415
- }
416
- } catch (_err) {
417
- // ignore resize errors
418
- }
419
- }
420
- } else if (parent.layoutMode === 'VERTICAL') {
421
- // If the child is HORIZONTAL with SPACE_BETWEEN and already has FIXED sizing,
422
- // don't use STRETCH as it conflicts. Instead, keep the explicit FIXED width.
423
- const childIsHorizontal = 'layoutMode' in child && (child as any).layoutMode === 'HORIZONTAL';
424
- const childHasSpaceBetween = childIsHorizontal && (child as any).primaryAxisAlignItems === 'SPACE_BETWEEN';
425
- const childHasFixedWidth = 'primaryAxisSizingMode' in child && (child as any).primaryAxisSizingMode === 'FIXED';
426
- if (childHasSpaceBetween && childHasFixedWidth) {
427
- // Keep the FIXED sizing, but set layoutAlign to FILL to take parent width
428
- // Actually, for SPACE_BETWEEN to work, we need the child to have a specific width
429
- // So we resize it to parent width and keep FIXED
430
- if ('resize' in child && widthBase > 0) {
431
- try {
432
- const padding = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
433
- (child as any).resize(widthBase - padding, (child as any).height);
434
- } catch (_err) {
435
- // ignore
436
- }
437
- }
438
- } else {
439
- (child as any).layoutAlign = 'STRETCH';
440
- if (
441
- (('counterAxisSizingMode' in parent && (parent as any).counterAxisSizingMode === 'FIXED') || widthOverride != null)
442
- && 'resize' in child
443
- ) {
444
- try {
445
- // widthOverride is already content-width (padding already subtracted by caller).
446
- // When falling back to parent.width we must subtract padding ourselves.
447
- const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
448
- const targetWidth = Math.max(0, widthBase - padding);
449
- if (targetWidth > 0) {
450
- (child as any).resize(targetWidth, (child as any).height);
451
- if ('layoutMode' in child) {
452
- const childLayout = (child as any).layoutMode;
453
- if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
454
- (child as any).primaryAxisSizingMode = 'FIXED';
455
- } else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
456
- (child as any).counterAxisSizingMode = 'FIXED';
457
- }
458
- } else if ('counterAxisSizingMode' in child) {
459
- (child as any).counterAxisSizingMode = 'FIXED';
460
- }
461
- }
462
- } catch (_err) {
463
- // ignore
464
- }
465
- }
466
- }
467
- }
468
- }
469
- if (hasFullHeight) {
470
- if (parent.layoutMode === 'VERTICAL') {
471
- if ('layoutGrow' in child && (parent as any).primaryAxisSizingMode === 'FIXED') {
472
- (child as any).layoutGrow = 1;
473
- }
474
- } else if (parent.layoutMode === 'HORIZONTAL') {
475
- if ('layoutAlign' in child) {
476
- (child as any).layoutAlign = 'STRETCH';
477
- }
478
- if (
479
- 'counterAxisSizingMode' in parent
480
- && (parent as any).counterAxisSizingMode === 'FIXED'
481
- && 'resize' in child
482
- ) {
483
- try {
484
- const padding = (parent.paddingTop || 0) + (parent.paddingBottom || 0);
485
- const targetHeight = Math.max(0, parent.height - padding);
486
- if (targetHeight > 0) {
487
- (child as any).resize((child as any).width, targetHeight);
488
- if ('counterAxisSizingMode' in child) {
489
- (child as any).counterAxisSizingMode = 'FIXED';
490
- }
491
- }
492
- } catch (_err) {
493
- // ignore
494
- }
495
- }
496
- }
497
- }
498
- } catch (_err) {
499
- // ignore
500
- }
501
- reapplyDirectionalBordersIfNeeded(child);
502
- // Keep mark so we can re-apply after parent resize.
503
- }
504
-
505
- export function applyGridColumnsIfPossible(
506
- frame: FrameNode,
507
- widthOverride?: number,
508
- colsOverride?: number
509
- ): void {
510
- const cols = colsOverride != null ? colsOverride : GRID_COLUMNS_NODES.get(frame);
511
- if (!cols || cols <= 0) return;
512
- if (colsOverride != null) {
513
- GRID_COLUMNS_NODES.set(frame, cols);
514
- }
515
-
516
- try {
517
- if (frame.layoutMode !== 'HORIZONTAL') {
518
- frame.layoutMode = 'HORIZONTAL';
519
- }
520
- if ((frame as any).layoutWrap !== undefined) {
521
- (frame as any).layoutWrap = 'WRAP';
522
- }
523
- } catch (_err) {
524
- // ignore
525
- }
526
-
527
- const totalWidth = widthOverride && Number.isFinite(widthOverride) ? widthOverride : frame.width;
528
- if (!totalWidth || totalWidth <= 0) return;
529
-
530
- const gap = frame.itemSpacing || 0;
531
- const padding = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
532
- const available = totalWidth - padding - gap * (cols - 1);
533
- if (available <= 0) return;
534
-
535
- const childWidth = Math.max(0, available / cols);
536
- if ((frame as any).counterAxisSpacing !== undefined) {
537
- (frame as any).counterAxisSpacing = gap;
538
- }
539
-
540
- for (const child of frame.children) {
541
- if (!('resize' in child)) continue;
542
- try {
543
- if ((child as any).layoutPositioning === 'ABSOLUTE') continue;
544
- const span = Math.min(COL_SPAN_NODES.get(child) ?? 1, cols);
545
- const spanWidth = childWidth * span + gap * (span - 1);
546
- // For text nodes, fix the width so textAlignHorizontal (e.g. text-right) takes effect.
547
- // Without this, WIDTH_AND_HEIGHT auto-resize snaps the node back to text-fit width.
548
- if ((child as any).textAutoResize !== undefined) {
549
- (child as any).textAutoResize = 'HEIGHT';
550
- }
551
- (child as any).resize(spanWidth, (child as any).height);
552
- // Prevent grid children from expanding beyond calculated width
553
- if ('layoutGrow' in child) {
554
- (child as any).layoutGrow = 0;
555
- }
556
- if ('layoutMode' in child) {
557
- const childLayout = (child as any).layoutMode;
558
- if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
559
- (child as any).counterAxisSizingMode = 'FIXED';
560
- } else if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
561
- (child as any).primaryAxisSizingMode = 'FIXED';
562
- }
563
- } else if ('primaryAxisSizingMode' in child) {
564
- (child as any).primaryAxisSizingMode = 'FIXED';
565
- }
566
- reapplyDirectionalBordersIfNeeded(child);
567
- } catch (_err) {
568
- // ignore
569
- }
570
- }
571
- }
572
-
573
- export function hasGridColumnsNode(node: SceneNode): boolean {
574
- return GRID_COLUMNS_NODES.has(node);
575
- }
576
-
577
- export function getGridColumnsNode(node: SceneNode): number | null {
578
- const cols = GRID_COLUMNS_NODES.get(node);
579
- return cols != null ? cols : null;
580
- }
581
-
582
- export function applyFlexGrowIfPossible(frame: FrameNode, widthOverride?: number): void {
583
- if (frame.layoutMode !== 'HORIZONTAL') return;
584
-
585
- const padding = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
586
- const gap = frame.itemSpacing || 0;
587
- const children = frame.children.filter(child => {
588
- const positioning = (child as any).layoutPositioning;
589
- return positioning !== 'ABSOLUTE';
590
- });
591
- if (children.length === 0) return;
592
-
593
- let fixedWidth = 0;
594
- let growTotal = 0;
595
- const growChildren: SceneNode[] = [];
596
-
597
- for (const child of children) {
598
- const grow = (child as any).layoutGrow;
599
- if (Number.isFinite(grow) && grow > 0) {
600
- growTotal += grow;
601
- growChildren.push(child);
602
- } else {
603
- fixedWidth += (child as any).width || 0;
604
- }
605
- }
606
-
607
- if (growChildren.length === 0 || growTotal <= 0) return;
608
-
609
- // Determine target width: use override, frame width, or compute based on children
610
- let targetWidth = widthOverride && Number.isFinite(widthOverride) ? widthOverride : frame.width;
611
-
612
- // If no width available, compute a minimum width based on fixed children + space for grow children
613
- const totalGap = gap * Math.max(0, children.length - 1);
614
- if (!targetWidth || targetWidth <= 0) {
615
- // Use 150px per grow unit as a reasonable default for flex-grow children
616
- const growWidth = 150 * growTotal;
617
- targetWidth = padding + totalGap + fixedWidth + growWidth;
618
- }
619
-
620
- // Resize frame to target width and set to FIXED
621
- try {
622
- frame.resize(targetWidth, frame.height);
623
- frame.primaryAxisSizingMode = 'FIXED';
624
- } catch (_err) {
625
- // ignore
626
- }
627
-
628
- const remaining = targetWidth - padding - totalGap - fixedWidth;
629
- if (!Number.isFinite(remaining) || remaining <= 0) return;
630
-
631
- for (const child of growChildren) {
632
- const grow = (child as any).layoutGrow;
633
- const width = remaining * (grow / growTotal);
634
- if (!Number.isFinite(width) || width <= 0) continue;
635
- if (!('resize' in child)) continue;
636
- try {
637
- (child as any).resize(width, (child as any).height);
638
- if ('layoutMode' in child) {
639
- const childLayout = (child as any).layoutMode;
640
- if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
641
- (child as any).counterAxisSizingMode = 'FIXED';
642
- } else if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
643
- (child as any).primaryAxisSizingMode = 'FIXED';
644
- }
645
- } else if ('primaryAxisSizingMode' in child) {
646
- (child as any).primaryAxisSizingMode = 'FIXED';
647
- }
648
- } catch (_err) {
649
- // ignore
650
- }
651
- }
106
+ const defs = COMPONENT_DEFS;
107
+ return defs && defs.paletteTokens ? defs.paletteTokens : {};
652
108
  }
653
109
 
654
110
  function cssVarToToken(value: string): string | null {
@@ -667,27 +123,6 @@ function resolveSpacingToken(token: string, spacingScale: Record<string, number>
667
123
  return spacingValue(token, spacingScale);
668
124
  }
669
125
 
670
- const STATE_VARIANTS = new Set([
671
- 'hover',
672
- 'focus',
673
- 'focus-visible',
674
- 'disabled',
675
- 'active',
676
- 'aria-invalid',
677
- ]);
678
-
679
- function isStateVariant(variant: string): boolean {
680
- if (STATE_VARIANTS.has(variant)) return true;
681
- return variant.startsWith('data-[state=');
682
- }
683
-
684
- function shouldApplyAtom(atom: ReturnType<typeof parseUtilityClass>, state: string): boolean {
685
- if (hasResponsiveVariantSemantic(atom.variants)) return false;
686
- if (state === 'default') return atom.variants.length === 0;
687
- if (!atom.variants.length) return false;
688
- if (!atom.variants.every(isStateVariant)) return false;
689
- return variantStateSemantic(atom.variants) === state;
690
- }
691
126
 
692
127
  function parseOpacityToken(token: string): number | null {
693
128
  if (!token) return null;
@@ -770,35 +205,26 @@ function parseFlexShorthand(value: string): { grow: number | null; basis: number
770
205
  }
771
206
 
772
207
  function getBorderSideWeights(frame: FrameNode): { top: number; right: number; bottom: number; left: number } {
773
- const target = frame as any;
774
208
  const hasStroke = Array.isArray(frame.strokes) && frame.strokes.length > 0;
775
- const baseWeight = hasStroke && Number.isFinite(frame.strokeWeight) ? frame.strokeWeight : 0;
209
+ const baseStrokeWeight = typeof frame.strokeWeight === 'number' ? frame.strokeWeight : 0;
210
+ const baseWeight = hasStroke && Number.isFinite(baseStrokeWeight) ? baseStrokeWeight : 0;
211
+ const sideWeight = (value: number | symbol): number =>
212
+ typeof value === 'number' && Number.isFinite(value) ? value : baseWeight;
776
213
  return {
777
- top: Number.isFinite(target.strokeTopWeight) ? target.strokeTopWeight : baseWeight,
778
- right: Number.isFinite(target.strokeRightWeight) ? target.strokeRightWeight : baseWeight,
779
- bottom: Number.isFinite(target.strokeBottomWeight) ? target.strokeBottomWeight : baseWeight,
780
- left: Number.isFinite(target.strokeLeftWeight) ? target.strokeLeftWeight : baseWeight,
214
+ top: sideWeight(frame.strokeTopWeight),
215
+ right: sideWeight(frame.strokeRightWeight),
216
+ bottom: sideWeight(frame.strokeBottomWeight),
217
+ left: sideWeight(frame.strokeLeftWeight),
781
218
  };
782
219
  }
783
220
 
784
221
  function setBorderSideWeights(frame: FrameNode, sides: { top: number; right: number; bottom: number; left: number }): void {
785
- const target = frame as any;
786
- const hasIndividualStrokes = (
787
- 'strokeTopWeight' in target
788
- || 'strokeRightWeight' in target
789
- || 'strokeBottomWeight' in target
790
- || 'strokeLeftWeight' in target
791
- );
792
- if (hasIndividualStrokes) {
793
- target.strokeTopWeight = sides.top;
794
- target.strokeRightWeight = sides.right;
795
- target.strokeBottomWeight = sides.bottom;
796
- target.strokeLeftWeight = sides.left;
797
- // Do not write strokeWeight here: in Figma this can normalize all sides
798
- // and undo directional borders (e.g. border-t becoming full box).
799
- return;
800
- }
801
- frame.strokeWeight = Math.max(sides.top, sides.right, sides.bottom, sides.left, 0);
222
+ frame.strokeTopWeight = sides.top;
223
+ frame.strokeRightWeight = sides.right;
224
+ frame.strokeBottomWeight = sides.bottom;
225
+ frame.strokeLeftWeight = sides.left;
226
+ // Do not write strokeWeight here: in Figma this can normalize all sides
227
+ // and undo directional borders (e.g. border-t becoming full box).
802
228
  }
803
229
 
804
230
  function setDirectionalBorder(frame: FrameNode, utility: string, weight: number): boolean {
@@ -880,26 +306,50 @@ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<stri
880
306
  let autoMarginLeft = false;
881
307
  let autoMarginRight = false;
882
308
  let autoMarginInline = false;
309
+ let declaredGridColumns: number | null = null;
310
+
311
+ const gridTemplateValue = declarations['grid-template-columns'];
312
+ if (typeof gridTemplateValue === 'string') {
313
+ const match = gridTemplateValue.trim().match(/repeat\((\d+),/);
314
+ if (match) {
315
+ const parsed = parseInt(match[1], 10);
316
+ if (Number.isFinite(parsed) && parsed > 0) {
317
+ declaredGridColumns = parsed;
318
+ }
319
+ }
320
+ }
883
321
 
884
322
  for (const [prop, rawValue] of Object.entries(declarations)) {
885
323
  const value = rawValue.trim();
886
324
  if (prop === 'display' && (value === 'flex' || value === 'inline-flex')) {
887
325
  frame.layoutMode = 'HORIZONTAL';
326
+ // CSS default for flex containers is `align-items: stretch`.
327
+ setFrameCrossAlign(frame, 'STRETCH');
888
328
  continue;
889
329
  }
890
- if (prop === 'display' && value === 'grid') {
891
- frame.layoutMode = 'HORIZONTAL';
892
- if ((frame as any).layoutWrap !== undefined) {
893
- (frame as any).layoutWrap = 'WRAP';
330
+ if (prop === 'display' && (value === 'grid' || value === 'inline-grid')) {
331
+ if (declaredGridColumns != null) {
332
+ frame.layoutMode = 'HORIZONTAL';
333
+ markGridColumnsNode(frame, declaredGridColumns);
334
+ if (frame.layoutWrap !== undefined) {
335
+ frame.layoutWrap = 'WRAP';
336
+ }
337
+ } else {
338
+ // CSS grid without explicit template columns behaves like a single-column
339
+ // vertical flow in our Figma mapping.
340
+ frame.layoutMode = 'VERTICAL';
341
+ markCssGridVerticalFrame(frame);
894
342
  }
343
+ // CSS default for grid containers is `align-items: stretch` per track.
344
+ setFrameCrossAlign(frame, 'STRETCH');
895
345
  continue;
896
346
  }
897
347
  if (prop === 'flex-direction') {
898
348
  frame.layoutMode = value === 'column' || value === 'column-reverse' ? 'VERTICAL' : 'HORIZONTAL';
899
349
  continue;
900
350
  }
901
- if (prop === 'flex-wrap' && (frame as any).layoutWrap !== undefined) {
902
- (frame as any).layoutWrap = value === 'wrap' || value === 'wrap-reverse' ? 'WRAP' : 'NO_WRAP';
351
+ if (prop === 'flex-wrap' && frame.layoutWrap !== undefined) {
352
+ frame.layoutWrap = value === 'wrap' || value === 'wrap-reverse' ? 'WRAP' : 'NO_WRAP';
903
353
  continue;
904
354
  }
905
355
  if (prop === 'grid-template-columns') {
@@ -923,10 +373,11 @@ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<stri
923
373
  continue;
924
374
  }
925
375
  if (prop === 'align-items') {
926
- if (value === 'center') frame.counterAxisAlignItems = 'CENTER';
927
- if (value === 'flex-start') frame.counterAxisAlignItems = 'MIN';
928
- if (value === 'flex-end') frame.counterAxisAlignItems = 'MAX';
929
- if (value === 'stretch') frame.counterAxisAlignItems = 'MIN';
376
+ if (value === 'center') { frame.counterAxisAlignItems = 'CENTER'; setFrameCrossAlign(frame, 'CENTER'); }
377
+ if (value === 'flex-start') { frame.counterAxisAlignItems = 'MIN'; setFrameCrossAlign(frame, 'MIN'); }
378
+ if (value === 'flex-end') { frame.counterAxisAlignItems = 'MAX'; setFrameCrossAlign(frame, 'MAX'); }
379
+ // Figma has no parent-level STRETCH — emulated per-child via layoutAlign in applyChildProperties.
380
+ if (value === 'stretch') { frame.counterAxisAlignItems = 'MIN'; setFrameCrossAlign(frame, 'STRETCH'); }
930
381
  continue;
931
382
  }
932
383
  if (prop === 'align-self') {
@@ -1014,6 +465,7 @@ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<stri
1014
465
  const len = parseCssLength(value);
1015
466
  if (len != null) {
1016
467
  frame.resize(frame.width, len);
468
+ markFixedHeightNode(frame);
1017
469
  if (frame.layoutMode === 'VERTICAL') {
1018
470
  frame.primaryAxisSizingMode = 'FIXED';
1019
471
  } else if (frame.layoutMode === 'HORIZONTAL') {
@@ -1077,6 +529,7 @@ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<stri
1077
529
 
1078
530
  if (autoMarginInline || (autoMarginLeft && autoMarginRight)) {
1079
531
  markSelfAlignmentNode(frame, 'CENTER');
532
+ markFullWidthNode(frame);
1080
533
  } else if (autoMarginLeft) {
1081
534
  markSelfAlignmentNode(frame, 'MAX');
1082
535
  } else if (autoMarginRight) {
@@ -1374,6 +827,12 @@ function applySemanticUtilitiesToFrame(
1374
827
  radiusGroup: Record<string, string> | null,
1375
828
  ): Set<string> {
1376
829
  const handled = new Set<string>();
830
+ // Track whether flex-direction was explicitly set on this frame. `flex`
831
+ // means "enable flex layout, default direction row" — but in CSS it does
832
+ // NOT reset an already-set flex-direction. A duplicated class list like
833
+ // `flex flex-col … flex flex-col` (base + user override on SheetContent)
834
+ // must keep the VERTICAL direction even if the trailing `flex` is processed.
835
+ let flexDirectionSet = false;
1377
836
  const spacingScale = (COMPONENT_DEFS && COMPONENT_DEFS.spacingScale) ? COMPONENT_DEFS.spacingScale : {};
1378
837
  let gap: number | null = null;
1379
838
  let gapX: number | null = null;
@@ -1403,8 +862,46 @@ function applySemanticUtilitiesToFrame(
1403
862
 
1404
863
  if (!shouldApplyAtom(atom, 'default')) continue;
1405
864
 
1406
- if (utility === 'flex' || utility === 'inline-flex' || utility === 'flex-row') {
865
+ if (utility === 'flex' || utility === 'inline-flex') {
866
+ // Bare `flex` enables flex layout but does not set direction. Only write
867
+ // HORIZONTAL when the direction hasn't been declared yet by a prior
868
+ // flex-row / flex-col / grid / flex-col-reverse etc.
869
+ if (!flexDirectionSet) frame.layoutMode = 'HORIZONTAL';
870
+ // CSS default for flex containers is `align-items: stretch`.
871
+ setFrameCrossAlign(frame, 'STRETCH');
872
+ // CSS distinguishes `display: flex` (block-level — takes full width in
873
+ // its parent block context) from `display: inline-flex` (inline-level
874
+ // — hugs content). The plugin treats both as HORIZONTAL auto-layout,
875
+ // but the SIZING of the container in its parent differs. Mark
876
+ // inline-flex frames so they hug content on their main axis and don't
877
+ // get stretched by a parent's STRETCH cross-axis alignment.
878
+ if (utility === 'inline-flex') {
879
+ try {
880
+ if ('primaryAxisSizingMode' in frame) frame.primaryAxisSizingMode = 'AUTO';
881
+ if ('counterAxisSizingMode' in frame) frame.counterAxisSizingMode = 'AUTO';
882
+ if ('layoutAlign' in frame) frame.layoutAlign = 'INHERIT';
883
+ if ('layoutSizingHorizontal' in frame) frame.layoutSizingHorizontal = 'HUG';
884
+ if ('layoutSizingVertical' in frame) frame.layoutSizingVertical = 'HUG';
885
+ } catch (_err) {
886
+ // ignore — these properties may not be writable in all node types
887
+ }
888
+ }
889
+ handled.add(cls);
890
+ continue;
891
+ }
892
+ if (utility === 'flex-row' || utility === 'flex-row-reverse') {
1407
893
  frame.layoutMode = 'HORIZONTAL';
894
+ flexDirectionSet = true;
895
+ setFrameCrossAlign(frame, 'STRETCH');
896
+ handled.add(cls);
897
+ continue;
898
+ }
899
+ if (utility === 'flex-col' || utility === 'flex-col-reverse') {
900
+ // `flex-col` sets column direction. Any following bare `flex` must not
901
+ // revert layoutMode to HORIZONTAL (see duplicated SheetContent classes).
902
+ frame.layoutMode = 'VERTICAL';
903
+ flexDirectionSet = true;
904
+ setFrameCrossAlign(frame, 'STRETCH');
1408
905
  handled.add(cls);
1409
906
  continue;
1410
907
  }
@@ -1412,14 +909,16 @@ function applySemanticUtilitiesToFrame(
1412
909
  const hasColumns = classes.some((c: string) => /^grid-cols-\d+$/.test(c));
1413
910
  if (hasColumns) {
1414
911
  frame.layoutMode = 'HORIZONTAL';
1415
- if ((frame as any).layoutWrap !== undefined) {
1416
- (frame as any).layoutWrap = 'WRAP';
912
+ if (frame.layoutWrap !== undefined) {
913
+ frame.layoutWrap = 'WRAP';
1417
914
  }
1418
915
  } else {
1419
916
  // Single-column implicit grid → VERTICAL; children stretch to fill (CSS default align-items: stretch)
1420
917
  frame.layoutMode = 'VERTICAL';
1421
- CSS_GRID_VERTICAL_FRAMES.add(frame);
918
+ markCssGridVerticalFrame(frame);
1422
919
  }
920
+ flexDirectionSet = true;
921
+ setFrameCrossAlign(frame, 'STRETCH');
1423
922
  handled.add(cls);
1424
923
  continue;
1425
924
  }
@@ -1499,6 +998,109 @@ function applySemanticUtilitiesToFrame(
1499
998
  continue;
1500
999
  }
1501
1000
 
1001
+ // Handle percent-based positions: `top-[X%]`, `bottom-[X%]`, `left-[X%]`,
1002
+ // `right-[X%]` (e.g. `top-[30%]`, `left-[18.33%]`). Stored on
1003
+ // POSITION_INFO_NODES as a fraction in [0, 1]; resolved by
1004
+ // `applyDeferredPercentPositioning` once the parent's dimensions are final.
1005
+ const topPctMatch = utility.match(/^top-\[(\d+(?:\.\d+)?)%\]$/);
1006
+ if (topPctMatch) {
1007
+ markPositionInfo(frame, { topPercent: parseFloat(topPctMatch[1]) / 100 });
1008
+ handled.add(cls);
1009
+ continue;
1010
+ }
1011
+ const bottomPctMatch = utility.match(/^bottom-\[(\d+(?:\.\d+)?)%\]$/);
1012
+ if (bottomPctMatch) {
1013
+ markPositionInfo(frame, { bottomPercent: parseFloat(bottomPctMatch[1]) / 100 });
1014
+ handled.add(cls);
1015
+ continue;
1016
+ }
1017
+ const leftPctMatch = utility.match(/^left-\[(\d+(?:\.\d+)?)%\]$/);
1018
+ if (leftPctMatch) {
1019
+ markPositionInfo(frame, { leftPercent: parseFloat(leftPctMatch[1]) / 100 });
1020
+ handled.add(cls);
1021
+ continue;
1022
+ }
1023
+ const rightPctMatch = utility.match(/^right-\[(\d+(?:\.\d+)?)%\]$/);
1024
+ if (rightPctMatch) {
1025
+ markPositionInfo(frame, { rightPercent: parseFloat(rightPctMatch[1]) / 100 });
1026
+ handled.add(cls);
1027
+ continue;
1028
+ }
1029
+
1030
+ // Handle self-translate fractions: `-translate-x-1/2`, `translate-x-1/2`,
1031
+ // `-translate-x-full`, `translate-y-1/3`, etc. Mirrors CSS
1032
+ // `transform: translate(N%, N%)` applied as a fraction of the element's
1033
+ // OWN width/height. Most common with absolute + percent positions
1034
+ // (`absolute left-[50%] top-[50%] -translate-x-1/2 -translate-y-1/2`
1035
+ // centers the element on the parent's (50%, 50%) point). Resolved in
1036
+ // `applyDeferredPercentPositioning` because it depends on the child's
1037
+ // final width / height, which may not be known when classes are parsed.
1038
+ const translateMatch = utility.match(/^(-?)translate-(x|y)-(.+)$/);
1039
+ if (translateMatch) {
1040
+ const sign = translateMatch[1] === '-' ? -1 : 1;
1041
+ const axis = translateMatch[2];
1042
+ const token = translateMatch[3];
1043
+ let fraction: number | null = null;
1044
+ if (token === 'full') fraction = 1;
1045
+ else if (token === 'px') fraction = 0; // px-based translate is 0% of self
1046
+ else {
1047
+ const frac = token.match(/^(\d+)\/(\d+)$/);
1048
+ if (frac) {
1049
+ const num = parseFloat(frac[1]);
1050
+ const den = parseFloat(frac[2]);
1051
+ if (den !== 0) fraction = num / den;
1052
+ }
1053
+ }
1054
+ if (fraction != null) {
1055
+ const signed = sign * fraction;
1056
+ if (axis === 'x') {
1057
+ markPositionInfo(frame, { translateXFraction: signed });
1058
+ } else {
1059
+ markPositionInfo(frame, { translateYFraction: signed });
1060
+ }
1061
+ handled.add(cls);
1062
+ continue;
1063
+ }
1064
+ }
1065
+
1066
+ // Handle aspect-ratio: aspect-{n}/{m}, aspect-square, aspect-video,
1067
+ // aspect-[N/M] and aspect-[N] arbitrary values. Records the ratio
1068
+ // (width/height) on the frame; deferred-layout resolves it once the
1069
+ // frame's width is settled.
1070
+ if (utility === 'aspect-square') {
1071
+ markAspectRatio(frame, 1);
1072
+ handled.add(cls);
1073
+ continue;
1074
+ }
1075
+ if (utility === 'aspect-video') {
1076
+ markAspectRatio(frame, 16 / 9);
1077
+ handled.add(cls);
1078
+ continue;
1079
+ }
1080
+ const aspectFractionMatch = utility.match(/^aspect-(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
1081
+ if (aspectFractionMatch) {
1082
+ const aw = parseFloat(aspectFractionMatch[1]);
1083
+ const ah = parseFloat(aspectFractionMatch[2]);
1084
+ if (aw > 0 && ah > 0) markAspectRatio(frame, aw / ah);
1085
+ handled.add(cls);
1086
+ continue;
1087
+ }
1088
+ const aspectArbMatch = utility.match(/^aspect-\[(.+)\]$/);
1089
+ if (aspectArbMatch) {
1090
+ const expr = aspectArbMatch[1];
1091
+ const slash = expr.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
1092
+ if (slash) {
1093
+ const aw = parseFloat(slash[1]);
1094
+ const ah = parseFloat(slash[2]);
1095
+ if (aw > 0 && ah > 0) markAspectRatio(frame, aw / ah);
1096
+ } else {
1097
+ const num = parseFloat(expr);
1098
+ if (Number.isFinite(num) && num > 0) markAspectRatio(frame, num);
1099
+ }
1100
+ handled.add(cls);
1101
+ continue;
1102
+ }
1103
+
1502
1104
  // Handle blur effects (blur-sm, blur, blur-md, blur-lg, blur-xl, blur-2xl, blur-3xl)
1503
1105
  const blurMatch = utility.match(/^blur(?:-(.+))?$/);
1504
1106
  if (blurMatch) {
@@ -1517,10 +1119,9 @@ function applySemanticUtilitiesToFrame(
1517
1119
  const blurKey = blurMatch[1] || '';
1518
1120
  const blurRadius = blurScale[blurKey];
1519
1121
  if (blurRadius != null && blurRadius > 0) {
1520
- frame.effects = [
1521
- ...(frame.effects || []),
1122
+ frame.effects = (frame.effects || []).concat([
1522
1123
  { type: 'LAYER_BLUR', blurType: 'NORMAL', radius: blurRadius, visible: true } as Effect,
1523
- ];
1124
+ ]);
1524
1125
  }
1525
1126
  handled.add(cls);
1526
1127
  continue;
@@ -1554,12 +1155,21 @@ function applySemanticUtilitiesToFrame(
1554
1155
  };
1555
1156
  const defs = SHADOW_MAP[shadowKey];
1556
1157
  if (defs !== undefined) {
1158
+ // Figma renders multi-layer DROP_SHADOW effects as independently
1159
+ // stacked filters — they composite "harder" than CSS box-shadow,
1160
+ // where the browser's filter pipeline smooths overlapping
1161
+ // shadow layers. The numeric values in SHADOW_MAP match
1162
+ // Tailwind v3's docs exactly, but the visual result reads as
1163
+ // ~30 % stronger in Figma at parity. Damp the alpha channel so
1164
+ // the rendered page matches Storybook / browser intensity. Tune
1165
+ // this single constant to dial all tiers up or down together.
1166
+ const SHADOW_ALPHA_DAMPER = 0.7;
1557
1167
  const shadowEffects: Effect[] = [];
1558
1168
  for (let si = 0; si < defs.length; si++) {
1559
1169
  const d = defs[si];
1560
1170
  shadowEffects.push({
1561
1171
  type: d.type,
1562
- color: { r: d.r, g: d.g, b: d.b, a: d.a },
1172
+ color: { r: d.r, g: d.g, b: d.b, a: d.a * SHADOW_ALPHA_DAMPER },
1563
1173
  offset: { x: d.x, y: d.y },
1564
1174
  radius: d.radius,
1565
1175
  spread: d.spread,
@@ -1568,23 +1178,18 @@ function applySemanticUtilitiesToFrame(
1568
1178
  } as Effect);
1569
1179
  }
1570
1180
  // Replace any existing shadow effects (drop + inner) and keep other effects (blur etc.)
1571
- const allEffects: Effect[] = Array.from((frame as any).effects || []);
1572
- const nonShadow = allEffects.filter(
1573
- (e: Effect) => (e as any).type !== 'DROP_SHADOW' && (e as any).type !== 'INNER_SHADOW'
1181
+ const allEffects: Effect[] = Array.from(frame.effects || []);
1182
+ const nonShadow: Effect[] = allEffects.filter(
1183
+ (e: Effect) => e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW'
1574
1184
  );
1575
- (frame as any).effects = nonShadow.concat(shadowEffects);
1185
+ frame.effects = nonShadow.concat(shadowEffects);
1576
1186
  }
1577
1187
  handled.add(cls);
1578
1188
  continue;
1579
1189
  }
1580
1190
 
1581
- if (utility === 'flex-col') {
1582
- frame.layoutMode = 'VERTICAL';
1583
- handled.add(cls);
1584
- continue;
1585
- }
1586
- if (utility === 'flex-wrap' && (frame as any).layoutWrap !== undefined) {
1587
- (frame as any).layoutWrap = 'WRAP';
1191
+ if (utility === 'flex-wrap' && frame.layoutWrap !== undefined) {
1192
+ frame.layoutWrap = 'WRAP';
1588
1193
  handled.add(cls);
1589
1194
  continue;
1590
1195
  }
@@ -1596,15 +1201,49 @@ function applySemanticUtilitiesToFrame(
1596
1201
  handled.add(cls);
1597
1202
  continue;
1598
1203
  }
1599
- if (utility === 'items-center') { frame.counterAxisAlignItems = 'CENTER'; handled.add(cls); continue; }
1600
- if (utility === 'items-start') { frame.counterAxisAlignItems = 'MIN'; handled.add(cls); continue; }
1601
- if (utility === 'items-end') { frame.counterAxisAlignItems = 'MAX'; handled.add(cls); continue; }
1204
+ // `text-center` / `text-left` / `text-right` on a block-flow parent
1205
+ // also affects horizontal alignment of inline-display children inside
1206
+ // it (CSS centers inline content via the parent's text-align). Set
1207
+ // the parent's counterAxisAlignItems so the Figma inspector reflects
1208
+ // the visual alignment AND inline-display children inherit it. Block
1209
+ // children that get layoutAlign='STRETCH' via the implicit-stretch
1210
+ // pass still take full width regardless of this alignment, preserving
1211
+ // the headline/paragraph stretch-with-text-align behaviour.
1212
+ //
1213
+ // Don't touch FRAME_CROSS_ALIGN — the implicit-stretch logic uses
1214
+ // that to decide which children stretch; setting it to CENTER here
1215
+ // would disable stretching for block children of `text-center` divs.
1216
+ //
1217
+ // Doesn't `continue` — text-center also affects text rendering and is
1218
+ // consumed by the text builder later.
1219
+ if (utility === 'text-center') {
1220
+ setFrameInlineAlign(frame, 'CENTER');
1221
+ if ('counterAxisAlignItems' in frame) {
1222
+ try { frame.counterAxisAlignItems = 'CENTER'; } catch (_e) { /* ignore */ }
1223
+ }
1224
+ } else if (utility === 'text-right') {
1225
+ setFrameInlineAlign(frame, 'MAX');
1226
+ if ('counterAxisAlignItems' in frame) {
1227
+ try { frame.counterAxisAlignItems = 'MAX'; } catch (_e) { /* ignore */ }
1228
+ }
1229
+ } else if (utility === 'text-left') {
1230
+ setFrameInlineAlign(frame, 'MIN');
1231
+ if ('counterAxisAlignItems' in frame) {
1232
+ try { frame.counterAxisAlignItems = 'MIN'; } catch (_e) { /* ignore */ }
1233
+ }
1234
+ }
1235
+ if (utility === 'items-center') { frame.counterAxisAlignItems = 'CENTER'; setFrameCrossAlign(frame, 'CENTER'); handled.add(cls); continue; }
1236
+ if (utility === 'items-start') { frame.counterAxisAlignItems = 'MIN'; setFrameCrossAlign(frame, 'MIN'); handled.add(cls); continue; }
1237
+ if (utility === 'items-end') { frame.counterAxisAlignItems = 'MAX'; setFrameCrossAlign(frame, 'MAX'); handled.add(cls); continue; }
1238
+ // Figma has no parent-level STRETCH — emulated per-child via layoutAlign in applyChildProperties.
1239
+ if (utility === 'items-stretch') { frame.counterAxisAlignItems = 'MIN'; setFrameCrossAlign(frame, 'STRETCH'); handled.add(cls); continue; }
1602
1240
  if (utility === 'justify-center') { frame.primaryAxisAlignItems = 'CENTER'; handled.add(cls); continue; }
1603
1241
  if (utility === 'justify-between') { frame.primaryAxisAlignItems = 'SPACE_BETWEEN'; handled.add(cls); continue; }
1604
1242
  if (utility === 'justify-start') { frame.primaryAxisAlignItems = 'MIN'; handled.add(cls); continue; }
1605
1243
  if (utility === 'justify-end') { frame.primaryAxisAlignItems = 'MAX'; handled.add(cls); continue; }
1606
1244
  if (utility === 'mx-auto') {
1607
- // Figma no longer supports layoutAlign = CENTER. Keep as a no-op.
1245
+ markSelfAlignmentNode(frame, 'CENTER');
1246
+ markFullWidthNode(frame);
1608
1247
  handled.add(cls);
1609
1248
  continue;
1610
1249
  }
@@ -1716,12 +1355,28 @@ function applySemanticUtilitiesToFrame(
1716
1355
  handled.add(cls);
1717
1356
  continue;
1718
1357
  }
1358
+ if (utility === 'w-fit' || utility === 'w-auto' || utility === 'w-max') {
1359
+ // Hug content width — counterAxisSizingMode AUTO for vertical layout,
1360
+ // primaryAxisSizingMode AUTO for horizontal layout.
1361
+ try {
1362
+ if (frame.layoutMode === 'HORIZONTAL') {
1363
+ frame.primaryAxisSizingMode = 'AUTO';
1364
+ } else {
1365
+ frame.counterAxisSizingMode = 'AUTO';
1366
+ }
1367
+ } catch (_err) { /* ignore */ }
1368
+ handled.add(cls);
1369
+ continue;
1370
+ }
1719
1371
  if (utility === 'container') {
1720
1372
  markFullWidthNode(frame);
1721
1373
  handled.add(cls);
1722
1374
  continue;
1723
1375
  }
1724
- if (utility === 'h-full') {
1376
+ if (utility === 'h-full' || utility === 'h-screen' || utility === 'min-h-screen') {
1377
+ // h-screen / min-h-screen are treated like h-full: fill parent primary axis.
1378
+ // The Story Layout viewport-height pre-pass pins a fixed height upstream so
1379
+ // the fill cascades correctly.
1725
1380
  markFullHeightNode(frame);
1726
1381
  handled.add(cls);
1727
1382
  continue;
@@ -1745,9 +1400,20 @@ function applySemanticUtilitiesToFrame(
1745
1400
  }
1746
1401
  }
1747
1402
  } else {
1748
- const fraction = parseFractionToken(token);
1749
- if (fraction != null) {
1750
- markFractionWidthNode(frame, fraction);
1403
+ // Arbitrary percent — `w-[60%]` — same parent-relative semantics as
1404
+ // a w-1/2 / w-2/3 fraction. The post-append pipeline resizes the
1405
+ // child to fraction * parent content-width.
1406
+ const percentMatch = token.match(/^\[(\d+(?:\.\d+)?)%\]$/);
1407
+ if (percentMatch) {
1408
+ const pct = parseFloat(percentMatch[1]);
1409
+ if (Number.isFinite(pct) && pct >= 0) {
1410
+ markFractionWidthNode(frame, Math.max(0, Math.min(1, pct / 100)));
1411
+ }
1412
+ } else {
1413
+ const fraction = parseFractionToken(token);
1414
+ if (fraction != null) {
1415
+ markFractionWidthNode(frame, fraction);
1416
+ }
1751
1417
  }
1752
1418
  }
1753
1419
  handled.add(cls);
@@ -1758,6 +1424,7 @@ function applySemanticUtilitiesToFrame(
1758
1424
  const val = resolveSpacingToken(hMatch[1], spacingScale);
1759
1425
  if (val != null) {
1760
1426
  frame.resize(frame.width, val);
1427
+ markFixedHeightNode(frame);
1761
1428
  // Set sizing mode to FIXED to prevent auto-resizing
1762
1429
  if (frame.layoutMode === 'VERTICAL') {
1763
1430
  frame.primaryAxisSizingMode = 'FIXED';
@@ -1774,6 +1441,64 @@ function applySemanticUtilitiesToFrame(
1774
1441
  continue;
1775
1442
  }
1776
1443
 
1444
+ // min-h-N / min-h-[Npx] → minHeight floor. Used by shadcn Textarea
1445
+ // (`min-h-20`) and any other consumer who wants a height baseline.
1446
+ // `min-h-screen` is handled separately upstream as a FILL signal.
1447
+ const minHScaleMatch = utility.match(/^min-h-(\d+(?:\.\d+)?)$/);
1448
+ if (minHScaleMatch) {
1449
+ const val = resolveSpacingToken(minHScaleMatch[1], spacingScale);
1450
+ if (val != null && val > 0) {
1451
+ try {
1452
+ if ('minHeight' in frame) (frame as { minHeight: number }).minHeight = val;
1453
+ if (frame.height < val) frame.resize(frame.width, val);
1454
+ } catch (_e) { /* ignore */ }
1455
+ }
1456
+ handled.add(cls);
1457
+ continue;
1458
+ }
1459
+ const minHArbitraryMatch = utility.match(/^min-h-\[(\d+(?:\.\d+)?)(px|rem|em)?\]$/);
1460
+ if (minHArbitraryMatch) {
1461
+ let val = parseFloat(minHArbitraryMatch[1]);
1462
+ const unit = minHArbitraryMatch[2] || 'px';
1463
+ if (unit === 'rem' || unit === 'em') val *= 16;
1464
+ if (Number.isFinite(val) && val > 0) {
1465
+ try {
1466
+ if ('minHeight' in frame) (frame as { minHeight: number }).minHeight = val;
1467
+ if (frame.height < val) frame.resize(frame.width, val);
1468
+ } catch (_e) { /* ignore */ }
1469
+ }
1470
+ handled.add(cls);
1471
+ continue;
1472
+ }
1473
+
1474
+ // min-w-N / min-w-[Npx] — mirror of min-h-*.
1475
+ const minWScaleMatch = utility.match(/^min-w-(\d+(?:\.\d+)?)$/);
1476
+ if (minWScaleMatch) {
1477
+ const val = resolveSpacingToken(minWScaleMatch[1], spacingScale);
1478
+ if (val != null && val > 0) {
1479
+ try {
1480
+ if ('minWidth' in frame) (frame as { minWidth: number }).minWidth = val;
1481
+ if (frame.width < val) frame.resize(val, frame.height);
1482
+ } catch (_e) { /* ignore */ }
1483
+ }
1484
+ handled.add(cls);
1485
+ continue;
1486
+ }
1487
+ const minWArbitraryMatch = utility.match(/^min-w-\[(\d+(?:\.\d+)?)(px|rem|em)?\]$/);
1488
+ if (minWArbitraryMatch) {
1489
+ let val = parseFloat(minWArbitraryMatch[1]);
1490
+ const unit = minWArbitraryMatch[2] || 'px';
1491
+ if (unit === 'rem' || unit === 'em') val *= 16;
1492
+ if (Number.isFinite(val) && val > 0) {
1493
+ try {
1494
+ if ('minWidth' in frame) (frame as { minWidth: number }).minWidth = val;
1495
+ if (frame.width < val) frame.resize(val, frame.height);
1496
+ } catch (_e) { /* ignore */ }
1497
+ }
1498
+ handled.add(cls);
1499
+ continue;
1500
+ }
1501
+
1777
1502
  // size-X sets both width and height (Tailwind's size utility)
1778
1503
  const sizeMatch = utility.match(/^size-(.+)$/);
1779
1504
  if (sizeMatch) {
@@ -1781,6 +1506,7 @@ function applySemanticUtilitiesToFrame(
1781
1506
  if (val != null) {
1782
1507
  frame.resize(val, val);
1783
1508
  markFixedWidthNode(frame);
1509
+ markFixedHeightNode(frame);
1784
1510
  frame.primaryAxisSizingMode = 'FIXED';
1785
1511
  frame.counterAxisSizingMode = 'FIXED';
1786
1512
  }
@@ -1793,6 +1519,7 @@ function applySemanticUtilitiesToFrame(
1793
1519
  if (maxW != null) {
1794
1520
  frame.resize(maxW, frame.height);
1795
1521
  frame.counterAxisSizingMode = 'FIXED';
1522
+ markMaxWidthNode(frame, maxW);
1796
1523
  }
1797
1524
  handled.add(cls);
1798
1525
  continue;
@@ -1858,11 +1585,17 @@ export function applyTailwindStylesToFrame(
1858
1585
  ): TailwindStyle {
1859
1586
  const style = tailwindClassesToStyle(classes, 'default', colorGroup);
1860
1587
  const styleMap = getStyleMap();
1861
- applySemanticUtilitiesToFrame(frame, classes, radiusGroup);
1588
+ const semanticHandled = applySemanticUtilitiesToFrame(frame, classes, radiusGroup);
1862
1589
  let hasGradient = false;
1863
1590
 
1864
1591
  if (styleMap) {
1865
1592
  for (const cls of classes) {
1593
+ // Classes resolved by the semantic pass above (grid/flex/grid-cols-*/col-span-*/…)
1594
+ // must not be re-applied via per-class CSS declarations. Per-class CSS is isolated:
1595
+ // when `grid` is processed in isolation it sees `display: grid` with no
1596
+ // `grid-template-columns` and resets layoutMode to VERTICAL, undoing the semantic
1597
+ // pass that already set HORIZONTAL + WRAP for multi-column grids.
1598
+ if (semanticHandled.has(cls)) continue;
1866
1599
  const entryList = styleMap[cls];
1867
1600
  if (!entryList || entryList.length === 0) continue;
1868
1601
  const atom = parseUtilityClass(cls);
@@ -2010,6 +1743,18 @@ export function applyTailwindStylesToFrame(
2010
1743
  frame.opacity = style.opacity;
2011
1744
  }
2012
1745
 
1746
+ // Ring utilities (`ring-1`, `ring-2`, `ring-ring`, `ring-primary/50`,
1747
+ // `ring-offset-N`, …) are MARKED here at parse time and resolved later
1748
+ // by `applyRingIfPossible` in the post-append pipeline, when the host's
1749
+ // content children are all in place and its natural W × H is final.
1750
+ // Doing this eagerly here used to inflate Hug-sized parents during the
1751
+ // brief flex-child moment between `appendChild` and
1752
+ // `layoutPositioning = 'ABSOLUTE'`. See `src/layout/deferred-layout.ts`
1753
+ // (`applyRingIfPossible`) for why the deferred phase is the right
1754
+ // place for this concern.
1755
+ const ringInfo = getRingInfoFromClasses(classes, colorGroup);
1756
+ if (ringInfo) markRingNode(frame, ringInfo);
1757
+
2013
1758
  return style;
2014
1759
  }
2015
1760
 
@@ -2299,11 +2044,11 @@ export function tailwindForNode(node: SceneNode): string {
2299
2044
  if (frame.counterAxisAlignItems && mapAlign[frame.counterAxisAlignItems]) {
2300
2045
  classes.push(mapAlign[frame.counterAxisAlignItems]);
2301
2046
  }
2302
- if ((frame as any).layoutGrow === 1) classes.push('flex-1');
2303
- if ((frame as any).layoutAlign === 'STRETCH') classes.push('self-stretch');
2047
+ if (frame.layoutGrow === 1) classes.push('flex-1');
2048
+ if (frame.layoutAlign === 'STRETCH') classes.push('self-stretch');
2304
2049
  } else {
2305
- if (typeof (node as any).width === 'number') pushSizeClass(classes, 'w', (node as any).width);
2306
- if (typeof (node as any).height === 'number') pushSizeClass(classes, 'h', (node as any).height);
2050
+ if ('width' in node && typeof node.width === 'number') pushSizeClass(classes, 'w', node.width);
2051
+ if ('height' in node && typeof node.height === 'number') pushSizeClass(classes, 'h', node.height);
2307
2052
  }
2308
2053
 
2309
2054
  // Corner radius
@@ -2315,7 +2060,8 @@ export function tailwindForNode(node: SceneNode): string {
2315
2060
 
2316
2061
  // Fills -> background or text color
2317
2062
  try {
2318
- const paint = firstVisiblePaint((node as any).fills);
2063
+ const fills = 'fills' in node ? node.fills : undefined;
2064
+ const paint = firstVisiblePaint(Array.isArray(fills) ? fills : null);
2319
2065
  if (paint) {
2320
2066
  const rgb = paint.color;
2321
2067
  const token = nearestColorToken(rgb);
@@ -2325,35 +2071,38 @@ export function tailwindForNode(node: SceneNode): string {
2325
2071
  classes.push(opacityClass(paint.opacity));
2326
2072
  }
2327
2073
  }
2328
- } catch (e) { /* ignore */ }
2074
+ } catch (_e) { /* ignore */ }
2329
2075
 
2330
2076
  // Strokes -> border color/width
2331
2077
  try {
2332
- const paint = firstVisiblePaint((node as any).strokes);
2078
+ const strokes = 'strokes' in node ? node.strokes : undefined;
2079
+ const paint = firstVisiblePaint(Array.isArray(strokes) ? strokes : null);
2333
2080
  if (paint) {
2334
- const w = Math.round((node as any).strokeWeight || 1);
2081
+ const sw = 'strokeWeight' in node && typeof node.strokeWeight === 'number' ? node.strokeWeight : 1;
2082
+ const w = Math.round(sw || 1);
2335
2083
  classes.push('border');
2336
2084
  if (w !== 1) classes.push('border-' + twSpace(w));
2337
2085
  const token = nearestColorToken(paint.color);
2338
2086
  if (token) classes.push('border-' + token);
2339
2087
  }
2340
- } catch (e) { /* ignore */ }
2088
+ } catch (_e) { /* ignore */ }
2341
2089
 
2342
2090
  // Shadows (drop)
2343
2091
  try {
2344
- const eff = ((node as any).effects || []).find(
2092
+ const effects = 'effects' in node && Array.isArray(node.effects) ? node.effects : [];
2093
+ const eff = effects.find(
2345
2094
  (e: Effect) => e.type === 'DROP_SHADOW' && e.visible !== false,
2346
2095
  );
2347
2096
  const cl = mapShadow(eff);
2348
2097
  if (cl) classes.push(cl);
2349
- } catch (e) { /* ignore */ }
2098
+ } catch (_e) { /* ignore */ }
2350
2099
 
2351
2100
  // Typography for text
2352
2101
  if (node.type === 'TEXT') {
2353
2102
  const textNode = node as TextNode;
2354
2103
  if (textNode.fontSize) classes.push('text-[' + px(textNode.fontSize as number) + ']');
2355
- if (textNode.lineHeight && (textNode.lineHeight as any).unit === 'PIXELS') {
2356
- classes.push('leading-[' + px((textNode.lineHeight as any).value) + ']');
2104
+ if (textNode.lineHeight && typeof textNode.lineHeight === 'object' && textNode.lineHeight.unit === 'PIXELS') {
2105
+ classes.push('leading-[' + px(textNode.lineHeight.value) + ']');
2357
2106
  }
2358
2107
  if (textNode.fontName && (textNode.fontName as FontName).style && /bold/i.test((textNode.fontName as FontName).style)) {
2359
2108
  classes.push('font-bold');