inkhouse 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +201 -0
  2. package/bin/inkhouse.mjs +171 -0
  3. package/code.js +11802 -0
  4. package/manifest.json +30 -0
  5. package/package.json +45 -0
  6. package/scanner/blob-placement-regression.ts +132 -0
  7. package/scanner/class-collector.ts +69 -0
  8. package/scanner/cli.ts +336 -0
  9. package/scanner/component-scanner.ts +2876 -0
  10. package/scanner/css-patch-regression.ts +112 -0
  11. package/scanner/css-token-reader-regression.ts +92 -0
  12. package/scanner/css-token-reader.ts +477 -0
  13. package/scanner/font-style-resolver-regression.ts +32 -0
  14. package/scanner/index.ts +9 -0
  15. package/scanner/radial-gradient-regression.ts +53 -0
  16. package/scanner/style-map.ts +145 -0
  17. package/scanner/tailwind-parser.ts +644 -0
  18. package/scanner/transform-math-regression.ts +42 -0
  19. package/scanner/types.ts +298 -0
  20. package/src/blob-placement.ts +111 -0
  21. package/src/change-detection.ts +204 -0
  22. package/src/class-utils.ts +105 -0
  23. package/src/clip-path-decorative.ts +194 -0
  24. package/src/color-resolver.ts +98 -0
  25. package/src/colors.ts +196 -0
  26. package/src/component-defs.ts +54 -0
  27. package/src/component-gen.ts +561 -0
  28. package/src/component-lookup.ts +82 -0
  29. package/src/config.ts +115 -0
  30. package/src/design-system.ts +59 -0
  31. package/src/dev-server.ts +173 -0
  32. package/src/figma-globals.d.ts +3 -0
  33. package/src/font-style-resolver.ts +171 -0
  34. package/src/github.ts +1465 -0
  35. package/src/icon-builder.ts +607 -0
  36. package/src/image-cache.ts +22 -0
  37. package/src/inline-text.ts +271 -0
  38. package/src/layout-parser.ts +667 -0
  39. package/src/layout-utils.ts +155 -0
  40. package/src/main.ts +687 -0
  41. package/src/node-ir.ts +595 -0
  42. package/src/pack-provider.ts +148 -0
  43. package/src/packs.ts +126 -0
  44. package/src/radial-gradient.ts +84 -0
  45. package/src/render-context.ts +138 -0
  46. package/src/responsive-analyzer.ts +139 -0
  47. package/src/state-analyzer.ts +143 -0
  48. package/src/story-builder.ts +1706 -0
  49. package/src/story-layout.ts +38 -0
  50. package/src/tailwind.ts +2379 -0
  51. package/src/text-builder.ts +116 -0
  52. package/src/text-line.ts +42 -0
  53. package/src/token-source.ts +43 -0
  54. package/src/tokens.ts +717 -0
  55. package/src/transform-math.ts +44 -0
  56. package/src/ui-builder.ts +1996 -0
  57. package/src/utility-resolver.ts +125 -0
  58. package/src/variables.ts +1042 -0
  59. package/src/width-solver.ts +466 -0
  60. package/templates/patch-tokens-route.ts +165 -0
  61. package/templates/scan-components-route.ts +57 -0
  62. package/ui.html +1222 -0
@@ -0,0 +1,2379 @@
1
+ // --- Tailwind utilities: class parsing, style conversion, and codegen ---
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';
8
+ import {
9
+ parseUtilityClass,
10
+ hasResponsiveVariant as hasResponsiveVariantSemantic,
11
+ variantState as variantStateSemantic,
12
+ spacingValue,
13
+ resolveMaxWidth,
14
+ resolveRadius,
15
+ parseLength as parseLengthSemantic,
16
+ } from './utility-resolver';
17
+ import { parseRadialAnchorFromUtility, radialGradientTransformFromAnchor, type RadialAnchor } from './radial-gradient';
18
+ import { rotationTransformAroundPointRadians } from './transform-math';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface TailwindStyle {
25
+ bg: string | null;
26
+ bgToken: string | null;
27
+ bgOpacity?: number;
28
+ text: string | null;
29
+ textToken: string | null;
30
+ border: string | null;
31
+ borderToken: string | null;
32
+ opacity: number | null;
33
+ underline?: boolean;
34
+ }
35
+
36
+ export interface FrameProperties {
37
+ cornerRadius: number;
38
+ paddingTop: number;
39
+ paddingBottom: number;
40
+ paddingLeft: number;
41
+ paddingRight: number;
42
+ background: string | null;
43
+ backgroundOpacity?: number;
44
+ borderColor: string | null;
45
+ borderWidth: number;
46
+ }
47
+
48
+ const FALLBACK_COLOR_TOKENS: Record<string, string> = {
49
+ white: '#ffffff',
50
+ black: '#000000',
51
+ transparent: '#000000', // parsed color; opacity: 0 is applied separately via resolveColorTokenValue
52
+ 'gray-900': '#111827',
53
+ 'gray-800': '#1f2937',
54
+ 'gray-700': '#374151',
55
+ 'gray-600': '#4b5563',
56
+ 'gray-500': '#6b7280',
57
+ 'gray-400': '#9ca3af',
58
+ 'gray-300': '#d1d5db',
59
+ 'gray-200': '#e5e7eb',
60
+ 'gray-100': '#f3f4f6',
61
+ 'slate-900': '#0f172a',
62
+ 'slate-800': '#1e293b',
63
+ 'slate-700': '#334155',
64
+ 'slate-600': '#475569',
65
+ 'slate-500': '#64748b',
66
+ 'slate-400': '#94a3b8',
67
+ 'slate-300': '#cbd5e1',
68
+ 'slate-200': '#e2e8f0',
69
+ 'slate-100': '#f1f5f9',
70
+ };
71
+
72
+ type StyleMapEntry = { declarations: Record<string, string>; media?: string };
73
+ type StyleMap = Record<string, StyleMapEntry[]>;
74
+
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
+
92
+ function getStyleMap(): StyleMap | null {
93
+ const defs: any = COMPONENT_DEFS as any;
94
+ return defs && defs.styleMap ? (defs.styleMap as StyleMap) : null;
95
+ }
96
+
97
+ 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
+ }
652
+ }
653
+
654
+ function cssVarToToken(value: string): string | null {
655
+ const varMatch = value.match(/var\\(--([^)]+)\\)/);
656
+ if (!varMatch) return null;
657
+ const token = varMatch[1].replace(/^color-/, '');
658
+ return token || null;
659
+ }
660
+
661
+ function resolveSpacingToken(token: string, spacingScale: Record<string, number>): number | null {
662
+ if (!token) return null;
663
+ if (token.startsWith('[') && token.endsWith(']')) {
664
+ const inner = token.slice(1, -1);
665
+ return parseLengthSemantic(inner);
666
+ }
667
+ return spacingValue(token, spacingScale);
668
+ }
669
+
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
+
692
+ function parseOpacityToken(token: string): number | null {
693
+ if (!token) return null;
694
+ let raw = token;
695
+ if (raw.startsWith('[') && raw.endsWith(']')) {
696
+ raw = raw.slice(1, -1);
697
+ }
698
+ const percent = raw.endsWith('%');
699
+ const num = parseFloat(percent ? raw.slice(0, -1) : raw);
700
+ if (Number.isNaN(num)) return null;
701
+ const value = percent ? num / 100 : (num > 1 ? num / 100 : num);
702
+ return Math.max(0, Math.min(1, value));
703
+ }
704
+
705
+ function applyCssDeclarations(style: TailwindStyle, declarations: Record<string, string>): void {
706
+ for (const [prop, rawValue] of Object.entries(declarations)) {
707
+ const value = rawValue.trim();
708
+ if (prop === 'background-color') {
709
+ if (value === 'transparent') {
710
+ style.bg = null;
711
+ style.bgToken = null;
712
+ continue;
713
+ }
714
+ style.bg = value;
715
+ const token = cssVarToToken(value);
716
+ if (token) style.bgToken = token;
717
+ } else if (prop === 'color') {
718
+ style.text = value;
719
+ const token = cssVarToToken(value);
720
+ if (token) style.textToken = token;
721
+ } else if (prop === 'border-color') {
722
+ style.border = value;
723
+ const token = cssVarToToken(value);
724
+ if (token) style.borderToken = token;
725
+ } else if (prop === 'opacity') {
726
+ const num = parseFloat(value);
727
+ if (!Number.isNaN(num)) style.opacity = num > 1 ? num / 100 : num;
728
+ } else if (prop === 'text-decoration-line' && value.includes('underline')) {
729
+ style.underline = true;
730
+ }
731
+ }
732
+ }
733
+
734
+ function parseCssLength(value: string): number | null {
735
+ const trimmed = value.trim();
736
+ if (trimmed === '0') return 0;
737
+ const pxMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)px$/);
738
+ if (pxMatch) return parseFloat(pxMatch[1]);
739
+ const remMatch = trimmed.match(/^(-?\d+(?:\.\d+)?)rem$/);
740
+ if (remMatch) return parseFloat(remMatch[1]) * 16;
741
+ return null;
742
+ }
743
+
744
+ function alignSelfToLayoutAlign(value: string): 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | null {
745
+ if (value === 'center') return 'CENTER';
746
+ if (value === 'flex-start' || value === 'start') return 'MIN';
747
+ if (value === 'flex-end' || value === 'end') return 'MAX';
748
+ if (value === 'stretch') return 'STRETCH';
749
+ if (value === 'baseline') return 'MIN';
750
+ return null;
751
+ }
752
+
753
+ function parseFlexShorthand(value: string): { grow: number | null; basis: number | null } {
754
+ const trimmed = value.trim();
755
+ if (!trimmed) return { grow: null, basis: null };
756
+ if (trimmed === 'none') return { grow: 0, basis: null };
757
+ if (trimmed === 'auto') return { grow: 1, basis: null };
758
+ if (trimmed === 'initial') return { grow: 0, basis: null };
759
+
760
+ const parts = trimmed.split(/\s+/);
761
+ if (parts.length === 1) {
762
+ const grow = parseFloat(parts[0]);
763
+ return { grow: Number.isFinite(grow) ? grow : null, basis: null };
764
+ }
765
+
766
+ const grow = parseFloat(parts[0]);
767
+ const basisRaw = parts.length >= 3 ? parts[2] : '';
768
+ const basis = parseCssLength(basisRaw);
769
+ return { grow: Number.isFinite(grow) ? grow : null, basis };
770
+ }
771
+
772
+ function getBorderSideWeights(frame: FrameNode): { top: number; right: number; bottom: number; left: number } {
773
+ const target = frame as any;
774
+ const hasStroke = Array.isArray(frame.strokes) && frame.strokes.length > 0;
775
+ const baseWeight = hasStroke && Number.isFinite(frame.strokeWeight) ? frame.strokeWeight : 0;
776
+ 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,
781
+ };
782
+ }
783
+
784
+ 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);
802
+ }
803
+
804
+ function setDirectionalBorder(frame: FrameNode, utility: string, weight: number): boolean {
805
+ if (!Number.isFinite(weight) || weight < 0) return false;
806
+ const current = getBorderSideWeights(frame);
807
+ const next = {
808
+ top: current.top,
809
+ right: current.right,
810
+ bottom: current.bottom,
811
+ left: current.left,
812
+ };
813
+ if (utility === 'border-t') next.top = weight;
814
+ else if (utility === 'border-r') next.right = weight;
815
+ else if (utility === 'border-b') next.bottom = weight;
816
+ else if (utility === 'border-l') next.left = weight;
817
+ else if (utility === 'border-x') {
818
+ next.left = weight;
819
+ next.right = weight;
820
+ } else if (utility === 'border-y') {
821
+ next.top = weight;
822
+ next.bottom = weight;
823
+ } else {
824
+ return false;
825
+ }
826
+ setBorderSideWeights(frame, next);
827
+ return true;
828
+ }
829
+
830
+ export function applyBorderWidthUtilities(frame: FrameNode, classes: string[]): void {
831
+ let touched = false;
832
+ let next = { top: 0, right: 0, bottom: 0, left: 0 };
833
+ for (const cls of classes) {
834
+ const atom = parseUtilityClass(cls);
835
+ if (!atom.utility) continue;
836
+ if (!shouldApplyAtom(atom, 'default')) continue;
837
+
838
+ const utility = atom.utility;
839
+ if (utility === 'border') {
840
+ next = { top: 1, right: 1, bottom: 1, left: 1 };
841
+ touched = true;
842
+ continue;
843
+ }
844
+ const borderWidthMatch = utility.match(/^border-(\d+)$/);
845
+ if (borderWidthMatch) {
846
+ const weight = parseFloat(borderWidthMatch[1]);
847
+ if (Number.isFinite(weight) && weight >= 0) {
848
+ next = { top: weight, right: weight, bottom: weight, left: weight };
849
+ touched = true;
850
+ }
851
+ continue;
852
+ }
853
+ if (utility === 'border-t') { next.top = 1; touched = true; continue; }
854
+ if (utility === 'border-r') { next.right = 1; touched = true; continue; }
855
+ if (utility === 'border-b') { next.bottom = 1; touched = true; continue; }
856
+ if (utility === 'border-l') { next.left = 1; touched = true; continue; }
857
+ if (utility === 'border-x') { next.left = 1; next.right = 1; touched = true; continue; }
858
+ if (utility === 'border-y') { next.top = 1; next.bottom = 1; touched = true; continue; }
859
+ const directionalBorderWidthMatch = utility.match(/^(border-(?:t|r|b|l|x|y))-(\d+)$/);
860
+ if (directionalBorderWidthMatch) {
861
+ const borderWeight = parseFloat(directionalBorderWidthMatch[2]);
862
+ if (!Number.isFinite(borderWeight) || borderWeight < 0) continue;
863
+ const directionalUtility = directionalBorderWidthMatch[1];
864
+ if (directionalUtility === 'border-t') next.top = borderWeight;
865
+ else if (directionalUtility === 'border-r') next.right = borderWeight;
866
+ else if (directionalUtility === 'border-b') next.bottom = borderWeight;
867
+ else if (directionalUtility === 'border-l') next.left = borderWeight;
868
+ else if (directionalUtility === 'border-x') { next.left = borderWeight; next.right = borderWeight; }
869
+ else if (directionalUtility === 'border-y') { next.top = borderWeight; next.bottom = borderWeight; }
870
+ touched = true;
871
+ continue;
872
+ }
873
+ }
874
+ if (touched) {
875
+ setBorderSideWeights(frame, next);
876
+ }
877
+ }
878
+
879
+ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<string, string>): void {
880
+ let autoMarginLeft = false;
881
+ let autoMarginRight = false;
882
+ let autoMarginInline = false;
883
+
884
+ for (const [prop, rawValue] of Object.entries(declarations)) {
885
+ const value = rawValue.trim();
886
+ if (prop === 'display' && (value === 'flex' || value === 'inline-flex')) {
887
+ frame.layoutMode = 'HORIZONTAL';
888
+ continue;
889
+ }
890
+ if (prop === 'display' && value === 'grid') {
891
+ frame.layoutMode = 'HORIZONTAL';
892
+ if ((frame as any).layoutWrap !== undefined) {
893
+ (frame as any).layoutWrap = 'WRAP';
894
+ }
895
+ continue;
896
+ }
897
+ if (prop === 'flex-direction') {
898
+ frame.layoutMode = value === 'column' || value === 'column-reverse' ? 'VERTICAL' : 'HORIZONTAL';
899
+ continue;
900
+ }
901
+ if (prop === 'flex-wrap' && (frame as any).layoutWrap !== undefined) {
902
+ (frame as any).layoutWrap = value === 'wrap' || value === 'wrap-reverse' ? 'WRAP' : 'NO_WRAP';
903
+ continue;
904
+ }
905
+ if (prop === 'grid-template-columns') {
906
+ const match = value.match(/repeat\((\d+),/);
907
+ if (match) {
908
+ markGridColumnsNode(frame, parseInt(match[1], 10));
909
+ }
910
+ continue;
911
+ }
912
+ if (prop === 'gap' || prop === 'column-gap' || prop === 'row-gap') {
913
+ const len = parseCssLength(value);
914
+ if (len != null) frame.itemSpacing = len;
915
+ continue;
916
+ }
917
+ if (prop === 'justify-content') {
918
+ if (value === 'center') frame.primaryAxisAlignItems = 'CENTER';
919
+ if (value === 'flex-start') frame.primaryAxisAlignItems = 'MIN';
920
+ if (value === 'flex-end') frame.primaryAxisAlignItems = 'MAX';
921
+ if (value === 'space-between') frame.primaryAxisAlignItems = 'SPACE_BETWEEN';
922
+ if (value === 'space-around' || value === 'space-evenly') frame.primaryAxisAlignItems = 'SPACE_BETWEEN';
923
+ continue;
924
+ }
925
+ 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';
930
+ continue;
931
+ }
932
+ if (prop === 'align-self') {
933
+ const align = alignSelfToLayoutAlign(value);
934
+ if (align) markSelfAlignmentNode(frame, align);
935
+ continue;
936
+ }
937
+ if (prop === 'flex-grow') {
938
+ const grow = parseFloat(value);
939
+ if (Number.isFinite(grow)) frame.layoutGrow = grow;
940
+ continue;
941
+ }
942
+ if (prop === 'flex-basis') {
943
+ const len = parseCssLength(value);
944
+ if (len != null) markFlexBasisNode(frame, len);
945
+ continue;
946
+ }
947
+ if (prop === 'flex') {
948
+ const parsed = parseFlexShorthand(value);
949
+ if (parsed.grow != null) frame.layoutGrow = parsed.grow;
950
+ if (parsed.basis != null) markFlexBasisNode(frame, parsed.basis);
951
+ continue;
952
+ }
953
+ if (prop === 'padding') {
954
+ const parts = value.split(/\\s+/);
955
+ const nums = parts.map(parseCssLength);
956
+ if (nums.every(n => n != null)) {
957
+ const [t, r, b, l] = nums as number[];
958
+ if (parts.length === 1) {
959
+ frame.paddingTop = frame.paddingRight = frame.paddingBottom = frame.paddingLeft = t;
960
+ } else if (parts.length === 2) {
961
+ frame.paddingTop = frame.paddingBottom = t;
962
+ frame.paddingLeft = frame.paddingRight = r;
963
+ } else if (parts.length === 3) {
964
+ frame.paddingTop = t;
965
+ frame.paddingLeft = frame.paddingRight = r;
966
+ frame.paddingBottom = b;
967
+ } else if (parts.length === 4) {
968
+ frame.paddingTop = t;
969
+ frame.paddingRight = r;
970
+ frame.paddingBottom = b;
971
+ frame.paddingLeft = l;
972
+ }
973
+ }
974
+ continue;
975
+ }
976
+ if (prop === 'padding-left') {
977
+ const len = parseCssLength(value);
978
+ if (len != null) frame.paddingLeft = len;
979
+ continue;
980
+ }
981
+ if (prop === 'padding-right') {
982
+ const len = parseCssLength(value);
983
+ if (len != null) frame.paddingRight = len;
984
+ continue;
985
+ }
986
+ if (prop === 'padding-top') {
987
+ const len = parseCssLength(value);
988
+ if (len != null) frame.paddingTop = len;
989
+ continue;
990
+ }
991
+ if (prop === 'padding-bottom') {
992
+ const len = parseCssLength(value);
993
+ if (len != null) frame.paddingBottom = len;
994
+ continue;
995
+ }
996
+ if (prop === 'width') {
997
+ if (value === '100%') {
998
+ markFullWidthNode(frame);
999
+ continue;
1000
+ }
1001
+ const len = parseCssLength(value);
1002
+ if (len != null) {
1003
+ frame.resize(len, frame.height);
1004
+ markFixedWidthNode(frame);
1005
+ if (frame.layoutMode === 'HORIZONTAL') {
1006
+ frame.primaryAxisSizingMode = 'FIXED';
1007
+ } else if (frame.layoutMode === 'VERTICAL') {
1008
+ frame.counterAxisSizingMode = 'FIXED';
1009
+ }
1010
+ }
1011
+ continue;
1012
+ }
1013
+ if (prop === 'height') {
1014
+ const len = parseCssLength(value);
1015
+ if (len != null) {
1016
+ frame.resize(frame.width, len);
1017
+ if (frame.layoutMode === 'VERTICAL') {
1018
+ frame.primaryAxisSizingMode = 'FIXED';
1019
+ } else if (frame.layoutMode === 'HORIZONTAL') {
1020
+ frame.counterAxisSizingMode = 'FIXED';
1021
+ }
1022
+ }
1023
+ if (value === '100%') markFullHeightNode(frame);
1024
+ continue;
1025
+ }
1026
+ if (prop === 'max-width') {
1027
+ const len = parseCssLength(value);
1028
+ if (len != null) {
1029
+ frame.resize(len, frame.height);
1030
+ frame.counterAxisSizingMode = 'FIXED';
1031
+ }
1032
+ continue;
1033
+ }
1034
+ if (prop === 'border-width') {
1035
+ const len = parseCssLength(value);
1036
+ if (len != null) {
1037
+ frame.strokeWeight = len;
1038
+ }
1039
+ continue;
1040
+ }
1041
+ if (prop === 'border-top-width' || prop === 'border-right-width' || prop === 'border-bottom-width' || prop === 'border-left-width') {
1042
+ const len = parseCssLength(value);
1043
+ if (len != null) {
1044
+ const utility = prop === 'border-top-width'
1045
+ ? 'border-t'
1046
+ : prop === 'border-right-width'
1047
+ ? 'border-r'
1048
+ : prop === 'border-bottom-width'
1049
+ ? 'border-b'
1050
+ : 'border-l';
1051
+ setDirectionalBorder(frame, utility, len);
1052
+ }
1053
+ continue;
1054
+ }
1055
+ if (prop === 'min-width') {
1056
+ const len = parseCssLength(value);
1057
+ if (len != null) markMinWidthNode(frame, len);
1058
+ continue;
1059
+ }
1060
+ if (prop === 'margin-inline' && value === 'auto') {
1061
+ autoMarginInline = true;
1062
+ continue;
1063
+ }
1064
+ if (prop === 'margin-left' && value === 'auto') {
1065
+ autoMarginLeft = true;
1066
+ continue;
1067
+ }
1068
+ if (prop === 'margin-right' && value === 'auto') {
1069
+ autoMarginRight = true;
1070
+ continue;
1071
+ }
1072
+ if (prop === 'position' && value === 'absolute') {
1073
+ markAbsoluteNode(frame);
1074
+ continue;
1075
+ }
1076
+ }
1077
+
1078
+ if (autoMarginInline || (autoMarginLeft && autoMarginRight)) {
1079
+ markSelfAlignmentNode(frame, 'CENTER');
1080
+ } else if (autoMarginLeft) {
1081
+ markSelfAlignmentNode(frame, 'MAX');
1082
+ } else if (autoMarginRight) {
1083
+ markSelfAlignmentNode(frame, 'MIN');
1084
+ }
1085
+ }
1086
+
1087
+ // ---------------------------------------------------------------------------
1088
+ // Section 1 – Tailwind-to-Style conversion (lines 194-425 of code.js)
1089
+ // ---------------------------------------------------------------------------
1090
+
1091
+ /**
1092
+ * Extract color token name from a Tailwind class
1093
+ * e.g., "bg-primary" -> "primary", "text-primary-foreground" -> "primary-foreground"
1094
+ */
1095
+ export function extractColorToken(cls: string): string | null {
1096
+ // Handle bg-* classes
1097
+ if (cls.startsWith('bg-')) {
1098
+ const token = cls.substring(3);
1099
+ // Skip non-color values like bg-transparent, bg-gradient, bg-[...]
1100
+ if (token === 'transparent' || token === 'current' || token.startsWith('[')) return null;
1101
+ return token;
1102
+ }
1103
+ // Handle text-* classes (only color ones, not text-sm, text-center, etc.)
1104
+ if (cls.startsWith('text-')) {
1105
+ const token = cls.substring(5);
1106
+ // Skip non-color text utilities
1107
+ const nonColorPrefixes = [
1108
+ 'xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', '8xl', '9xl',
1109
+ 'left', 'center', 'right', 'justify', 'start', 'end', 'wrap', 'nowrap', 'balance', 'pretty',
1110
+ ];
1111
+ if (nonColorPrefixes.indexOf(token) !== -1 || token.startsWith('[')) return null;
1112
+ return token;
1113
+ }
1114
+ // Handle border-* classes
1115
+ if (cls.startsWith('border-')) {
1116
+ const token = cls.substring(7);
1117
+ // Skip non-color border utilities
1118
+ const nonColorBorder = [
1119
+ '0', '2', '4', '8', 't', 'r', 'b', 'l', 'x', 'y',
1120
+ 'solid', 'dashed', 'dotted', 'double', 'hidden', 'none', 'collapse', 'separate', 'transparent',
1121
+ ];
1122
+ if (nonColorBorder.indexOf(token) !== -1 || token.startsWith('[')) return null;
1123
+ return token;
1124
+ }
1125
+ return null;
1126
+ }
1127
+
1128
+ function applySemanticStyleForUtility(
1129
+ style: TailwindStyle,
1130
+ utility: string,
1131
+ colorGroup: Record<string, string>,
1132
+ ): boolean {
1133
+ if (!utility) return false;
1134
+
1135
+ if (utility === 'bg-transparent') {
1136
+ style.bg = null;
1137
+ style.bgToken = null;
1138
+ return true;
1139
+ }
1140
+
1141
+ if (utility === 'underline') {
1142
+ style.underline = true;
1143
+ return true;
1144
+ }
1145
+ if (utility === 'no-underline') {
1146
+ style.underline = false;
1147
+ return true;
1148
+ }
1149
+
1150
+ if (utility.startsWith('opacity-')) {
1151
+ const opacityToken = utility.slice('opacity-'.length);
1152
+ const opacityValue = parseOpacityToken(opacityToken);
1153
+ if (opacityValue != null) {
1154
+ style.opacity = opacityValue;
1155
+ return true;
1156
+ }
1157
+ }
1158
+
1159
+ const token = extractColorToken(utility);
1160
+ if (!token) return false;
1161
+
1162
+ const opacityMatch = token.match(/^(.+)\/(\d+)$/);
1163
+ const actualToken = opacityMatch ? opacityMatch[1] : token;
1164
+ const colorOpacity = opacityMatch ? parseInt(opacityMatch[2], 10) / 100 : null;
1165
+ const palette = getPaletteTokens();
1166
+ const color = colorGroup[actualToken] || palette[actualToken] || FALLBACK_COLOR_TOKENS[actualToken];
1167
+ if (!color) return false;
1168
+
1169
+ if (utility.startsWith('bg-')) {
1170
+ style.bg = color;
1171
+ style.bgToken = actualToken;
1172
+ if (colorOpacity !== null) style.bgOpacity = colorOpacity;
1173
+ return true;
1174
+ }
1175
+ if (utility.startsWith('text-')) {
1176
+ style.text = color;
1177
+ style.textToken = actualToken;
1178
+ return true;
1179
+ }
1180
+ if (utility.startsWith('border-')) {
1181
+ style.border = color;
1182
+ style.borderToken = actualToken;
1183
+ return true;
1184
+ }
1185
+
1186
+ return false;
1187
+ }
1188
+
1189
+ function resolveColorTokenValue(
1190
+ token: string,
1191
+ colorGroup: Record<string, string>,
1192
+ ): { value: string; opacity?: number } | null {
1193
+ if (!token) return null;
1194
+ // Special-case: transparent always resolves to black at opacity 0
1195
+ if (token === 'transparent') return { value: '#000000', opacity: 0 };
1196
+ let raw = token;
1197
+ let opacity: number | undefined;
1198
+ if (raw.includes('/')) {
1199
+ const [base, alpha] = raw.split('/');
1200
+ raw = base;
1201
+ const parsed = parseOpacityToken(alpha);
1202
+ if (parsed != null) opacity = parsed;
1203
+ }
1204
+ if (raw.startsWith('[') && raw.endsWith(']')) {
1205
+ raw = raw.slice(1, -1);
1206
+ }
1207
+ if (raw.startsWith('#') || raw.startsWith('rgb') || raw.startsWith('hsl') || raw.startsWith('oklch(')) {
1208
+ return { value: raw, opacity };
1209
+ }
1210
+ const palette = getPaletteTokens();
1211
+ const value = colorGroup[raw] || palette[raw] || FALLBACK_COLOR_TOKENS[raw];
1212
+ if (!value) return null;
1213
+ return { value, opacity };
1214
+ }
1215
+
1216
+ /**
1217
+ * Convert Tailwind gradient direction to Figma gradientTransform matrix.
1218
+ * Tailwind directions: t, tr, r, br, b, bl, l, tl
1219
+ * Default Figma gradient goes left-to-right (equivalent to 'r').
1220
+ * We rotate around center (0.5, 0.5) to match CSS gradient angles.
1221
+ */
1222
+ function directionToGradientTransform(
1223
+ direction: string | undefined
1224
+ ): [[number, number, number], [number, number, number]] {
1225
+ // Default to left-to-right (to-r) = identity matrix
1226
+ if (!direction) return [[1, 0, 0], [0, 1, 0]];
1227
+
1228
+ // Map direction to rotation angle in radians in Figma's y-down coordinate space.
1229
+ // Positive CSS angles are clockwise in y-up space, so vertical components must
1230
+ // be sign-adjusted here to avoid mirrored gradients.
1231
+ const angleMap: Record<string, number> = {
1232
+ 'r': 0, // 0° - left to right (default)
1233
+ 'br': -Math.PI / 4, // 45° - top-left to bottom-right
1234
+ 'b': -Math.PI / 2, // 90° - top to bottom
1235
+ 'bl': -(3 * Math.PI) / 4, // 135° - top-right to bottom-left
1236
+ 'l': Math.PI, // 180° - right to left
1237
+ 'tl': (3 * Math.PI) / 4, // 225° - bottom-right to top-left
1238
+ 't': Math.PI / 2, // 270° - bottom to top
1239
+ 'tr': Math.PI / 4, // 315° - bottom-left to top-right
1240
+ };
1241
+
1242
+ const angle = angleMap[direction];
1243
+ if (angle == null) return [[1, 0, 0], [0, 1, 0]];
1244
+ return rotationTransformAroundPointRadians(angle, 0.5, 0.5);
1245
+ }
1246
+
1247
+ function gradientFromClasses(
1248
+ classes: string[],
1249
+ colorGroup: Record<string, string>,
1250
+ ): { from: RGB; to: RGB; via?: RGB; direction?: string; type?: 'linear' | 'radial'; opacity?: number; radialAnchor?: RadialAnchor } | null {
1251
+ let direction: string | undefined;
1252
+ let gradientType: 'linear' | 'radial' = 'linear';
1253
+ let radialAnchor: RadialAnchor | undefined;
1254
+ let fromToken: string | undefined;
1255
+ let viaToken: string | undefined;
1256
+ let toToken: string | undefined;
1257
+ let opacity: number | undefined;
1258
+
1259
+ for (const cls of classes) {
1260
+ const atom = parseUtilityClass(cls);
1261
+ if (!atom.utility) continue;
1262
+ if (!shouldApplyAtom(atom, 'default')) continue;
1263
+ const utility = atom.utility;
1264
+ if (utility.startsWith('bg-gradient-to-')) {
1265
+ direction = utility.replace('bg-gradient-to-', '');
1266
+ gradientType = 'linear';
1267
+ } else if (utility.startsWith('bg-linear-to-')) {
1268
+ direction = utility.replace('bg-linear-to-', '');
1269
+ gradientType = 'linear';
1270
+ } else if (utility === 'bg-radial' || utility.startsWith('bg-radial-')) {
1271
+ gradientType = 'radial';
1272
+ const anchor = parseRadialAnchorFromUtility(utility);
1273
+ if (anchor) radialAnchor = anchor;
1274
+ } else if (utility.startsWith('from-')) {
1275
+ fromToken = utility.slice(5);
1276
+ } else if (utility.startsWith('via-')) {
1277
+ viaToken = utility.slice(4);
1278
+ } else if (utility.startsWith('to-')) {
1279
+ toToken = utility.slice(3);
1280
+ } else if (utility.startsWith('opacity-')) {
1281
+ const op = parseOpacityToken(utility.slice('opacity-'.length));
1282
+ if (op != null) opacity = op;
1283
+ }
1284
+ }
1285
+
1286
+ if (!fromToken || !toToken) return null;
1287
+ const fromValue = resolveColorTokenValue(fromToken, colorGroup);
1288
+ const toValue = resolveColorTokenValue(toToken, colorGroup);
1289
+ if (!fromValue || !toValue) return null;
1290
+
1291
+ const from = parseColor(fromValue.value);
1292
+ const to = parseColor(toValue.value);
1293
+ if (fromValue.opacity != null) from.a = fromValue.opacity;
1294
+ if (toValue.opacity != null) to.a = toValue.opacity;
1295
+
1296
+ // Parse optional via color for 3-color gradients
1297
+ let via: RGB | undefined;
1298
+ if (viaToken) {
1299
+ const viaValue = resolveColorTokenValue(viaToken, colorGroup);
1300
+ if (viaValue) {
1301
+ via = parseColor(viaValue.value);
1302
+ if (viaValue.opacity != null) via.a = viaValue.opacity;
1303
+ }
1304
+ }
1305
+
1306
+ return { from, to, via, direction, type: gradientType, opacity, radialAnchor };
1307
+ }
1308
+
1309
+ /**
1310
+ * Parse Tailwind classes and convert to style object
1311
+ */
1312
+ export function tailwindClassesToStyle(
1313
+ classes: string[],
1314
+ state: string,
1315
+ colorGroup: Record<string, string>,
1316
+ ): TailwindStyle {
1317
+ const style: TailwindStyle = {
1318
+ bg: null,
1319
+ bgToken: null,
1320
+ text: null,
1321
+ textToken: null,
1322
+ border: null,
1323
+ borderToken: null,
1324
+ opacity: null,
1325
+ };
1326
+ const styleMap = getStyleMap();
1327
+
1328
+ for (const cls of classes) {
1329
+ const atom = parseUtilityClass(cls);
1330
+ if (!atom.utility) continue;
1331
+ if (!shouldApplyAtom(atom, state)) continue;
1332
+
1333
+ if (applySemanticStyleForUtility(style, atom.utility, colorGroup)) continue;
1334
+
1335
+ if (styleMap) {
1336
+ const entryList = styleMap[cls];
1337
+ if (!entryList || entryList.length === 0) continue;
1338
+ for (const entry of entryList) {
1339
+ applyCssDeclarations(style, entry.declarations);
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ return style;
1345
+ }
1346
+
1347
+ /**
1348
+ * Get button style from COMPONENT_DEFS
1349
+ */
1350
+ export function getButtonStyleFromDefs(
1351
+ variant: string,
1352
+ state: string,
1353
+ colorGroup: Record<string, string>,
1354
+ ): TailwindStyle | null {
1355
+ const def = getComponentDef('Button');
1356
+ if (!def || def.type !== 'cva') {
1357
+ // Fallback to null if no definition found
1358
+ return null;
1359
+ }
1360
+
1361
+ // Get all classes for this variant
1362
+ let classes: string[] = def.baseClasses.slice();
1363
+ if (def.variantClasses && def.variantClasses.variant && def.variantClasses.variant[variant]) {
1364
+ classes = classes.concat(def.variantClasses.variant[variant]);
1365
+ }
1366
+
1367
+ // Convert to style for the requested state
1368
+ return tailwindClassesToStyle(classes, state, colorGroup);
1369
+ }
1370
+
1371
+ function applySemanticUtilitiesToFrame(
1372
+ frame: FrameNode,
1373
+ classes: string[],
1374
+ radiusGroup: Record<string, string> | null,
1375
+ ): Set<string> {
1376
+ const handled = new Set<string>();
1377
+ const spacingScale = (COMPONENT_DEFS && COMPONENT_DEFS.spacingScale) ? COMPONENT_DEFS.spacingScale : {};
1378
+ let gap: number | null = null;
1379
+ let gapX: number | null = null;
1380
+ let gapY: number | null = null;
1381
+
1382
+ for (const cls of classes) {
1383
+ const atom = parseUtilityClass(cls);
1384
+ if (!atom.utility) continue;
1385
+
1386
+ const utility = atom.utility;
1387
+
1388
+ const gridColsMatch = utility.match(/^grid-cols-(\d+)$/);
1389
+ if (gridColsMatch) {
1390
+ markGridColumnsNode(frame, parseInt(gridColsMatch[1], 10));
1391
+ handled.add(cls);
1392
+ continue;
1393
+ }
1394
+
1395
+ const colSpanMatch = utility.match(/^col-span-(\d+|full)$/);
1396
+ if (colSpanMatch) {
1397
+ // 'full' is stored as 999 and clamped to actual cols when applied
1398
+ const span = colSpanMatch[1] === 'full' ? 999 : parseInt(colSpanMatch[1], 10);
1399
+ markColSpanNode(frame, span);
1400
+ handled.add(cls);
1401
+ continue;
1402
+ }
1403
+
1404
+ if (!shouldApplyAtom(atom, 'default')) continue;
1405
+
1406
+ if (utility === 'flex' || utility === 'inline-flex' || utility === 'flex-row') {
1407
+ frame.layoutMode = 'HORIZONTAL';
1408
+ handled.add(cls);
1409
+ continue;
1410
+ }
1411
+ if (utility === 'grid' || utility === 'inline-grid') {
1412
+ const hasColumns = classes.some((c: string) => /^grid-cols-\d+$/.test(c));
1413
+ if (hasColumns) {
1414
+ frame.layoutMode = 'HORIZONTAL';
1415
+ if ((frame as any).layoutWrap !== undefined) {
1416
+ (frame as any).layoutWrap = 'WRAP';
1417
+ }
1418
+ } else {
1419
+ // Single-column implicit grid → VERTICAL; children stretch to fill (CSS default align-items: stretch)
1420
+ frame.layoutMode = 'VERTICAL';
1421
+ CSS_GRID_VERTICAL_FRAMES.add(frame);
1422
+ }
1423
+ handled.add(cls);
1424
+ continue;
1425
+ }
1426
+ if (utility === 'absolute') {
1427
+ markAbsoluteNode(frame);
1428
+ handled.add(cls);
1429
+ continue;
1430
+ }
1431
+
1432
+ // Handle inset-{n} → position at (n,n) and fill parent minus 2*n on each axis
1433
+ const insetMatch = utility.match(/^inset-(\d+(?:\.\d+)?)$/);
1434
+ if (insetMatch) {
1435
+ const insetPx = parseFloat(insetMatch[1]) * 4;
1436
+ if (insetPx === 0) {
1437
+ markFullWidthNode(frame);
1438
+ markFullHeightNode(frame);
1439
+ } else {
1440
+ markAbsoluteNode(frame);
1441
+ markPositionInfo(frame, { inset: insetPx });
1442
+ }
1443
+ handled.add(cls);
1444
+ continue;
1445
+ }
1446
+
1447
+ // Handle inset-x-0 → full width (like CSS left:0 right:0 for absolute elements)
1448
+ if (utility === 'inset-x-0') {
1449
+ markFullWidthNode(frame);
1450
+ handled.add(cls);
1451
+ continue;
1452
+ }
1453
+
1454
+ // Handle inset-y-0 → full height (like CSS top:0 bottom:0 for absolute elements)
1455
+ if (utility === 'inset-y-0') {
1456
+ markFullHeightNode(frame);
1457
+ handled.add(cls);
1458
+ continue;
1459
+ }
1460
+
1461
+ // Handle top-{n}, -top-{n}, bottom-{n}, -bottom-{n} as absolute y-position hints
1462
+ const topMatch = utility.match(/^(-?)top-(\d+(?:\.\d+)?)$/);
1463
+ if (topMatch) {
1464
+ const negative = topMatch[1] === '-';
1465
+ const val = parseFloat(topMatch[2]) * 4; // Tailwind spacing scale: 1 unit = 4px
1466
+ markPositionInfo(frame, { top: negative ? -val : val });
1467
+ handled.add(cls);
1468
+ continue;
1469
+ }
1470
+ // Handle top-[calc(100%-Nrem)] — positions element so top = parentHeight - N*16px
1471
+ const topCalcMatch = utility.match(/^top-\[calc\(100%-(\d+(?:\.\d+)?)rem\)\]$/);
1472
+ if (topCalcMatch) {
1473
+ const remOffset = parseFloat(topCalcMatch[1]) * 16;
1474
+ DEFERRED_TOP_RELATIVE_NODES.set(frame, remOffset);
1475
+ handled.add(cls);
1476
+ continue;
1477
+ }
1478
+ const bottomMatch = utility.match(/^bottom-(\d+(?:\.\d+)?)$/);
1479
+ if (bottomMatch) {
1480
+ const val = parseFloat(bottomMatch[1]) * 4;
1481
+ markPositionInfo(frame, { bottom: val });
1482
+ handled.add(cls);
1483
+ continue;
1484
+ }
1485
+ const leftMatch = utility.match(/^(-?)left-(\d+(?:\.\d+)?)$/);
1486
+ if (leftMatch) {
1487
+ const negative = leftMatch[1] === '-';
1488
+ const val = parseFloat(leftMatch[2]) * 4;
1489
+ markPositionInfo(frame, { left: negative ? -val : val });
1490
+ handled.add(cls);
1491
+ continue;
1492
+ }
1493
+ const rightMatch = utility.match(/^(-?)right-(\d+(?:\.\d+)?)$/);
1494
+ if (rightMatch) {
1495
+ const negative = rightMatch[1] === '-';
1496
+ const val = parseFloat(rightMatch[2]) * 4;
1497
+ markPositionInfo(frame, { right: negative ? -val : val });
1498
+ handled.add(cls);
1499
+ continue;
1500
+ }
1501
+
1502
+ // Handle blur effects (blur-sm, blur, blur-md, blur-lg, blur-xl, blur-2xl, blur-3xl)
1503
+ const blurMatch = utility.match(/^blur(?:-(.+))?$/);
1504
+ if (blurMatch) {
1505
+ // CSS filter:blur(N) uses σ=N (Gaussian std dev). Figma's layer blur visual spread
1506
+ // is roughly half that of CSS for the same value, so we scale up by 2× to match.
1507
+ const blurScale: Record<string, number> = {
1508
+ 'none': 0,
1509
+ 'sm': 8,
1510
+ '': 16, // plain 'blur' = 8px CSS → 16px Figma
1511
+ 'md': 24,
1512
+ 'lg': 32,
1513
+ 'xl': 48,
1514
+ '2xl': 80,
1515
+ '3xl': 128,
1516
+ };
1517
+ const blurKey = blurMatch[1] || '';
1518
+ const blurRadius = blurScale[blurKey];
1519
+ if (blurRadius != null && blurRadius > 0) {
1520
+ frame.effects = [
1521
+ ...(frame.effects || []),
1522
+ { type: 'LAYER_BLUR', blurType: 'NORMAL', radius: blurRadius, visible: true } as Effect,
1523
+ ];
1524
+ }
1525
+ handled.add(cls);
1526
+ continue;
1527
+ }
1528
+
1529
+ // Handle box-shadow utilities (shadow-sm, shadow, shadow-md, shadow-lg, shadow-xl, shadow-2xl, shadow-inner, shadow-none)
1530
+ const shadowMatch = utility.match(/^shadow(?:-(.+))?$/);
1531
+ if (shadowMatch) {
1532
+ const shadowKey = shadowMatch[1] || '';
1533
+ const SHADOW_MAP: Record<string, Array<{ x: number; y: number; radius: number; spread: number; r: number; g: number; b: number; a: number; type: 'DROP_SHADOW' | 'INNER_SHADOW' }>> = {
1534
+ 'none': [],
1535
+ 'sm': [{ x: 0, y: 1, radius: 2, spread: 0, r: 0, g: 0, b: 0, a: 0.05, type: 'DROP_SHADOW' }],
1536
+ '': [
1537
+ { x: 0, y: 1, radius: 3, spread: 0, r: 0, g: 0, b: 0, a: 0.1, type: 'DROP_SHADOW' },
1538
+ { x: 0, y: 1, radius: 2, spread: 0, r: 0, g: 0, b: 0, a: 0.06, type: 'DROP_SHADOW' },
1539
+ ],
1540
+ 'md': [
1541
+ { x: 0, y: 4, radius: 6, spread: 0, r: 0, g: 0, b: 0, a: 0.1, type: 'DROP_SHADOW' },
1542
+ { x: 0, y: 2, radius: 4, spread: 0, r: 0, g: 0, b: 0, a: 0.06, type: 'DROP_SHADOW' },
1543
+ ],
1544
+ 'lg': [
1545
+ { x: 0, y: 10, radius: 15, spread: 0, r: 0, g: 0, b: 0, a: 0.1, type: 'DROP_SHADOW' },
1546
+ { x: 0, y: 4, radius: 6, spread: 0, r: 0, g: 0, b: 0, a: 0.05, type: 'DROP_SHADOW' },
1547
+ ],
1548
+ 'xl': [
1549
+ { x: 0, y: 20, radius: 25, spread: 0, r: 0, g: 0, b: 0, a: 0.1, type: 'DROP_SHADOW' },
1550
+ { x: 0, y: 8, radius: 10, spread: 0, r: 0, g: 0, b: 0, a: 0.04, type: 'DROP_SHADOW' },
1551
+ ],
1552
+ '2xl': [{ x: 0, y: 25, radius: 50, spread: 0, r: 0, g: 0, b: 0, a: 0.25, type: 'DROP_SHADOW' }],
1553
+ 'inner': [{ x: 0, y: 2, radius: 4, spread: 0, r: 0, g: 0, b: 0, a: 0.06, type: 'INNER_SHADOW' }],
1554
+ };
1555
+ const defs = SHADOW_MAP[shadowKey];
1556
+ if (defs !== undefined) {
1557
+ const shadowEffects: Effect[] = [];
1558
+ for (let si = 0; si < defs.length; si++) {
1559
+ const d = defs[si];
1560
+ shadowEffects.push({
1561
+ type: d.type,
1562
+ color: { r: d.r, g: d.g, b: d.b, a: d.a },
1563
+ offset: { x: d.x, y: d.y },
1564
+ radius: d.radius,
1565
+ spread: d.spread,
1566
+ visible: true,
1567
+ blendMode: 'NORMAL',
1568
+ } as Effect);
1569
+ }
1570
+ // 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'
1574
+ );
1575
+ (frame as any).effects = nonShadow.concat(shadowEffects);
1576
+ }
1577
+ handled.add(cls);
1578
+ continue;
1579
+ }
1580
+
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';
1588
+ handled.add(cls);
1589
+ continue;
1590
+ }
1591
+ if (utility === 'flex-1') {
1592
+ // layoutGrow is a child property that requires parent context.
1593
+ // Setting it here (before the frame is in a parent) causes Figma to
1594
+ // switch the frame to FILL mode and collapse height to 1px.
1595
+ // applyChildProperties handles this with the correct parent-FIXED guard.
1596
+ handled.add(cls);
1597
+ continue;
1598
+ }
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; }
1602
+ if (utility === 'justify-center') { frame.primaryAxisAlignItems = 'CENTER'; handled.add(cls); continue; }
1603
+ if (utility === 'justify-between') { frame.primaryAxisAlignItems = 'SPACE_BETWEEN'; handled.add(cls); continue; }
1604
+ if (utility === 'justify-start') { frame.primaryAxisAlignItems = 'MIN'; handled.add(cls); continue; }
1605
+ if (utility === 'justify-end') { frame.primaryAxisAlignItems = 'MAX'; handled.add(cls); continue; }
1606
+ if (utility === 'mx-auto') {
1607
+ // Figma no longer supports layoutAlign = CENTER. Keep as a no-op.
1608
+ handled.add(cls);
1609
+ continue;
1610
+ }
1611
+
1612
+ const gapXMatch = utility.match(/^gap-x-(.+)$/);
1613
+ if (gapXMatch) {
1614
+ const val = resolveSpacingToken(gapXMatch[1], spacingScale);
1615
+ if (val != null) gapX = val;
1616
+ handled.add(cls);
1617
+ continue;
1618
+ }
1619
+ const gapYMatch = utility.match(/^gap-y-(.+)$/);
1620
+ if (gapYMatch) {
1621
+ const val = resolveSpacingToken(gapYMatch[1], spacingScale);
1622
+ if (val != null) gapY = val;
1623
+ handled.add(cls);
1624
+ continue;
1625
+ }
1626
+ const gapMatch = utility.match(/^gap-(.+)$/);
1627
+ if (gapMatch) {
1628
+ const val = resolveSpacingToken(gapMatch[1], spacingScale);
1629
+ if (val != null) gap = val;
1630
+ handled.add(cls);
1631
+ continue;
1632
+ }
1633
+ const spaceXMatch = utility.match(/^space-x-(.+)$/);
1634
+ if (spaceXMatch) {
1635
+ const val = resolveSpacingToken(spaceXMatch[1], spacingScale);
1636
+ if (val != null) gapX = val;
1637
+ handled.add(cls);
1638
+ continue;
1639
+ }
1640
+ const spaceYMatch = utility.match(/^space-y-(.+)$/);
1641
+ if (spaceYMatch) {
1642
+ const val = resolveSpacingToken(spaceYMatch[1], spacingScale);
1643
+ if (val != null) gapY = val;
1644
+ handled.add(cls);
1645
+ continue;
1646
+ }
1647
+
1648
+ const pMatch = utility.match(/^p-(.+)$/);
1649
+ if (pMatch) {
1650
+ const val = resolveSpacingToken(pMatch[1], spacingScale);
1651
+ if (val != null) {
1652
+ frame.paddingLeft = frame.paddingRight = frame.paddingTop = frame.paddingBottom = val;
1653
+ }
1654
+ handled.add(cls);
1655
+ continue;
1656
+ }
1657
+ const pxMatch = utility.match(/^px-(.+)$/);
1658
+ if (pxMatch) {
1659
+ const val = resolveSpacingToken(pxMatch[1], spacingScale);
1660
+ if (val != null) frame.paddingLeft = frame.paddingRight = val;
1661
+ handled.add(cls);
1662
+ continue;
1663
+ }
1664
+ const pyMatch = utility.match(/^py-(.+)$/);
1665
+ if (pyMatch) {
1666
+ const val = resolveSpacingToken(pyMatch[1], spacingScale);
1667
+ if (val != null) frame.paddingTop = frame.paddingBottom = val;
1668
+ handled.add(cls);
1669
+ continue;
1670
+ }
1671
+ const ptMatch = utility.match(/^pt-(.+)$/);
1672
+ if (ptMatch) {
1673
+ const val = resolveSpacingToken(ptMatch[1], spacingScale);
1674
+ if (val != null) frame.paddingTop = val;
1675
+ handled.add(cls);
1676
+ continue;
1677
+ }
1678
+ const pbMatch = utility.match(/^pb-(.+)$/);
1679
+ if (pbMatch) {
1680
+ const val = resolveSpacingToken(pbMatch[1], spacingScale);
1681
+ if (val != null) frame.paddingBottom = val;
1682
+ handled.add(cls);
1683
+ continue;
1684
+ }
1685
+ const plMatch = utility.match(/^pl-(.+)$/);
1686
+ if (plMatch) {
1687
+ const val = resolveSpacingToken(plMatch[1], spacingScale);
1688
+ if (val != null) frame.paddingLeft = val;
1689
+ handled.add(cls);
1690
+ continue;
1691
+ }
1692
+ const prMatch = utility.match(/^pr-(.+)$/);
1693
+ if (prMatch) {
1694
+ const val = resolveSpacingToken(prMatch[1], spacingScale);
1695
+ if (val != null) frame.paddingRight = val;
1696
+ handled.add(cls);
1697
+ continue;
1698
+ }
1699
+ const mtMatch = utility.match(/^mt-(.+)$/);
1700
+ if (mtMatch) {
1701
+ const val = resolveSpacingToken(mtMatch[1], spacingScale);
1702
+ if (val != null) frame.paddingTop = (frame.paddingTop || 0) + val;
1703
+ handled.add(cls);
1704
+ continue;
1705
+ }
1706
+ const mbMatch = utility.match(/^mb-(.+)$/);
1707
+ if (mbMatch) {
1708
+ const val = resolveSpacingToken(mbMatch[1], spacingScale);
1709
+ if (val != null) frame.paddingBottom = (frame.paddingBottom || 0) + val;
1710
+ handled.add(cls);
1711
+ continue;
1712
+ }
1713
+
1714
+ if (utility === 'w-full') {
1715
+ markFullWidthNode(frame);
1716
+ handled.add(cls);
1717
+ continue;
1718
+ }
1719
+ if (utility === 'container') {
1720
+ markFullWidthNode(frame);
1721
+ handled.add(cls);
1722
+ continue;
1723
+ }
1724
+ if (utility === 'h-full') {
1725
+ markFullHeightNode(frame);
1726
+ handled.add(cls);
1727
+ continue;
1728
+ }
1729
+ const wMatch = utility.match(/^w-(.+)$/);
1730
+ if (wMatch) {
1731
+ const token = wMatch[1];
1732
+ const val = resolveSpacingToken(token, spacingScale);
1733
+ if (val != null) {
1734
+ frame.resize(val, frame.height);
1735
+ markFixedWidthNode(frame);
1736
+ // Set sizing mode to FIXED to prevent auto-resizing
1737
+ if (frame.layoutMode === 'HORIZONTAL') {
1738
+ frame.primaryAxisSizingMode = 'FIXED';
1739
+ } else if (frame.layoutMode === 'VERTICAL') {
1740
+ frame.counterAxisSizingMode = 'FIXED';
1741
+ } else {
1742
+ // For NONE layout mode, use counterAxisSizingMode for width
1743
+ if ('counterAxisSizingMode' in frame) {
1744
+ frame.counterAxisSizingMode = 'FIXED';
1745
+ }
1746
+ }
1747
+ } else {
1748
+ const fraction = parseFractionToken(token);
1749
+ if (fraction != null) {
1750
+ markFractionWidthNode(frame, fraction);
1751
+ }
1752
+ }
1753
+ handled.add(cls);
1754
+ continue;
1755
+ }
1756
+ const hMatch = utility.match(/^h-(.+)$/);
1757
+ if (hMatch) {
1758
+ const val = resolveSpacingToken(hMatch[1], spacingScale);
1759
+ if (val != null) {
1760
+ frame.resize(frame.width, val);
1761
+ // Set sizing mode to FIXED to prevent auto-resizing
1762
+ if (frame.layoutMode === 'VERTICAL') {
1763
+ frame.primaryAxisSizingMode = 'FIXED';
1764
+ } else if (frame.layoutMode === 'HORIZONTAL') {
1765
+ frame.counterAxisSizingMode = 'FIXED';
1766
+ } else {
1767
+ // For NONE layout mode, use primaryAxisSizingMode for height
1768
+ if ('primaryAxisSizingMode' in frame) {
1769
+ frame.primaryAxisSizingMode = 'FIXED';
1770
+ }
1771
+ }
1772
+ }
1773
+ handled.add(cls);
1774
+ continue;
1775
+ }
1776
+
1777
+ // size-X sets both width and height (Tailwind's size utility)
1778
+ const sizeMatch = utility.match(/^size-(.+)$/);
1779
+ if (sizeMatch) {
1780
+ const val = resolveSpacingToken(sizeMatch[1], spacingScale);
1781
+ if (val != null) {
1782
+ frame.resize(val, val);
1783
+ markFixedWidthNode(frame);
1784
+ frame.primaryAxisSizingMode = 'FIXED';
1785
+ frame.counterAxisSizingMode = 'FIXED';
1786
+ }
1787
+ handled.add(cls);
1788
+ continue;
1789
+ }
1790
+
1791
+ if (utility.startsWith('max-w-')) {
1792
+ const maxW = resolveMaxWidth(utility);
1793
+ if (maxW != null) {
1794
+ frame.resize(maxW, frame.height);
1795
+ frame.counterAxisSizingMode = 'FIXED';
1796
+ }
1797
+ handled.add(cls);
1798
+ continue;
1799
+ }
1800
+
1801
+ if (utility === 'border') {
1802
+ frame.strokeWeight = 1;
1803
+ handled.add(cls);
1804
+ continue;
1805
+ }
1806
+ const borderWidthMatch = utility.match(/^border-(\d+)$/);
1807
+ if (borderWidthMatch) {
1808
+ frame.strokeWeight = parseFloat(borderWidthMatch[1]);
1809
+ handled.add(cls);
1810
+ continue;
1811
+ }
1812
+ if (setDirectionalBorder(frame, utility, 1)) {
1813
+ handled.add(cls);
1814
+ continue;
1815
+ }
1816
+ const directionalBorderWidthMatch = utility.match(/^(border-(?:t|r|b|l|x|y))-(\d+)$/);
1817
+ if (directionalBorderWidthMatch) {
1818
+ const directionalUtility = directionalBorderWidthMatch[1];
1819
+ const borderWeight = parseFloat(directionalBorderWidthMatch[2]);
1820
+ if (setDirectionalBorder(frame, directionalUtility, borderWeight)) {
1821
+ handled.add(cls);
1822
+ continue;
1823
+ }
1824
+ }
1825
+
1826
+ if (utility === 'rounded' || utility.startsWith('rounded-')) {
1827
+ const radius = resolveRadius(utility, radiusGroup);
1828
+ if (radius != null) frame.cornerRadius = radius;
1829
+ handled.add(cls);
1830
+ continue;
1831
+ }
1832
+ }
1833
+
1834
+ if (gap != null) {
1835
+ frame.itemSpacing = gap;
1836
+ } else if (frame.layoutMode === 'HORIZONTAL' && gapX != null) {
1837
+ frame.itemSpacing = gapX;
1838
+ } else if (frame.layoutMode === 'VERTICAL' && gapY != null) {
1839
+ frame.itemSpacing = gapY;
1840
+ } else if (gapX != null) {
1841
+ frame.itemSpacing = gapX;
1842
+ } else if (gapY != null) {
1843
+ frame.itemSpacing = gapY;
1844
+ }
1845
+
1846
+ return handled;
1847
+ }
1848
+
1849
+ /**
1850
+ * Apply Tailwind classes to a Figma frame
1851
+ */
1852
+ export function applyTailwindStylesToFrame(
1853
+ frame: FrameNode,
1854
+ classes: string[],
1855
+ colorGroup: Record<string, string>,
1856
+ radiusGroup: Record<string, string> | null,
1857
+ theme: string,
1858
+ ): TailwindStyle {
1859
+ const style = tailwindClassesToStyle(classes, 'default', colorGroup);
1860
+ const styleMap = getStyleMap();
1861
+ applySemanticUtilitiesToFrame(frame, classes, radiusGroup);
1862
+ let hasGradient = false;
1863
+
1864
+ if (styleMap) {
1865
+ for (const cls of classes) {
1866
+ const entryList = styleMap[cls];
1867
+ if (!entryList || entryList.length === 0) continue;
1868
+ const atom = parseUtilityClass(cls);
1869
+ if (!shouldApplyAtom(atom, 'default')) continue;
1870
+ for (const entry of entryList) {
1871
+ if (entry.media) continue;
1872
+ applyCssDeclarationsToFrame(frame, entry.declarations);
1873
+ }
1874
+ }
1875
+ }
1876
+
1877
+ const gradient = gradientFromClasses(classes, colorGroup);
1878
+ if (gradient) {
1879
+ // Build gradient stops - include via color at 50% if present
1880
+ const gradientStops: Array<{ position: number; color: { r: number; g: number; b: number; a: number } }> = [
1881
+ { position: 0, color: { r: gradient.from.r, g: gradient.from.g, b: gradient.from.b, a: gradient.from.a ?? 1 } },
1882
+ ];
1883
+ if (gradient.via) {
1884
+ gradientStops.push({
1885
+ position: 0.5,
1886
+ color: { r: gradient.via.r, g: gradient.via.g, b: gradient.via.b, a: gradient.via.a ?? 1 },
1887
+ });
1888
+ }
1889
+ gradientStops.push({
1890
+ position: 1,
1891
+ color: { r: gradient.to.r, g: gradient.to.g, b: gradient.to.b, a: gradient.to.a ?? 1 },
1892
+ });
1893
+
1894
+ // Premultiplied-alpha correction: CSS gradients avoid dark bands by matching the RGB of a
1895
+ // transparent stop to its neighbour (e.g. "to-transparent" inherits the from-color).
1896
+ // Figma interpolates RGB linearly, so without this fix green→transparent becomes green→black.
1897
+ const firstStop = gradientStops[0];
1898
+ const lastStop = gradientStops[gradientStops.length - 1];
1899
+ if (firstStop.color.a === 0) {
1900
+ firstStop.color.r = lastStop.color.r;
1901
+ firstStop.color.g = lastStop.color.g;
1902
+ firstStop.color.b = lastStop.color.b;
1903
+ }
1904
+ if (lastStop.color.a === 0) {
1905
+ lastStop.color.r = firstStop.color.r;
1906
+ lastStop.color.g = firstStop.color.g;
1907
+ lastStop.color.b = firstStop.color.b;
1908
+ }
1909
+ // If there's a via stop, apply the same correction against its neighbours
1910
+ if (gradientStops.length === 3) {
1911
+ const midStop = gradientStops[1];
1912
+ if (midStop.color.a === 0) {
1913
+ midStop.color.r = (firstStop.color.r + lastStop.color.r) / 2;
1914
+ midStop.color.g = (firstStop.color.g + lastStop.color.g) / 2;
1915
+ midStop.color.b = (firstStop.color.b + lastStop.color.b) / 2;
1916
+ }
1917
+ }
1918
+
1919
+ // Apply opacity to the fill itself, not the frame (frame.opacity would affect children too)
1920
+ const fillOpacity = gradient.opacity != null ? gradient.opacity : 1;
1921
+
1922
+ if (gradient.type === 'radial') {
1923
+ // Match CSS `radial-gradient(...)` defaults:
1924
+ // ellipse farthest-corner at center, unless class specifies `at_*`.
1925
+ const gradientTransform = radialGradientTransformFromAnchor(gradient.radialAnchor);
1926
+ frame.fills = [{
1927
+ type: 'GRADIENT_RADIAL',
1928
+ gradientStops: gradientStops,
1929
+ gradientTransform: gradientTransform,
1930
+ opacity: fillOpacity,
1931
+ }];
1932
+ } else {
1933
+ // Linear gradient with direction
1934
+ const gradientTransform = directionToGradientTransform(gradient.direction);
1935
+ frame.fills = [{
1936
+ type: 'GRADIENT_LINEAR',
1937
+ gradientStops: gradientStops,
1938
+ gradientTransform: gradientTransform,
1939
+ opacity: fillOpacity,
1940
+ }];
1941
+ }
1942
+ hasGradient = true;
1943
+ }
1944
+
1945
+ // Apply background - try variable binding first, fall back to raw color
1946
+ if (style.bg && !hasGradient) {
1947
+ const bgToken = style.bgToken;
1948
+ const bound = bgToken && bindColorVariable(frame, bgToken, 'fill', theme);
1949
+ if (bound) {
1950
+ if (style.bgOpacity != null && Array.isArray(frame.fills) && frame.fills.length > 0) {
1951
+ const nextFills = JSON.parse(JSON.stringify(frame.fills));
1952
+ nextFills[0].opacity = style.bgOpacity;
1953
+ frame.fills = nextFills;
1954
+ }
1955
+ } else {
1956
+ const bg = parseColor(style.bg);
1957
+ const bgOpacity = style.bgOpacity != null ? style.bgOpacity : (bg.a == null ? 1 : bg.a);
1958
+ frame.fills = [{ type: 'SOLID', color: { r: bg.r, g: bg.g, b: bg.b }, opacity: bgOpacity }];
1959
+ }
1960
+ }
1961
+
1962
+ // Apply border - try variable binding first, fall back to raw color
1963
+ if (style.border) {
1964
+ const borderToken = style.borderToken;
1965
+ const borderBound = borderToken && bindColorVariable(frame, borderToken, 'stroke', theme);
1966
+ if (!borderBound) {
1967
+ const borderColor = parseColor(style.border);
1968
+ frame.strokes = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
1969
+ }
1970
+ frame.strokeWeight = 1;
1971
+ }
1972
+
1973
+ const hasBorderWidth = classes.some(cls => {
1974
+ const atom = parseUtilityClass(cls);
1975
+ if (!atom.utility) return false;
1976
+ if (!shouldApplyAtom(atom, 'default')) return false;
1977
+ return (
1978
+ atom.utility === 'border'
1979
+ || /^border-\d+$/.test(atom.utility)
1980
+ || /^border-(t|r|b|l|x|y)$/.test(atom.utility)
1981
+ || /^border-(t|r|b|l|x|y)-\d+$/.test(atom.utility)
1982
+ );
1983
+ });
1984
+ if (hasBorderWidth) {
1985
+ BORDER_WIDTH_CLASSES.set(frame, classes.slice());
1986
+ } else {
1987
+ BORDER_WIDTH_CLASSES.delete(frame);
1988
+ }
1989
+
1990
+ if (
1991
+ hasBorderWidth
1992
+ && frame.strokeWeight
1993
+ && (!frame.strokes || frame.strokes.length === 0)
1994
+ && (!style.border)
1995
+ ) {
1996
+ const fallbackBorder = colorGroup.border || FALLBACK_COLOR_TOKENS['gray-200'] || '#e5e7eb';
1997
+ const rgb = parseColor(fallbackBorder);
1998
+ frame.strokes = [{ type: 'SOLID', color: { r: rgb.r, g: rgb.g, b: rgb.b } }];
1999
+ }
2000
+
2001
+ if (hasBorderWidth && frame.strokeWeight) {
2002
+ // Setting strokes can normalize per-side weights in Figma; re-apply directional border
2003
+ // utilities so classes like `border-t` remain top-only instead of becoming full boxes.
2004
+ applyBorderWidthUtilities(frame, classes);
2005
+ }
2006
+
2007
+ // Apply opacity - but NOT if we have a gradient (gradient fill already has its own opacity)
2008
+ // Applying frame.opacity when there's a gradient would make children transparent too
2009
+ if (style.opacity != null && !hasGradient) {
2010
+ frame.opacity = style.opacity;
2011
+ }
2012
+
2013
+ return style;
2014
+ }
2015
+
2016
+ // ---------------------------------------------------------------------------
2017
+ // Section 2 – Component Change Detection helpers (lines 3283-3433 of code.js)
2018
+ // ---------------------------------------------------------------------------
2019
+
2020
+ /**
2021
+ * Tailwind spacing scale (matches tailwind-parser.ts)
2022
+ */
2023
+ export const TAILWIND_SPACING_REVERSE: Record<number, string> = {
2024
+ 0: '0', 1: '0.25', 2: '0.5', 4: '1', 6: '1.5', 8: '2', 10: '2.5', 12: '3',
2025
+ 14: '3.5', 16: '4', 20: '5', 24: '6', 28: '7', 32: '8', 36: '9', 40: '10',
2026
+ 44: '11', 48: '12', 56: '14', 64: '16', 80: '20', 96: '24', 112: '28',
2027
+ 128: '32', 144: '36', 160: '40', 176: '44', 192: '48', 208: '52', 224: '56',
2028
+ 240: '60', 256: '64', 288: '72', 320: '80', 384: '96',
2029
+ };
2030
+
2031
+ /**
2032
+ * Tailwind border radius scale
2033
+ */
2034
+ export const TAILWIND_RADIUS_REVERSE: Record<number, string> = {
2035
+ 0: 'none', 2: 'sm', 4: 'DEFAULT', 6: 'md', 8: 'lg', 12: 'xl', 16: '2xl', 24: '3xl', 9999: 'full',
2036
+ };
2037
+
2038
+ /**
2039
+ * Find closest Tailwind spacing value for a pixel value
2040
+ */
2041
+ export function pxToTailwindSpacing(px: number): string {
2042
+ let closest = 0;
2043
+ let closestDiff = Infinity;
2044
+ for (const key in TAILWIND_SPACING_REVERSE) {
2045
+ const diff = Math.abs(parseInt(key) - px);
2046
+ if (diff < closestDiff) {
2047
+ closestDiff = diff;
2048
+ closest = parseInt(key);
2049
+ }
2050
+ }
2051
+ return TAILWIND_SPACING_REVERSE[closest];
2052
+ }
2053
+
2054
+ /**
2055
+ * Find closest Tailwind radius value for a pixel value
2056
+ */
2057
+ export function pxToTailwindRadius(px: number): string {
2058
+ let closest = 0;
2059
+ let closestDiff = Infinity;
2060
+ for (const key in TAILWIND_RADIUS_REVERSE) {
2061
+ const diff = Math.abs(parseInt(key) - px);
2062
+ if (diff < closestDiff) {
2063
+ closestDiff = diff;
2064
+ closest = parseInt(key);
2065
+ }
2066
+ }
2067
+ return TAILWIND_RADIUS_REVERSE[closest];
2068
+ }
2069
+
2070
+ /**
2071
+ * Convert RGB color to hex string
2072
+ */
2073
+ export function rgbToHex(r: number, g: number, b: number): string {
2074
+ const toHex = (c: number): string => {
2075
+ const hex = Math.round(c * 255).toString(16);
2076
+ return hex.length === 1 ? '0' + hex : hex;
2077
+ };
2078
+ return '#' + toHex(r) + toHex(g) + toHex(b);
2079
+ }
2080
+
2081
+ /**
2082
+ * Extract design properties from a Figma frame
2083
+ */
2084
+ export function extractFrameProperties(frame: SceneNode | null): FrameProperties {
2085
+ const props: FrameProperties = {
2086
+ cornerRadius: 0,
2087
+ paddingTop: 0,
2088
+ paddingBottom: 0,
2089
+ paddingLeft: 0,
2090
+ paddingRight: 0,
2091
+ background: null,
2092
+ borderColor: null,
2093
+ borderWidth: 0,
2094
+ };
2095
+
2096
+ if (!frame || (frame.type !== 'FRAME' && frame.type !== 'COMPONENT')) {
2097
+ return props;
2098
+ }
2099
+
2100
+ // Corner radius
2101
+ if (typeof frame.cornerRadius === 'number') {
2102
+ props.cornerRadius = frame.cornerRadius;
2103
+ }
2104
+
2105
+ // Padding
2106
+ props.paddingTop = frame.paddingTop || 0;
2107
+ props.paddingBottom = frame.paddingBottom || 0;
2108
+ props.paddingLeft = frame.paddingLeft || 0;
2109
+ props.paddingRight = frame.paddingRight || 0;
2110
+
2111
+ // Background color
2112
+ if (frame.fills && (frame.fills as readonly Paint[]).length > 0) {
2113
+ const fill = (frame.fills as readonly Paint[])[0];
2114
+ if (fill.type === 'SOLID' && fill.visible !== false) {
2115
+ props.background = rgbToHex(fill.color.r, fill.color.g, fill.color.b);
2116
+ if (fill.opacity !== undefined && fill.opacity !== 1) {
2117
+ props.backgroundOpacity = fill.opacity;
2118
+ }
2119
+ }
2120
+ }
2121
+
2122
+ // Border
2123
+ if (frame.strokes && frame.strokes.length > 0) {
2124
+ const stroke = frame.strokes[0];
2125
+ if (stroke.type === 'SOLID' && stroke.visible !== false) {
2126
+ props.borderColor = rgbToHex(stroke.color.r, stroke.color.g, stroke.color.b);
2127
+ props.borderWidth = frame.strokeWeight as number || 1;
2128
+ }
2129
+ }
2130
+
2131
+ return props;
2132
+ }
2133
+
2134
+ /**
2135
+ * Convert Figma properties to Tailwind class suggestions
2136
+ */
2137
+ export function propsToTailwindClasses(props: FrameProperties): string[] {
2138
+ const classes: string[] = [];
2139
+
2140
+ // Padding
2141
+ const pt = pxToTailwindSpacing(props.paddingTop);
2142
+ const pb = pxToTailwindSpacing(props.paddingBottom);
2143
+ const pl = pxToTailwindSpacing(props.paddingLeft);
2144
+ const pr = pxToTailwindSpacing(props.paddingRight);
2145
+
2146
+ if (pt === pb && pl === pr && pt === pl) {
2147
+ // All same: p-X
2148
+ classes.push('p-' + pt);
2149
+ } else if (pt === pb && pl === pr) {
2150
+ // Symmetric: px-X py-Y
2151
+ classes.push('px-' + pl);
2152
+ classes.push('py-' + pt);
2153
+ } else {
2154
+ // Individual
2155
+ if (pt !== '0') classes.push('pt-' + pt);
2156
+ if (pb !== '0') classes.push('pb-' + pb);
2157
+ if (pl !== '0') classes.push('pl-' + pl);
2158
+ if (pr !== '0') classes.push('pr-' + pr);
2159
+ }
2160
+
2161
+ // Border radius
2162
+ const radius = pxToTailwindRadius(props.cornerRadius);
2163
+ if (radius !== 'none') {
2164
+ classes.push(radius === 'DEFAULT' ? 'rounded' : 'rounded-' + radius);
2165
+ }
2166
+
2167
+ // Border
2168
+ if (props.borderWidth > 0) {
2169
+ classes.push(props.borderWidth === 1 ? 'border' : 'border-' + props.borderWidth);
2170
+ }
2171
+
2172
+ return classes;
2173
+ }
2174
+
2175
+ // ---------------------------------------------------------------------------
2176
+ // Section 3 – Dev Mode Codegen helpers (lines 4350-4486 of code.js)
2177
+ // ---------------------------------------------------------------------------
2178
+
2179
+ /** Format a number as a CSS px value */
2180
+ export function px(n: number): string {
2181
+ return Math.round(n) + 'px';
2182
+ }
2183
+
2184
+ /** Tailwind spacing scale (px -> token) for codegen */
2185
+ const SPACING_SCALE = new Map<number, string>([
2186
+ [0, '0'], [2, '0.5'], [4, '1'], [6, '1.5'], [8, '2'], [10, '2.5'], [12, '3'], [14, '3.5'],
2187
+ [16, '4'], [20, '5'], [24, '6'], [28, '7'], [32, '8'], [36, '9'], [40, '10'], [44, '11'], [48, '12'],
2188
+ [56, '14'], [64, '16'], [80, '20'], [96, '24'], [112, '28'], [128, '32'], [144, '36'], [160, '40'],
2189
+ [176, '44'], [192, '48'], [208, '52'], [224, '56'], [240, '60'], [256, '64'], [288, '72'],
2190
+ [320, '80'], [384, '96'],
2191
+ ]);
2192
+
2193
+ /** Map a pixel value to the nearest Tailwind spacing token, or an arbitrary value */
2194
+ export function twSpace(pxVal: number): string {
2195
+ const p = Math.round(pxVal || 0);
2196
+ if (SPACING_SCALE.has(p)) return SPACING_SCALE.get(p)!;
2197
+ return '[' + p + 'px]';
2198
+ }
2199
+
2200
+ /** Push a size class (e.g. w-4, h-8) onto a class list */
2201
+ export function pushSizeClass(list: string[], prefix: string, pxVal: number): void {
2202
+ if (typeof pxVal !== 'number') return;
2203
+ const token = twSpace(pxVal);
2204
+ list.push(prefix + '-' + token);
2205
+ }
2206
+
2207
+ /** Push optimised padding classes onto a class list */
2208
+ export function pushPaddingClasses(
2209
+ classes: string[],
2210
+ pl: number,
2211
+ pr: number,
2212
+ pt: number,
2213
+ pb: number,
2214
+ ): void {
2215
+ const L = twSpace(pl);
2216
+ const R = twSpace(pr);
2217
+ const T = twSpace(pt);
2218
+ const B = twSpace(pb);
2219
+ if (L === R && T === B && L === T) { classes.push('p-' + L); return; }
2220
+ if (L === R) classes.push('px-' + L); else { classes.push('pl-' + L, 'pr-' + R); }
2221
+ if (T === B) classes.push('py-' + T); else { classes.push('pt-' + T, 'pb-' + B); }
2222
+ }
2223
+
2224
+ /** Tailwind opacity scale values */
2225
+ const OPACITY_SCALE = [0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, 100];
2226
+
2227
+ /** Map an opacity value (0-1) to the nearest Tailwind opacity class */
2228
+ export function opacityClass(value: number): string {
2229
+ const v = Math.max(0, Math.min(1, value));
2230
+ const pct = Math.round(v * 100);
2231
+ let best = OPACITY_SCALE[0];
2232
+ let diff = Infinity;
2233
+ for (const entry of OPACITY_SCALE) {
2234
+ const d = Math.abs(entry - pct);
2235
+ if (d < diff) {
2236
+ diff = d;
2237
+ best = entry;
2238
+ }
2239
+ }
2240
+ if (diff <= 2) return 'opacity-' + best;
2241
+ return 'opacity-[' + pct + '%]';
2242
+ }
2243
+
2244
+ /** Return the first visible SOLID paint from an array of paints */
2245
+ export function firstVisiblePaint(paints: readonly Paint[] | null): SolidPaint | null {
2246
+ if (!Array.isArray(paints)) return null;
2247
+ return (paints.find((p: Paint) => p.visible !== false && p.type === 'SOLID') as SolidPaint) || null;
2248
+ }
2249
+
2250
+ /** Map a drop-shadow effect to a Tailwind shadow class */
2251
+ export function mapShadow(eff: Effect | null | undefined): string | null {
2252
+ if (!eff) return null;
2253
+ const r = (eff as DropShadowEffect).radius || 0;
2254
+ const y = ((eff as DropShadowEffect).offset && (eff as DropShadowEffect).offset.y) || 0;
2255
+ // Approximate Tailwind shadow scale
2256
+ if (r <= 2 && y <= 1) return 'shadow-sm';
2257
+ if (r <= 4 && y <= 2) return 'shadow';
2258
+ if (r <= 8) return 'shadow-md';
2259
+ if (r <= 12) return 'shadow-lg';
2260
+ if (r <= 20) return 'shadow-xl';
2261
+ return 'shadow-2xl';
2262
+ }
2263
+
2264
+ /** Generate Tailwind classes for a Figma node (codegen) */
2265
+ export function tailwindForNode(node: SceneNode): string {
2266
+ const classes: string[] = [];
2267
+
2268
+ // Layout (Auto Layout)
2269
+ if ('layoutMode' in node && (node as FrameNode).layoutMode) {
2270
+ const frame = node as FrameNode;
2271
+ classes.push('flex');
2272
+ classes.push(frame.layoutMode === 'HORIZONTAL' ? 'flex-row' : 'flex-col');
2273
+ if (typeof frame.itemSpacing === 'number' && frame.itemSpacing > 0) {
2274
+ classes.push('gap-' + twSpace(frame.itemSpacing));
2275
+ }
2276
+ // Padding
2277
+ const pl = frame.paddingLeft || 0;
2278
+ const pr = frame.paddingRight || 0;
2279
+ const pt = frame.paddingTop || 0;
2280
+ const pb = frame.paddingBottom || 0;
2281
+ if (pl || pr || pt || pb) {
2282
+ pushPaddingClasses(classes, pl, pr, pt, pb);
2283
+ }
2284
+ // Justify/align
2285
+ const mapJust: Record<string, string> = {
2286
+ MIN: 'justify-start',
2287
+ CENTER: 'justify-center',
2288
+ MAX: 'justify-end',
2289
+ SPACE_BETWEEN: 'justify-between',
2290
+ };
2291
+ const mapAlign: Record<string, string> = {
2292
+ MIN: 'items-start',
2293
+ CENTER: 'items-center',
2294
+ MAX: 'items-end',
2295
+ };
2296
+ if (frame.primaryAxisAlignItems && mapJust[frame.primaryAxisAlignItems]) {
2297
+ classes.push(mapJust[frame.primaryAxisAlignItems]);
2298
+ }
2299
+ if (frame.counterAxisAlignItems && mapAlign[frame.counterAxisAlignItems]) {
2300
+ classes.push(mapAlign[frame.counterAxisAlignItems]);
2301
+ }
2302
+ if ((frame as any).layoutGrow === 1) classes.push('flex-1');
2303
+ if ((frame as any).layoutAlign === 'STRETCH') classes.push('self-stretch');
2304
+ } 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);
2307
+ }
2308
+
2309
+ // Corner radius
2310
+ if ('cornerRadius' in node && typeof (node as FrameNode).cornerRadius === 'number') {
2311
+ const r = Math.round((node as FrameNode).cornerRadius as number);
2312
+ const map: Record<number, string> = { 0: 'rounded-none', 4: 'rounded-sm', 6: 'rounded-md', 8: 'rounded-lg', 12: 'rounded-xl' };
2313
+ classes.push(map[r] || ('rounded-[' + px(r) + ']'));
2314
+ }
2315
+
2316
+ // Fills -> background or text color
2317
+ try {
2318
+ const paint = firstVisiblePaint((node as any).fills);
2319
+ if (paint) {
2320
+ const rgb = paint.color;
2321
+ const token = nearestColorToken(rgb);
2322
+ if (node.type === 'TEXT') classes.push('text-' + (token || 'foreground'));
2323
+ else classes.push('bg-' + (token || 'background'));
2324
+ if (paint.opacity != null && paint.opacity !== 1) {
2325
+ classes.push(opacityClass(paint.opacity));
2326
+ }
2327
+ }
2328
+ } catch (e) { /* ignore */ }
2329
+
2330
+ // Strokes -> border color/width
2331
+ try {
2332
+ const paint = firstVisiblePaint((node as any).strokes);
2333
+ if (paint) {
2334
+ const w = Math.round((node as any).strokeWeight || 1);
2335
+ classes.push('border');
2336
+ if (w !== 1) classes.push('border-' + twSpace(w));
2337
+ const token = nearestColorToken(paint.color);
2338
+ if (token) classes.push('border-' + token);
2339
+ }
2340
+ } catch (e) { /* ignore */ }
2341
+
2342
+ // Shadows (drop)
2343
+ try {
2344
+ const eff = ((node as any).effects || []).find(
2345
+ (e: Effect) => e.type === 'DROP_SHADOW' && e.visible !== false,
2346
+ );
2347
+ const cl = mapShadow(eff);
2348
+ if (cl) classes.push(cl);
2349
+ } catch (e) { /* ignore */ }
2350
+
2351
+ // Typography for text
2352
+ if (node.type === 'TEXT') {
2353
+ const textNode = node as TextNode;
2354
+ 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) + ']');
2357
+ }
2358
+ if (textNode.fontName && (textNode.fontName as FontName).style && /bold/i.test((textNode.fontName as FontName).style)) {
2359
+ classes.push('font-bold');
2360
+ }
2361
+ if (textNode.textAlignHorizontal) {
2362
+ const alignMap: Record<string, string> = {
2363
+ LEFT: 'text-left',
2364
+ CENTER: 'text-center',
2365
+ RIGHT: 'text-right',
2366
+ JUSTIFIED: 'text-justify',
2367
+ };
2368
+ if (alignMap[textNode.textAlignHorizontal]) classes.push(alignMap[textNode.textAlignHorizontal]);
2369
+ }
2370
+ if (textNode.textDecoration === 'UNDERLINE') classes.push('underline');
2371
+ if (textNode.textCase === 'UPPER') classes.push('uppercase');
2372
+ }
2373
+
2374
+ if ('opacity' in node && node.opacity != null && node.opacity !== 1) {
2375
+ classes.push(opacityClass(node.opacity));
2376
+ }
2377
+
2378
+ return classes.filter(Boolean).join(' ');
2379
+ }