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.
- package/README.md +201 -0
- package/bin/inkhouse.mjs +171 -0
- package/code.js +11802 -0
- package/manifest.json +30 -0
- package/package.json +45 -0
- package/scanner/blob-placement-regression.ts +132 -0
- package/scanner/class-collector.ts +69 -0
- package/scanner/cli.ts +336 -0
- package/scanner/component-scanner.ts +2876 -0
- package/scanner/css-patch-regression.ts +112 -0
- package/scanner/css-token-reader-regression.ts +92 -0
- package/scanner/css-token-reader.ts +477 -0
- package/scanner/font-style-resolver-regression.ts +32 -0
- package/scanner/index.ts +9 -0
- package/scanner/radial-gradient-regression.ts +53 -0
- package/scanner/style-map.ts +145 -0
- package/scanner/tailwind-parser.ts +644 -0
- package/scanner/transform-math-regression.ts +42 -0
- package/scanner/types.ts +298 -0
- package/src/blob-placement.ts +111 -0
- package/src/change-detection.ts +204 -0
- package/src/class-utils.ts +105 -0
- package/src/clip-path-decorative.ts +194 -0
- package/src/color-resolver.ts +98 -0
- package/src/colors.ts +196 -0
- package/src/component-defs.ts +54 -0
- package/src/component-gen.ts +561 -0
- package/src/component-lookup.ts +82 -0
- package/src/config.ts +115 -0
- package/src/design-system.ts +59 -0
- package/src/dev-server.ts +173 -0
- package/src/figma-globals.d.ts +3 -0
- package/src/font-style-resolver.ts +171 -0
- package/src/github.ts +1465 -0
- package/src/icon-builder.ts +607 -0
- package/src/image-cache.ts +22 -0
- package/src/inline-text.ts +271 -0
- package/src/layout-parser.ts +667 -0
- package/src/layout-utils.ts +155 -0
- package/src/main.ts +687 -0
- package/src/node-ir.ts +595 -0
- package/src/pack-provider.ts +148 -0
- package/src/packs.ts +126 -0
- package/src/radial-gradient.ts +84 -0
- package/src/render-context.ts +138 -0
- package/src/responsive-analyzer.ts +139 -0
- package/src/state-analyzer.ts +143 -0
- package/src/story-builder.ts +1706 -0
- package/src/story-layout.ts +38 -0
- package/src/tailwind.ts +2379 -0
- package/src/text-builder.ts +116 -0
- package/src/text-line.ts +42 -0
- package/src/token-source.ts +43 -0
- package/src/tokens.ts +717 -0
- package/src/transform-math.ts +44 -0
- package/src/ui-builder.ts +1996 -0
- package/src/utility-resolver.ts +125 -0
- package/src/variables.ts +1042 -0
- package/src/width-solver.ts +466 -0
- package/templates/patch-tokens-route.ts +165 -0
- package/templates/scan-components-route.ts +57 -0
- package/ui.html +1222 -0
package/src/tailwind.ts
ADDED
|
@@ -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
|
+
}
|