figma-code-agent 1.0.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 +133 -0
- package/bin/install.js +328 -0
- package/knowledge/README.md +62 -0
- package/knowledge/css-strategy.md +973 -0
- package/knowledge/design-to-code-assets.md +855 -0
- package/knowledge/design-to-code-layout.md +929 -0
- package/knowledge/design-to-code-semantic.md +1085 -0
- package/knowledge/design-to-code-typography.md +1003 -0
- package/knowledge/design-to-code-visual.md +1145 -0
- package/knowledge/design-tokens-variables.md +1261 -0
- package/knowledge/design-tokens.md +960 -0
- package/knowledge/figma-api-devmode.md +894 -0
- package/knowledge/figma-api-plugin.md +920 -0
- package/knowledge/figma-api-rest.md +742 -0
- package/knowledge/figma-api-variables.md +848 -0
- package/knowledge/figma-api-webhooks.md +876 -0
- package/knowledge/payload-blocks.md +1184 -0
- package/knowledge/payload-figma-mapping.md +1210 -0
- package/knowledge/payload-visual-builder.md +1004 -0
- package/knowledge/plugin-architecture.md +1176 -0
- package/knowledge/plugin-best-practices.md +1206 -0
- package/knowledge/plugin-codegen.md +1313 -0
- package/package.json +31 -0
- package/skills/README.md +103 -0
- package/skills/audit-plugin/SKILL.md +244 -0
- package/skills/build-codegen-plugin/SKILL.md +279 -0
- package/skills/build-importer/SKILL.md +320 -0
- package/skills/build-plugin/SKILL.md +199 -0
- package/skills/build-token-pipeline/SKILL.md +363 -0
- package/skills/ref-html/SKILL.md +290 -0
- package/skills/ref-layout/SKILL.md +150 -0
- package/skills/ref-payload-block/SKILL.md +415 -0
- package/skills/ref-react/SKILL.md +222 -0
- package/skills/ref-tokens/SKILL.md +347 -0
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
# Visual Properties → CSS Mapping
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Authoritative reference for converting Figma visual properties into CSS. Encodes production-proven mapping rules covering the complete pipeline: extracting fills, strokes, effects, corner radii, gradients, opacity, and blend modes from Figma nodes, transforming them into intermediate types, and generating pixel-accurate CSS. This module covers everything that gives a node its visual appearance beyond layout (which is handled by the layout module).
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Reference this module when you need to:
|
|
10
|
+
|
|
11
|
+
- Convert Figma fills (solid, gradient, image) to CSS `background-color` or `background-image`
|
|
12
|
+
- Map Figma gradient transforms to CSS gradient angles and stop positions
|
|
13
|
+
- Convert Figma strokes (with alignment INSIDE/CENTER/OUTSIDE) to CSS borders or box-shadows
|
|
14
|
+
- Map Figma effects (shadows, blurs) to CSS `box-shadow`, `filter`, and `backdrop-filter`
|
|
15
|
+
- Handle uniform and per-corner border radius with shorthand optimization
|
|
16
|
+
- Apply node-level and fill-level opacity correctly without double-application
|
|
17
|
+
- Resolve Figma variable bindings on visual properties to CSS custom properties
|
|
18
|
+
- Understand the RGB 0-1 to 0-255 conversion and hex normalization
|
|
19
|
+
- Handle multiple fills with correct layer ordering (Figma vs CSS reversal)
|
|
20
|
+
- Generate design tokens from collected visual properties (colors, shadows, radii)
|
|
21
|
+
- Avoid common visual property conversion pitfalls
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Content
|
|
26
|
+
|
|
27
|
+
### 1. Fills → CSS Background
|
|
28
|
+
|
|
29
|
+
Figma fills are paint layers applied to a node. Each fill has a type (SOLID, gradient variants, IMAGE), visibility, and opacity. Only visible fills should be converted to CSS.
|
|
30
|
+
|
|
31
|
+
#### Extracted Fill Types
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
type FillData =
|
|
35
|
+
| { type: 'SOLID'; color: string; opacity: number; variable?: VariableReference }
|
|
36
|
+
| { type: 'GRADIENT'; gradient: GradientData }
|
|
37
|
+
| { type: 'IMAGE'; imageHash: string; scaleMode: string }
|
|
38
|
+
|
|
39
|
+
interface VariableReference {
|
|
40
|
+
id: string // Figma variable ID
|
|
41
|
+
name: string // Variable name path (e.g., "color/link/default")
|
|
42
|
+
isLocal: boolean // Local to this file vs external library
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
#### SOLID Fill → CSS
|
|
47
|
+
|
|
48
|
+
Figma stores RGB channels as floats in the 0-1 range. They must be converted to 0-255 integers for CSS.
|
|
49
|
+
|
|
50
|
+
**RGB Conversion Formula:**
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
CSS channel = Math.round(figmaChannel * 255)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Hex color generation:**
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
function rgbToHex(color: RGB, opacity: number = 1): string {
|
|
60
|
+
const { r, g, b } = color
|
|
61
|
+
if (opacity < 1) {
|
|
62
|
+
return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${opacity.toFixed(2)})`
|
|
63
|
+
}
|
|
64
|
+
return `#${Math.round(r * 255).toString(16).padStart(2, '0')}${Math.round(g * 255).toString(16).padStart(2, '0')}${Math.round(b * 255).toString(16).padStart(2, '0')}`
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Figma Fill | CSS Output |
|
|
69
|
+
|------------|------------|
|
|
70
|
+
| `{ r: 1, g: 0, b: 0 }`, opacity 1.0 | `background-color: #ff0000` |
|
|
71
|
+
| `{ r: 0.2, g: 0.4, b: 0.6 }`, opacity 0.8 | `background-color: rgba(51, 102, 153, 0.80)` |
|
|
72
|
+
| `{ r: 1, g: 1, b: 1 }`, opacity 1.0 | `background-color: #ffffff` |
|
|
73
|
+
| `{ r: 0, g: 0, b: 0 }`, opacity 0.5 | `background-color: rgba(0, 0, 0, 0.50)` |
|
|
74
|
+
|
|
75
|
+
**Opacity handling for hex colors:**
|
|
76
|
+
|
|
77
|
+
When a solid fill has `opacity < 1`, the alpha is embedded in the color value as `rgba()`. This is fill-level opacity, distinct from node-level opacity (see Section 6).
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
function applyOpacity(hexColor: string, opacity: number): string {
|
|
81
|
+
if (hexColor.startsWith('rgba')) {
|
|
82
|
+
return hexColor.replace(/[\d.]+\)$/, `${opacity})`)
|
|
83
|
+
}
|
|
84
|
+
const hex = hexColor.replace('#', '')
|
|
85
|
+
const r = parseInt(hex.substring(0, 2), 16)
|
|
86
|
+
const g = parseInt(hex.substring(2, 4), 16)
|
|
87
|
+
const b = parseInt(hex.substring(4, 6), 16)
|
|
88
|
+
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### IMAGE Fill → CSS
|
|
93
|
+
|
|
94
|
+
Image fills reference an `imageHash` that maps to an exported asset file.
|
|
95
|
+
|
|
96
|
+
| Figma `scaleMode` | CSS `background-size` |
|
|
97
|
+
|--------------------|----------------------|
|
|
98
|
+
| `FILL` (default) | `cover` |
|
|
99
|
+
| `FIT` | `contain` |
|
|
100
|
+
| `CROP` | `cover` (with position) |
|
|
101
|
+
| `TILE` | `auto` (with `background-repeat: repeat`) |
|
|
102
|
+
|
|
103
|
+
```css
|
|
104
|
+
/* IMAGE fill with FILL scale mode */
|
|
105
|
+
background-image: url(../assets/image-abc123.png);
|
|
106
|
+
background-size: cover;
|
|
107
|
+
background-position: center;
|
|
108
|
+
background-repeat: no-repeat;
|
|
109
|
+
|
|
110
|
+
/* IMAGE fill with FIT scale mode */
|
|
111
|
+
background-image: url(../assets/image-abc123.png);
|
|
112
|
+
background-size: contain;
|
|
113
|
+
background-position: center;
|
|
114
|
+
background-repeat: no-repeat;
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Fallback:** When an image hash cannot be resolved to a filename, use a placeholder background color:
|
|
118
|
+
|
|
119
|
+
```css
|
|
120
|
+
background-color: #f0f0f0;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### Multiple Fills — Layer Order Reversal (CRITICAL)
|
|
124
|
+
|
|
125
|
+
Figma renders fills **bottom-to-top**: the first fill in the array is the bottom layer (painted first, visually behind). CSS `background` layers render **top-to-bottom**: the first value is the top layer (visually in front).
|
|
126
|
+
|
|
127
|
+
**Rule: Reverse the fills array when generating CSS.**
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
Figma fills array: [fill_0 (bottom), fill_1 (middle), fill_2 (top)]
|
|
131
|
+
CSS backgrounds: [fill_2 (top), fill_1 (middle), fill_0 (bottom)]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
When layering multiple fills in CSS, solid colors must be converted to `linear-gradient(color, color)` because CSS cannot mix `background-color` with multiple `background-image` layers.
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// Multiple fills generation (reversed order)
|
|
138
|
+
const reversedFills = [...fills].reverse()
|
|
139
|
+
const backgroundImages: string[] = []
|
|
140
|
+
|
|
141
|
+
for (const fill of reversedFills) {
|
|
142
|
+
if (fill.type === 'SOLID') {
|
|
143
|
+
// Solid colors become degenerate gradients for layering
|
|
144
|
+
backgroundImages.push(`linear-gradient(${color}, ${color})`)
|
|
145
|
+
} else if (fill.type === 'GRADIENT') {
|
|
146
|
+
backgroundImages.push(generateGradientCSS(fill.gradient))
|
|
147
|
+
} else if (fill.type === 'IMAGE') {
|
|
148
|
+
backgroundImages.push(`url(../assets/${filename})`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**CSS output for multiple fills:**
|
|
154
|
+
|
|
155
|
+
```css
|
|
156
|
+
/* Gradient over solid color */
|
|
157
|
+
background-image: linear-gradient(90deg, #ff0000 0%, #0000ff 100%), linear-gradient(#f5f5f5, #f5f5f5);
|
|
158
|
+
background-size: cover, cover;
|
|
159
|
+
background-position: center, center;
|
|
160
|
+
background-repeat: no-repeat, no-repeat;
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### Visibility Filtering
|
|
164
|
+
|
|
165
|
+
Skip fills where `visible === false`. Figma allows toggling individual fill layer visibility, so always check before processing.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
for (const paint of fills) {
|
|
169
|
+
if (!paint.visible) continue // Skip invisible fills
|
|
170
|
+
// ... process fill
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### Mixed Fills on Text Nodes
|
|
175
|
+
|
|
176
|
+
Text nodes can have `fills === figma.mixed` when different character ranges have different colors. For extraction, use the first character's fill as the dominant color:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
if (fills === figma.mixed && node.type === 'TEXT') {
|
|
180
|
+
const rangeFills = (node as TextNode).getRangeFills(0, 1)
|
|
181
|
+
if (Array.isArray(rangeFills) && rangeFills.length > 0) {
|
|
182
|
+
fills = rangeFills
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Full styled-segment support (per-character colors) is handled by the typography module.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### 2. Gradient Mapping
|
|
192
|
+
|
|
193
|
+
Figma supports four gradient types. Each has color stops and a transform matrix that defines the gradient's orientation and shape.
|
|
194
|
+
|
|
195
|
+
#### Extracted Gradient Types
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
interface GradientData {
|
|
199
|
+
type: 'LINEAR' | 'RADIAL' | 'ANGULAR' | 'DIAMOND'
|
|
200
|
+
stops: Array<{ color: string; position: number }>
|
|
201
|
+
angle?: number // Only for LINEAR (calculated from transform matrix)
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
#### Gradient Type → CSS Function
|
|
206
|
+
|
|
207
|
+
| Figma Gradient Type | CSS Function | Notes |
|
|
208
|
+
|---------------------|-------------|-------|
|
|
209
|
+
| `GRADIENT_LINEAR` | `linear-gradient(Ndeg, ...stops)` | Angle from transform matrix |
|
|
210
|
+
| `GRADIENT_RADIAL` | `radial-gradient(circle, ...stops)` | Figma default is ellipse; `circle` is a safe approximation |
|
|
211
|
+
| `GRADIENT_ANGULAR` | `conic-gradient(...stops)` | Maps directly to CSS conic |
|
|
212
|
+
| `GRADIENT_DIAMOND` | `radial-gradient(circle, ...stops)` | No direct CSS equivalent; approximate with radial |
|
|
213
|
+
|
|
214
|
+
#### Linear Gradient Angle Conversion (The Math)
|
|
215
|
+
|
|
216
|
+
Figma represents gradient direction using a 2x3 affine transform matrix. The angle must be extracted and converted to CSS conventions.
|
|
217
|
+
|
|
218
|
+
**Figma transform matrix structure:**
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
Transform = [[a, c, e], [b, d, f]]
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Where `(a, b)` is the unit vector for the gradient direction.
|
|
225
|
+
|
|
226
|
+
**Coordinate system differences:**
|
|
227
|
+
|
|
228
|
+
| Property | Figma | CSS |
|
|
229
|
+
|----------|-------|-----|
|
|
230
|
+
| 0 degrees | Left-to-right (positive X) | Bottom-to-top (upward) |
|
|
231
|
+
| Direction | Counter-clockwise | Clockwise |
|
|
232
|
+
| Default horizontal | 0 degrees | 90 degrees |
|
|
233
|
+
|
|
234
|
+
**Conversion formula:**
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
function calculateGradientAngle(transform: Transform): number {
|
|
238
|
+
// Extract gradient direction vector
|
|
239
|
+
const [[a], [b]] = transform
|
|
240
|
+
|
|
241
|
+
// Calculate Figma angle (counter-clockwise from positive X axis)
|
|
242
|
+
const figmaAngle = Math.atan2(-b, a) * (180 / Math.PI)
|
|
243
|
+
|
|
244
|
+
// Convert to CSS angle:
|
|
245
|
+
// CSS 0deg = up, 90deg = right, 180deg = down, 270deg = left (clockwise)
|
|
246
|
+
// Figma default horizontal gradient (0deg) should map to CSS 90deg
|
|
247
|
+
const cssAngle = 90 - figmaAngle
|
|
248
|
+
|
|
249
|
+
// Normalize to 0-360 range
|
|
250
|
+
return Math.round(((cssAngle % 360) + 360) % 360)
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Step-by-step explanation:**
|
|
255
|
+
|
|
256
|
+
1. Extract the direction vector `(a, b)` from the first column of the transform matrix
|
|
257
|
+
2. Compute the Figma angle using `atan2(-b, a)` — the negation of `b` accounts for Figma's Y-axis direction
|
|
258
|
+
3. Convert from Figma's convention (0 degrees = right, counter-clockwise) to CSS convention (0 degrees = up, clockwise) by computing `90 - figmaAngle`
|
|
259
|
+
4. Normalize the result to the 0-360 degree range using modular arithmetic
|
|
260
|
+
|
|
261
|
+
**Common angle examples:**
|
|
262
|
+
|
|
263
|
+
| Figma Transform (a, b) | Figma Angle | CSS Angle | Visual Direction |
|
|
264
|
+
|-------------------------|-------------|-----------|-----------------|
|
|
265
|
+
| `(1, 0)` | 0 degrees | 90deg | Left to right |
|
|
266
|
+
| `(0, -1)` | 90 degrees | 0deg | Bottom to top |
|
|
267
|
+
| `(-1, 0)` | 180 degrees | 270deg | Right to left |
|
|
268
|
+
| `(0, 1)` | -90 degrees | 180deg | Top to bottom |
|
|
269
|
+
|
|
270
|
+
#### Color Stop Formatting
|
|
271
|
+
|
|
272
|
+
Figma stop positions are 0-1 floats. CSS expects percentages.
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
const stops = gradient.stops
|
|
276
|
+
.map(stop => `${stop.color} ${Math.round(stop.position * 100)}%`)
|
|
277
|
+
.join(', ')
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
| Figma Stop | CSS Stop |
|
|
281
|
+
|------------|----------|
|
|
282
|
+
| `{ color: '#ff0000', position: 0 }` | `#ff0000 0%` |
|
|
283
|
+
| `{ color: '#0000ff', position: 0.5 }` | `#0000ff 50%` |
|
|
284
|
+
| `{ color: '#00ff00', position: 1 }` | `#00ff00 100%` |
|
|
285
|
+
|
|
286
|
+
#### Complete Gradient CSS Generation
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
function generateGradientCSS(gradient: GradientData): string {
|
|
290
|
+
const stops = gradient.stops
|
|
291
|
+
.map(stop => `${stop.color} ${Math.round(stop.position * 100)}%`)
|
|
292
|
+
.join(', ')
|
|
293
|
+
|
|
294
|
+
switch (gradient.type) {
|
|
295
|
+
case 'LINEAR': {
|
|
296
|
+
const angle = gradient.angle ?? 180
|
|
297
|
+
return `linear-gradient(${angle}deg, ${stops})`
|
|
298
|
+
}
|
|
299
|
+
case 'RADIAL':
|
|
300
|
+
return `radial-gradient(circle, ${stops})`
|
|
301
|
+
case 'ANGULAR':
|
|
302
|
+
return `conic-gradient(${stops})`
|
|
303
|
+
case 'DIAMOND':
|
|
304
|
+
// No direct CSS equivalent — approximate with radial
|
|
305
|
+
return `radial-gradient(circle, ${stops})`
|
|
306
|
+
default:
|
|
307
|
+
return `linear-gradient(180deg, ${stops})`
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**CSS output examples:**
|
|
313
|
+
|
|
314
|
+
```css
|
|
315
|
+
/* Linear gradient, 45 degrees */
|
|
316
|
+
background-image: linear-gradient(45deg, #ff0000 0%, #0000ff 100%);
|
|
317
|
+
|
|
318
|
+
/* Radial gradient */
|
|
319
|
+
background-image: radial-gradient(circle, #ffffff 0%, #000000 100%);
|
|
320
|
+
|
|
321
|
+
/* Conic/angular gradient */
|
|
322
|
+
background-image: conic-gradient(#ff0000 0%, #00ff00 33%, #0000ff 66%, #ff0000 100%);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
#### Gradient Text
|
|
326
|
+
|
|
327
|
+
When a text node has a gradient fill, use the gradient-text technique:
|
|
328
|
+
|
|
329
|
+
```css
|
|
330
|
+
background-image: linear-gradient(90deg, #ff0000 0%, #0000ff 100%);
|
|
331
|
+
background-clip: text;
|
|
332
|
+
-webkit-background-clip: text;
|
|
333
|
+
color: transparent;
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
### 3. Strokes → CSS Border / Box-Shadow
|
|
339
|
+
|
|
340
|
+
Figma strokes have color, weight, alignment, and optional per-side weights. The stroke alignment determines which CSS property to use.
|
|
341
|
+
|
|
342
|
+
#### Extracted Stroke Type
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
interface StrokeData {
|
|
346
|
+
color: string // Hex or rgba string
|
|
347
|
+
opacity: number // 0-1
|
|
348
|
+
weight: number // Uniform weight (0 if per-side)
|
|
349
|
+
align: 'INSIDE' | 'OUTSIDE' | 'CENTER'
|
|
350
|
+
weights?: { // Per-side weights (if different)
|
|
351
|
+
top: number
|
|
352
|
+
right: number
|
|
353
|
+
bottom: number
|
|
354
|
+
left: number
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
#### Stroke Alignment → CSS Property
|
|
360
|
+
|
|
361
|
+
Figma's stroke alignment determines where the stroke sits relative to the node boundary. Each alignment needs a different CSS approach.
|
|
362
|
+
|
|
363
|
+
| Figma `strokeAlign` | CSS Approach | Rationale |
|
|
364
|
+
|---------------------|-------------|-----------|
|
|
365
|
+
| `CENTER` | `border: Wpx solid color` | CSS `border` is painted centered on the edge (default CSS behavior) |
|
|
366
|
+
| `INSIDE` | `box-shadow: inset 0 0 0 Wpx color` | Inset shadow doesn't change element dimensions, unlike `border` which adds to width/height |
|
|
367
|
+
| `OUTSIDE` | `box-shadow: 0 0 0 Wpx color` or `outline: Wpx solid color` | Outset shadow or outline paints outside without affecting layout |
|
|
368
|
+
|
|
369
|
+
> **Important:** CSS `border` adds to the element's rendered dimensions (unless `box-sizing: border-box` is set). Figma's INSIDE stroke does NOT change the frame's dimensions. Using `box-shadow: inset` for INSIDE strokes preserves dimensional accuracy.
|
|
370
|
+
|
|
371
|
+
**CENTER stroke (default approach):**
|
|
372
|
+
|
|
373
|
+
```css
|
|
374
|
+
/* Figma: strokeAlign CENTER, weight 2px, color #333333 */
|
|
375
|
+
border: 2px solid #333333;
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**INSIDE stroke (use inset box-shadow):**
|
|
379
|
+
|
|
380
|
+
```css
|
|
381
|
+
/* Figma: strokeAlign INSIDE, weight 2px, color #333333 */
|
|
382
|
+
box-shadow: inset 0 0 0 2px #333333;
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**OUTSIDE stroke (use outset box-shadow or outline):**
|
|
386
|
+
|
|
387
|
+
```css
|
|
388
|
+
/* Figma: strokeAlign OUTSIDE, weight 2px, color #333333 */
|
|
389
|
+
box-shadow: 0 0 0 2px #333333;
|
|
390
|
+
/* Alternative: */
|
|
391
|
+
outline: 2px solid #333333;
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
> **Implementation note:** A simpler approach maps all strokes to `border`. The box-shadow approach for INSIDE/OUTSIDE is the recommended production pattern for pixel-accurate results.
|
|
395
|
+
|
|
396
|
+
#### Per-Side Stroke Weights
|
|
397
|
+
|
|
398
|
+
Figma supports different stroke weights per side via `strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`. These are available on frames and components.
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
// Detection: per-side weights exist when strokeWeight is mixed
|
|
402
|
+
// or when individual side weights differ from each other
|
|
403
|
+
if (stroke.weights) {
|
|
404
|
+
const { top, right, bottom, left } = stroke.weights
|
|
405
|
+
if (top > 0) styles.borderTop = `${top}px solid ${color}`
|
|
406
|
+
if (right > 0) styles.borderRight = `${right}px solid ${color}`
|
|
407
|
+
if (bottom > 0) styles.borderBottom = `${bottom}px solid ${color}`
|
|
408
|
+
if (left > 0) styles.borderLeft = `${left}px solid ${color}`
|
|
409
|
+
} else if (stroke.weight > 0) {
|
|
410
|
+
styles.border = `${stroke.weight}px solid ${color}`
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**CSS output for per-side strokes:**
|
|
415
|
+
|
|
416
|
+
```css
|
|
417
|
+
/* Only bottom border (common for underlines, dividers) */
|
|
418
|
+
border-bottom: 2px solid #e0e0e0;
|
|
419
|
+
|
|
420
|
+
/* Different top and bottom (card with header accent) */
|
|
421
|
+
border-top: 4px solid #1a73e8;
|
|
422
|
+
border-bottom: 1px solid #e0e0e0;
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
#### Dash Patterns
|
|
426
|
+
|
|
427
|
+
Figma's `strokeDashPattern` defines a dash-gap array (e.g., `[10, 5]` means 10px dash, 5px gap). CSS approximation:
|
|
428
|
+
|
|
429
|
+
| Figma `strokeDashPattern` | CSS `border-style` |
|
|
430
|
+
|---------------------------|-------------------|
|
|
431
|
+
| `[]` (empty/none) | `solid` |
|
|
432
|
+
| `[dash, gap]` | `dashed` |
|
|
433
|
+
| `[1, gap]` (dot pattern) | `dotted` |
|
|
434
|
+
|
|
435
|
+
CSS `dashed` and `dotted` do not support custom dash lengths. For exact dash patterns, SVG `stroke-dasharray` is required.
|
|
436
|
+
|
|
437
|
+
#### Stroke Visibility and Type Filtering
|
|
438
|
+
|
|
439
|
+
Only solid, visible strokes are processed during extraction:
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
for (const paint of strokes) {
|
|
443
|
+
if (!paint.visible || paint.type !== 'SOLID') continue
|
|
444
|
+
// ... extract stroke data
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Gradient strokes and image strokes are not supported in CSS border — they require SVG or more complex CSS workarounds (e.g., border-image).
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
### 4. Effects → CSS Shadows & Filters
|
|
453
|
+
|
|
454
|
+
Figma effects include shadows and blurs. Each effect has a type, visibility, and type-specific properties.
|
|
455
|
+
|
|
456
|
+
#### Extracted Effect Types
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
type EffectData =
|
|
460
|
+
| { type: 'DROP_SHADOW'; offset: { x: number; y: number }; radius: number; spread: number; color: string }
|
|
461
|
+
| { type: 'INNER_SHADOW'; offset: { x: number; y: number }; radius: number; spread: number; color: string }
|
|
462
|
+
| { type: 'BLUR'; radius: number }
|
|
463
|
+
| { type: 'BACKGROUND_BLUR'; radius: number }
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
#### Effect Type → CSS Property
|
|
467
|
+
|
|
468
|
+
| Figma Effect Type | CSS Property | CSS Syntax |
|
|
469
|
+
|-------------------|-------------|------------|
|
|
470
|
+
| `DROP_SHADOW` | `box-shadow` | `Xpx Ypx Rpx Spx color` |
|
|
471
|
+
| `INNER_SHADOW` | `box-shadow` (inset) | `inset Xpx Ypx Rpx Spx color` |
|
|
472
|
+
| `LAYER_BLUR` | `filter` | `blur(Rpx)` |
|
|
473
|
+
| `BACKGROUND_BLUR` | `backdrop-filter` | `blur(Rpx)` |
|
|
474
|
+
|
|
475
|
+
#### DROP_SHADOW → box-shadow
|
|
476
|
+
|
|
477
|
+
```css
|
|
478
|
+
/* Figma: DROP_SHADOW { offset: {x: 0, y: 4}, radius: 8, spread: 0, color: rgba(0,0,0,0.25) } */
|
|
479
|
+
box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.25);
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Parameter mapping:
|
|
483
|
+
|
|
484
|
+
| Figma Property | CSS `box-shadow` Position | Description |
|
|
485
|
+
|----------------|--------------------------|-------------|
|
|
486
|
+
| `offset.x` | 1st value | Horizontal offset |
|
|
487
|
+
| `offset.y` | 2nd value | Vertical offset |
|
|
488
|
+
| `radius` | 3rd value (blur-radius) | Gaussian blur radius |
|
|
489
|
+
| `spread` | 4th value (spread-radius) | Expand/contract shadow; defaults to 0 in Figma |
|
|
490
|
+
| `color` | 5th value | Shadow color with alpha |
|
|
491
|
+
|
|
492
|
+
#### INNER_SHADOW → box-shadow inset
|
|
493
|
+
|
|
494
|
+
Same parameters as DROP_SHADOW but with the `inset` keyword prepended:
|
|
495
|
+
|
|
496
|
+
```css
|
|
497
|
+
/* Figma: INNER_SHADOW { offset: {x: 0, y: 2}, radius: 4, spread: 0, color: rgba(0,0,0,0.10) } */
|
|
498
|
+
box-shadow: inset 0px 2px 4px 0px rgba(0, 0, 0, 0.10);
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
#### LAYER_BLUR → filter: blur()
|
|
502
|
+
|
|
503
|
+
Layer blur applies a Gaussian blur to the entire element and its contents.
|
|
504
|
+
|
|
505
|
+
```css
|
|
506
|
+
/* Figma: LAYER_BLUR { radius: 8 } */
|
|
507
|
+
filter: blur(8px);
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
#### BACKGROUND_BLUR → backdrop-filter: blur()
|
|
511
|
+
|
|
512
|
+
Background blur blurs the content behind the element (used for frosted glass effects). Requires the element to have a semi-transparent background.
|
|
513
|
+
|
|
514
|
+
```css
|
|
515
|
+
/* Figma: BACKGROUND_BLUR { radius: 20 } */
|
|
516
|
+
backdrop-filter: blur(20px);
|
|
517
|
+
-webkit-backdrop-filter: blur(20px); /* Safari support */
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
> **Browser support note:** `backdrop-filter` is widely supported but older browsers may need the `-webkit-` prefix. Always include both for cross-browser compatibility.
|
|
521
|
+
|
|
522
|
+
#### Multiple Effects
|
|
523
|
+
|
|
524
|
+
A single node can have multiple effects of different types. They combine in CSS according to their property:
|
|
525
|
+
|
|
526
|
+
- **Multiple shadows** (drop + inner): comma-separated in a single `box-shadow` declaration
|
|
527
|
+
- **Multiple filters**: space-separated in a single `filter` declaration
|
|
528
|
+
- **backdrop-filter**: only one `backdrop-filter` value per element
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
function generateEffectStyles(effects: EffectData[]): CSSStyles {
|
|
532
|
+
const shadows: string[] = []
|
|
533
|
+
const filters: string[] = []
|
|
534
|
+
|
|
535
|
+
for (const effect of effects) {
|
|
536
|
+
if (effect.type === 'DROP_SHADOW') {
|
|
537
|
+
shadows.push(`${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread}px ${effect.color}`)
|
|
538
|
+
} else if (effect.type === 'INNER_SHADOW') {
|
|
539
|
+
shadows.push(`inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread}px ${effect.color}`)
|
|
540
|
+
} else if (effect.type === 'BLUR') {
|
|
541
|
+
filters.push(`blur(${effect.radius}px)`)
|
|
542
|
+
} else if (effect.type === 'BACKGROUND_BLUR') {
|
|
543
|
+
styles.backdropFilter = `blur(${effect.radius}px)`
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (shadows.length > 0) {
|
|
548
|
+
styles.boxShadow = shadows.join(', ')
|
|
549
|
+
}
|
|
550
|
+
if (filters.length > 0) {
|
|
551
|
+
styles.filter = filters.join(' ')
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
**CSS output with multiple effects:**
|
|
557
|
+
|
|
558
|
+
```css
|
|
559
|
+
/* Drop shadow + inner shadow combined */
|
|
560
|
+
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.15), inset 0px 1px 2px 0px rgba(0, 0, 0, 0.06);
|
|
561
|
+
|
|
562
|
+
/* Blur filter */
|
|
563
|
+
filter: blur(4px);
|
|
564
|
+
|
|
565
|
+
/* Frosted glass: backdrop blur + semi-transparent background */
|
|
566
|
+
backdrop-filter: blur(20px);
|
|
567
|
+
-webkit-backdrop-filter: blur(20px);
|
|
568
|
+
background-color: rgba(255, 255, 255, 0.70);
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
#### Effect Visibility Filtering
|
|
572
|
+
|
|
573
|
+
Like fills, effects have a `visible` property. Always check before processing:
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
for (const effect of effects) {
|
|
577
|
+
if (!effect.visible) continue // Skip toggled-off effects
|
|
578
|
+
// ... process effect
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
### 5. Corner Radius
|
|
585
|
+
|
|
586
|
+
Corner radius controls the rounding of a node's corners. Figma supports both uniform and per-corner values.
|
|
587
|
+
|
|
588
|
+
#### Extracted Corner Radius Type
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
type CornerRadius = number | {
|
|
592
|
+
topLeft: number
|
|
593
|
+
topRight: number
|
|
594
|
+
bottomRight: number
|
|
595
|
+
bottomLeft: number
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
- `number` — Uniform radius applied to all four corners
|
|
600
|
+
- `object` — Per-corner radii (Figma returns this when `cornerRadius === figma.mixed`)
|
|
601
|
+
|
|
602
|
+
#### Uniform Radius → CSS
|
|
603
|
+
|
|
604
|
+
When all corners share the same value:
|
|
605
|
+
|
|
606
|
+
```css
|
|
607
|
+
/* Figma: cornerRadius = 8 */
|
|
608
|
+
border-radius: 8px;
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
#### Per-Corner Radius → CSS
|
|
612
|
+
|
|
613
|
+
When corners have different values, Figma returns individual properties (`topLeftRadius`, `topRightRadius`, `bottomRightRadius`, `bottomLeftRadius`):
|
|
614
|
+
|
|
615
|
+
```css
|
|
616
|
+
/* Figma: TL=8, TR=8, BR=0, BL=0 (rounded top only) */
|
|
617
|
+
border-radius: 8px 8px 0px 0px;
|
|
618
|
+
|
|
619
|
+
/* Figma: TL=16, TR=0, BR=16, BL=0 (diagonal corners) */
|
|
620
|
+
border-radius: 16px 0px 16px 0px;
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**CSS shorthand order:** `top-left top-right bottom-right bottom-left` (clockwise from top-left).
|
|
624
|
+
|
|
625
|
+
#### Shorthand Optimization
|
|
626
|
+
|
|
627
|
+
When extracting per-corner radii, check if all four values are equal. If so, use the shorter uniform syntax:
|
|
628
|
+
|
|
629
|
+
```typescript
|
|
630
|
+
function generateCornerRadiusStyles(cornerRadius: CornerRadius): CSSStyles {
|
|
631
|
+
if (typeof cornerRadius === 'number') {
|
|
632
|
+
if (cornerRadius > 0) {
|
|
633
|
+
return { borderRadius: `${cornerRadius}px` }
|
|
634
|
+
}
|
|
635
|
+
return {}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const { topLeft, topRight, bottomRight, bottomLeft } = cornerRadius
|
|
639
|
+
// Optimize: if all corners equal, use shorthand
|
|
640
|
+
if (topLeft === topRight && topRight === bottomRight && bottomRight === bottomLeft) {
|
|
641
|
+
if (topLeft > 0) return { borderRadius: `${topLeft}px` }
|
|
642
|
+
return {}
|
|
643
|
+
}
|
|
644
|
+
// Otherwise use full four-value syntax
|
|
645
|
+
return { borderRadius: `${topLeft}px ${topRight}px ${bottomRight}px ${bottomLeft}px` }
|
|
646
|
+
}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
#### Circle Detection
|
|
650
|
+
|
|
651
|
+
When `border-radius` equals or exceeds half the element's smallest dimension, the element becomes circular (or pill-shaped for rectangles):
|
|
652
|
+
|
|
653
|
+
```css
|
|
654
|
+
/* Circle: radius >= min(width, height) / 2 */
|
|
655
|
+
border-radius: 50%;
|
|
656
|
+
|
|
657
|
+
/* Pill shape: large radius on a rectangle */
|
|
658
|
+
border-radius: 9999px;
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
> **Detection heuristic:** If `cornerRadius >= min(width, height) / 2`, consider using `border-radius: 50%` for circles or `border-radius: 9999px` for pill shapes. The 9999px approach is commonly used because it works regardless of element dimensions.
|
|
662
|
+
|
|
663
|
+
#### Zero and Undefined Radius
|
|
664
|
+
|
|
665
|
+
| Value | Meaning | CSS Output |
|
|
666
|
+
|-------|---------|------------|
|
|
667
|
+
| `cornerRadius` not present on node | No rounding capability | No `border-radius` property |
|
|
668
|
+
| `cornerRadius === 0` | Explicitly no rounding | No `border-radius` property (omit) |
|
|
669
|
+
| `cornerRadius === undefined` (extracted) | No radius data | No `border-radius` property (omit) |
|
|
670
|
+
|
|
671
|
+
Both `0` and `undefined` result in no CSS output. Do not emit `border-radius: 0px` — it is redundant.
|
|
672
|
+
|
|
673
|
+
#### Variable Bindings for Radius
|
|
674
|
+
|
|
675
|
+
Corner radius values can be bound to Figma variables (see Section 7 for the general pattern). When a variable is bound:
|
|
676
|
+
|
|
677
|
+
```css
|
|
678
|
+
/* Variable-bound radius */
|
|
679
|
+
border-radius: var(--radius-md);
|
|
680
|
+
|
|
681
|
+
/* With token lookup fallback */
|
|
682
|
+
border-radius: var(--radius-md, 8px);
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
### 6. Opacity & Blend Modes
|
|
688
|
+
|
|
689
|
+
#### Node-Level Opacity
|
|
690
|
+
|
|
691
|
+
Every Figma node has an `opacity` property (0-1 float). Only emit CSS when opacity is less than 1:
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
function generateOpacityStyles(opacity: number): CSSStyles {
|
|
695
|
+
if (opacity < 1) {
|
|
696
|
+
return { opacity: String(opacity) }
|
|
697
|
+
}
|
|
698
|
+
return {}
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
```css
|
|
703
|
+
/* Figma: opacity = 0.5 */
|
|
704
|
+
opacity: 0.5;
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
#### Fill-Level Opacity
|
|
708
|
+
|
|
709
|
+
Individual fills have their own opacity. This is embedded in the color's alpha channel, NOT as a separate CSS property:
|
|
710
|
+
|
|
711
|
+
```
|
|
712
|
+
Node opacity = 0.8 → CSS: opacity: 0.8
|
|
713
|
+
Fill opacity = 0.5 → CSS: background-color: rgba(R, G, B, 0.5)
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
These are independent — they do NOT combine into a single CSS property.
|
|
717
|
+
|
|
718
|
+
#### Opacity Compounding (Visual Result)
|
|
719
|
+
|
|
720
|
+
The visual opacity of a fill is the product of node-level and fill-level opacity:
|
|
721
|
+
|
|
722
|
+
```
|
|
723
|
+
Visual opacity = node.opacity * fill.opacity
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
However, in CSS they are expressed separately:
|
|
727
|
+
|
|
728
|
+
```css
|
|
729
|
+
/* Node opacity 0.8, fill opacity 0.5 */
|
|
730
|
+
/* Visual result: 0.8 * 0.5 = 0.4 effective opacity */
|
|
731
|
+
opacity: 0.8;
|
|
732
|
+
background-color: rgba(255, 0, 0, 0.5);
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
> **Warning:** Do NOT multiply them together into a single property. CSS `opacity` affects the entire element and all its children, while fill alpha only affects that specific background layer.
|
|
736
|
+
|
|
737
|
+
#### Blend Modes
|
|
738
|
+
|
|
739
|
+
Figma's `blendMode` property maps to CSS `mix-blend-mode`. Most Figma blend modes have direct CSS equivalents:
|
|
740
|
+
|
|
741
|
+
| Figma `blendMode` | CSS `mix-blend-mode` |
|
|
742
|
+
|--------------------|---------------------|
|
|
743
|
+
| `NORMAL` | `normal` (default, omit) |
|
|
744
|
+
| `MULTIPLY` | `multiply` |
|
|
745
|
+
| `SCREEN` | `screen` |
|
|
746
|
+
| `OVERLAY` | `overlay` |
|
|
747
|
+
| `DARKEN` | `darken` |
|
|
748
|
+
| `LIGHTEN` | `lighten` |
|
|
749
|
+
| `COLOR_DODGE` | `color-dodge` |
|
|
750
|
+
| `COLOR_BURN` | `color-burn` |
|
|
751
|
+
| `HARD_LIGHT` | `hard-light` |
|
|
752
|
+
| `SOFT_LIGHT` | `soft-light` |
|
|
753
|
+
| `DIFFERENCE` | `difference` |
|
|
754
|
+
| `EXCLUSION` | `exclusion` |
|
|
755
|
+
| `HUE` | `hue` |
|
|
756
|
+
| `SATURATION` | `saturation` |
|
|
757
|
+
| `COLOR` | `color` |
|
|
758
|
+
| `LUMINOSITY` | `luminosity` |
|
|
759
|
+
|
|
760
|
+
**Figma-only blend modes** (no direct CSS equivalent):
|
|
761
|
+
|
|
762
|
+
| Figma `blendMode` | CSS Approximation |
|
|
763
|
+
|--------------------|-------------------|
|
|
764
|
+
| `PASS_THROUGH` | `normal` (default for groups; children blend independently) |
|
|
765
|
+
| `LINEAR_DODGE` | No equivalent (approximate with `screen`) |
|
|
766
|
+
| `LINEAR_BURN` | No equivalent (approximate with `multiply`) |
|
|
767
|
+
|
|
768
|
+
```css
|
|
769
|
+
/* Figma: blendMode MULTIPLY */
|
|
770
|
+
mix-blend-mode: multiply;
|
|
771
|
+
|
|
772
|
+
/* For backgrounds specifically */
|
|
773
|
+
background-blend-mode: overlay;
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
> **When to use which:** `mix-blend-mode` blends the element with content below it. `background-blend-mode` blends multiple background layers within the same element.
|
|
777
|
+
|
|
778
|
+
---
|
|
779
|
+
|
|
780
|
+
### 7. Variable Binding for Visual Properties
|
|
781
|
+
|
|
782
|
+
Figma Variables allow designers to bind property values to named tokens. When a visual property is variable-bound, the CSS output should use `var()` instead of raw values.
|
|
783
|
+
|
|
784
|
+
#### Variable Resolution Pipeline
|
|
785
|
+
|
|
786
|
+
```
|
|
787
|
+
Figma Variable ID → Variable Lookup → Variable Name → CSS Variable Name → var() Reference
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
**Name conversion:** Figma uses `/` as path separator (e.g., `color/link/default`). CSS uses `-` (e.g., `--color-link-default`).
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
function figmaVariableToCssName(variableName: string): string {
|
|
794
|
+
return '--' + variableName
|
|
795
|
+
.toLowerCase()
|
|
796
|
+
.replace(/\//g, '-') // Slashes to dashes
|
|
797
|
+
.replace(/\s+/g, '-') // Spaces to dashes
|
|
798
|
+
.replace(/[^a-z0-9-_]/g, '') // Remove invalid chars
|
|
799
|
+
}
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
| Figma Variable Name | CSS Variable |
|
|
803
|
+
|---------------------|-------------|
|
|
804
|
+
| `color/link/default` | `--color-link-default` |
|
|
805
|
+
| `color/background/primary` | `--color-background-primary` |
|
|
806
|
+
| `spacing/lg` | `--spacing-lg` |
|
|
807
|
+
| `radius/md` | `--radius-md` |
|
|
808
|
+
|
|
809
|
+
#### Paint-Level Variable Binding (Primary)
|
|
810
|
+
|
|
811
|
+
Figma binds variables at the paint (fill) level. Each paint object can have `boundVariables.color.id`:
|
|
812
|
+
|
|
813
|
+
```typescript
|
|
814
|
+
// Check paint-level binding first (preferred)
|
|
815
|
+
const paintBoundVars = (paint as any).boundVariables
|
|
816
|
+
if (paintBoundVars && paintBoundVars.color && paintBoundVars.color.id) {
|
|
817
|
+
const variable = await lookupVariable(paintBoundVars.color.id)
|
|
818
|
+
// variable.name → "color/link/default"
|
|
819
|
+
}
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
#### Node-Level Variable Binding (Fallback)
|
|
823
|
+
|
|
824
|
+
Some Figma API versions expose bindings at the node level instead of (or in addition to) the paint level:
|
|
825
|
+
|
|
826
|
+
```typescript
|
|
827
|
+
// Fallback: check node-level binding
|
|
828
|
+
const boundVars = (node as any).boundVariables
|
|
829
|
+
if (boundVars && boundVars.fills) {
|
|
830
|
+
// Multiple possible structures:
|
|
831
|
+
// 1. Array: boundVars.fills[index] = { id: 'VariableID:xxx' }
|
|
832
|
+
// 2. Nested: boundVars.fills[index] = { color: { id: 'VariableID:xxx' } }
|
|
833
|
+
// 3. Object: boundVars.fills = { '0': { ... } }
|
|
834
|
+
}
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
**Resolution order:**
|
|
838
|
+
1. Paint-level `paint.boundVariables.color.id` (preferred)
|
|
839
|
+
2. Node-level `node.boundVariables.fills[index].id` (fallback)
|
|
840
|
+
3. Node-level `node.boundVariables.fills[index].color.id` (alternate structure)
|
|
841
|
+
4. Node-level `node.boundVariables.fill.id` (singular form, some API versions)
|
|
842
|
+
|
|
843
|
+
#### Local vs External Library Variables
|
|
844
|
+
|
|
845
|
+
Variables can be local (defined in the same file) or from an external library. This affects fallback strategy:
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
function lookupFillColor(fill: FillData): ColorLookupResult {
|
|
849
|
+
if (fill.variable && fill.variable.name) {
|
|
850
|
+
const cssVarName = figmaVariableToCssName(fill.variable.name)
|
|
851
|
+
|
|
852
|
+
if (fill.variable.isLocal) {
|
|
853
|
+
// Local variable: no fallback needed (defined in our tokens.css)
|
|
854
|
+
return { value: `var(${cssVarName})` }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// External library variable: include raw value as fallback
|
|
858
|
+
return {
|
|
859
|
+
value: `var(${cssVarName}, ${fill.color})`,
|
|
860
|
+
comment: `/* fallback: "${fill.variable.name}" is from external library */`
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// No variable: use raw color
|
|
864
|
+
return { value: fill.color }
|
|
865
|
+
}
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
**CSS output:**
|
|
869
|
+
|
|
870
|
+
```css
|
|
871
|
+
/* Local variable — no fallback needed */
|
|
872
|
+
background-color: var(--color-primary);
|
|
873
|
+
|
|
874
|
+
/* External library variable — include fallback */
|
|
875
|
+
background-color: var(--color-link-default, #1a73e8); /* fallback: "color/link/default" is from external library */
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
#### Variables on Other Visual Properties
|
|
879
|
+
|
|
880
|
+
The same variable binding pattern applies to:
|
|
881
|
+
|
|
882
|
+
| Property | Variable Path | CSS Variable Example |
|
|
883
|
+
|----------|-------------|---------------------|
|
|
884
|
+
| Fill color | `paint.boundVariables.color` | `var(--color-primary)` |
|
|
885
|
+
| Stroke color | `paint.boundVariables.color` | `var(--color-border-default)` |
|
|
886
|
+
| Corner radius | `node.boundVariables.cornerRadius` | `var(--radius-md)` |
|
|
887
|
+
| Effect color | `effect.boundVariables.color` | `var(--color-shadow)` |
|
|
888
|
+
|
|
889
|
+
#### Token Lookup (Value-Based Resolution)
|
|
890
|
+
|
|
891
|
+
When no variable binding exists, colors can still be mapped to tokens via reverse lookup. The token system collects all colors, assigns semantic names, and creates a lookup map:
|
|
892
|
+
|
|
893
|
+
```typescript
|
|
894
|
+
// Build lookup: raw color value → CSS variable reference
|
|
895
|
+
const lookup: TokenLookup = {
|
|
896
|
+
colors: new Map([
|
|
897
|
+
['#1a73e8', 'var(--color-primary)'],
|
|
898
|
+
['#333333', 'var(--color-neutral-800)'],
|
|
899
|
+
]),
|
|
900
|
+
radii: new Map([
|
|
901
|
+
[8, 'var(--radius-md)'],
|
|
902
|
+
[16, 'var(--radius-lg)'],
|
|
903
|
+
]),
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Usage: resolve raw value to token
|
|
907
|
+
function lookupColor(lookup: TokenLookup, value: string): string {
|
|
908
|
+
const normalized = value.toLowerCase()
|
|
909
|
+
return lookup.colors.get(normalized) || value
|
|
910
|
+
}
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
**Hex normalization is critical:** Always lowercase hex values before lookup to ensure `#1A73E8` matches `#1a73e8`.
|
|
914
|
+
|
|
915
|
+
---
|
|
916
|
+
|
|
917
|
+
### 8. Common Pitfalls
|
|
918
|
+
|
|
919
|
+
#### RGB 0-1 Float Range (NOT 0-255)
|
|
920
|
+
|
|
921
|
+
Figma's RGB channels are **0-1 floats**, not 0-255 integers. This is the single most common conversion bug.
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
// WRONG: Using Figma values directly
|
|
925
|
+
`rgb(${color.r}, ${color.g}, ${color.b})` // rgb(0.2, 0.4, 0.6) — INVALID
|
|
926
|
+
|
|
927
|
+
// CORRECT: Multiply by 255 and round
|
|
928
|
+
`rgb(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)})`
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
#### Fill Order Reversal
|
|
932
|
+
|
|
933
|
+
Figma paints fills **bottom-to-top** (index 0 = bottom layer). CSS paints backgrounds **top-to-bottom** (first value = top layer). Forgetting to reverse causes layers to appear in the wrong visual order.
|
|
934
|
+
|
|
935
|
+
```typescript
|
|
936
|
+
// WRONG: Using Figma order directly
|
|
937
|
+
const backgrounds = fills.map(f => toCSS(f))
|
|
938
|
+
|
|
939
|
+
// CORRECT: Reverse for CSS order
|
|
940
|
+
const backgrounds = [...fills].reverse().map(f => toCSS(f))
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
#### INSIDE Stroke Dimension Impact
|
|
944
|
+
|
|
945
|
+
CSS `border` adds to the element's rendered dimensions. Figma's INSIDE stroke does not change the frame's bounding box. Using `border` for INSIDE strokes causes elements to be larger than designed.
|
|
946
|
+
|
|
947
|
+
```css
|
|
948
|
+
/* WRONG for INSIDE stroke: */
|
|
949
|
+
border: 2px solid #333; /* Element is now 4px wider and taller */
|
|
950
|
+
|
|
951
|
+
/* CORRECT for INSIDE stroke: */
|
|
952
|
+
box-shadow: inset 0 0 0 2px #333; /* No dimension change */
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
#### cornerRadius: undefined vs 0
|
|
956
|
+
|
|
957
|
+
Both `undefined` (property not present) and `0` (explicitly zero) mean no rounding. Do not emit `border-radius: 0px` — it is redundant and adds unnecessary CSS.
|
|
958
|
+
|
|
959
|
+
```typescript
|
|
960
|
+
// WRONG: Emitting zero radius
|
|
961
|
+
if (cornerRadius !== undefined) {
|
|
962
|
+
styles.borderRadius = `${cornerRadius}px` // Emits "border-radius: 0px"
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// CORRECT: Skip zero and undefined
|
|
966
|
+
if (cornerRadius !== undefined && cornerRadius !== 0) {
|
|
967
|
+
// Only emit when there is actual rounding
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
#### Gradient Angle Mismatch
|
|
972
|
+
|
|
973
|
+
Figma's 0 degrees is **not** CSS's 0 degrees. Directly using the Figma angle in CSS produces a rotated gradient.
|
|
974
|
+
|
|
975
|
+
```css
|
|
976
|
+
/* Figma says 0° (horizontal, left-to-right) */
|
|
977
|
+
|
|
978
|
+
/* WRONG: */
|
|
979
|
+
linear-gradient(0deg, ...) /* CSS 0° = bottom-to-top */
|
|
980
|
+
|
|
981
|
+
/* CORRECT: Apply conversion formula */
|
|
982
|
+
linear-gradient(90deg, ...) /* CSS 90° = left-to-right */
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Always use the `calculateGradientAngle()` conversion with the transform matrix, not any raw "angle" value from Figma.
|
|
986
|
+
|
|
987
|
+
#### Opacity Compounding
|
|
988
|
+
|
|
989
|
+
Node opacity and fill opacity are independent in CSS. Do not multiply them into a single value.
|
|
990
|
+
|
|
991
|
+
```css
|
|
992
|
+
/* Figma: node opacity 0.8, fill with rgba(255,0,0,0.5) */
|
|
993
|
+
|
|
994
|
+
/* WRONG: Combined opacity */
|
|
995
|
+
opacity: 0.4;
|
|
996
|
+
background-color: #ff0000;
|
|
997
|
+
|
|
998
|
+
/* CORRECT: Separate opacity sources */
|
|
999
|
+
opacity: 0.8;
|
|
1000
|
+
background-color: rgba(255, 0, 0, 0.5);
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
#### Invisible Fills and Effects
|
|
1004
|
+
|
|
1005
|
+
Figma allows toggling individual fill layers and effects on/off via the `visible` property. Always check visibility before processing:
|
|
1006
|
+
|
|
1007
|
+
```typescript
|
|
1008
|
+
// WRONG: Processing all fills
|
|
1009
|
+
for (const fill of fills) { /* ... */ }
|
|
1010
|
+
|
|
1011
|
+
// CORRECT: Filter invisible fills
|
|
1012
|
+
for (const fill of fills) {
|
|
1013
|
+
if (!fill.visible) continue
|
|
1014
|
+
// ... process fill
|
|
1015
|
+
}
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
#### Mixed Stroke Weight Handling
|
|
1019
|
+
|
|
1020
|
+
When `strokeWeight === figma.mixed`, individual side weights exist. Do not treat `figma.mixed` as a number — it will cause NaN in CSS output.
|
|
1021
|
+
|
|
1022
|
+
```typescript
|
|
1023
|
+
// WRONG: Using strokeWeight directly
|
|
1024
|
+
styles.border = `${node.strokeWeight}px solid ${color}` // May produce "Symbol(mixed)px"
|
|
1025
|
+
|
|
1026
|
+
// CORRECT: Check for mixed first
|
|
1027
|
+
if (node.strokeWeight === figma.mixed) {
|
|
1028
|
+
// Use per-side weights: strokeTopWeight, strokeRightWeight, etc.
|
|
1029
|
+
} else {
|
|
1030
|
+
styles.border = `${node.strokeWeight}px solid ${color}`
|
|
1031
|
+
}
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
#### Hex Normalization for Token Lookup
|
|
1035
|
+
|
|
1036
|
+
Colors must be normalized before looking up in the token map. Figma may return `#1A73E8` but the token was stored as `#1a73e8`.
|
|
1037
|
+
|
|
1038
|
+
```typescript
|
|
1039
|
+
// WRONG: Direct lookup
|
|
1040
|
+
lookup.colors.get(fill.color) // Misses due to case mismatch
|
|
1041
|
+
|
|
1042
|
+
// CORRECT: Normalize first
|
|
1043
|
+
lookup.colors.get(fill.color.toLowerCase())
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
---
|
|
1047
|
+
|
|
1048
|
+
### 9. Color Token Integration
|
|
1049
|
+
|
|
1050
|
+
Colors extracted from visual properties can be promoted to design tokens for reuse across the generated codebase. This section covers how the pipeline collects, names, and emits color tokens.
|
|
1051
|
+
|
|
1052
|
+
#### Color Collection
|
|
1053
|
+
|
|
1054
|
+
All colors are collected from a node tree by traversing fills, strokes, and effects. Each unique color (after normalization) is tracked with its usage count.
|
|
1055
|
+
|
|
1056
|
+
```typescript
|
|
1057
|
+
// Sources of colors:
|
|
1058
|
+
// 1. Solid fills → fill.color
|
|
1059
|
+
// 2. Strokes → stroke.color
|
|
1060
|
+
// 3. Shadow effects → effect.color
|
|
1061
|
+
// (Gradient stops are skipped in v1)
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
#### Semantic Color Naming
|
|
1065
|
+
|
|
1066
|
+
Colors are assigned semantic names based on HSL analysis:
|
|
1067
|
+
|
|
1068
|
+
| HSL Range | Semantic | CSS Variable Pattern |
|
|
1069
|
+
|-----------|----------|---------------------|
|
|
1070
|
+
| Saturation < 10% | `neutral` | `--color-neutral-{100-900}` (by lightness) |
|
|
1071
|
+
| Hue 0-20 or 340-360 | `error` | `--color-error` |
|
|
1072
|
+
| Hue 30-60 | `warning` | `--color-warning` |
|
|
1073
|
+
| Hue 90-150 | `success` | `--color-success` |
|
|
1074
|
+
| Most-used saturated color | `primary` | `--color-primary` |
|
|
1075
|
+
| Second most-used saturated | `secondary` | `--color-secondary` |
|
|
1076
|
+
| Remaining saturated | `accent` | `--color-accent`, `--color-accent-2`, ... |
|
|
1077
|
+
|
|
1078
|
+
**Neutral scale:** Maps lightness to a 100-900 scale (inverted: 100 = lightest, 900 = darkest):
|
|
1079
|
+
|
|
1080
|
+
```typescript
|
|
1081
|
+
const step = Math.round((1 - lightness / 100) * 8 + 1) * 100
|
|
1082
|
+
// Lightness 95% → step 100 (near white)
|
|
1083
|
+
// Lightness 50% → step 500 (medium gray)
|
|
1084
|
+
// Lightness 5% → step 900 (near black)
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
#### Effect Token Naming
|
|
1088
|
+
|
|
1089
|
+
Shadows and radii are also promoted to tokens using a size scale:
|
|
1090
|
+
|
|
1091
|
+
```
|
|
1092
|
+
Shadows: --shadow-sm, --shadow-md, --shadow-lg, --shadow-xl, --shadow-2xl
|
|
1093
|
+
Radii: --radius-sm, --radius-md, --radius-lg, --radius-xl, --radius-full
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
Shadow tokens are ordered by blur radius (smallest = `sm`). Radius tokens are ordered by pixel value.
|
|
1097
|
+
|
|
1098
|
+
---
|
|
1099
|
+
|
|
1100
|
+
### 10. Complete Visual Style Generation
|
|
1101
|
+
|
|
1102
|
+
All visual properties are generated together and merged into a single CSS object:
|
|
1103
|
+
|
|
1104
|
+
```typescript
|
|
1105
|
+
function generateVisualStyles(
|
|
1106
|
+
fills: FillData[] | undefined,
|
|
1107
|
+
strokes: StrokeData[] | undefined,
|
|
1108
|
+
effects: EffectData[] | undefined,
|
|
1109
|
+
cornerRadius: CornerRadius | undefined,
|
|
1110
|
+
opacity: number,
|
|
1111
|
+
lookup?: TokenLookup
|
|
1112
|
+
): CSSStyles {
|
|
1113
|
+
return {
|
|
1114
|
+
...generateBackgroundStyles(fills, lookup),
|
|
1115
|
+
...generateBorderStyles(strokes, lookup),
|
|
1116
|
+
...generateCornerRadiusStyles(cornerRadius, lookup),
|
|
1117
|
+
...generateEffectStyles(effects),
|
|
1118
|
+
...generateOpacityStyles(opacity),
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
**Complete CSS output example:**
|
|
1124
|
+
|
|
1125
|
+
```css
|
|
1126
|
+
/* A card component with all visual properties */
|
|
1127
|
+
background-color: var(--color-neutral-100);
|
|
1128
|
+
border: 1px solid var(--color-neutral-300);
|
|
1129
|
+
border-radius: var(--radius-lg);
|
|
1130
|
+
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.08);
|
|
1131
|
+
opacity: 0.95;
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
---
|
|
1135
|
+
|
|
1136
|
+
## Cross-References
|
|
1137
|
+
|
|
1138
|
+
- **`figma-api-rest.md`** — Node visual properties in the REST API response (`fills`, `strokes`, `effects`, `cornerRadius`, `opacity`, `blendMode`)
|
|
1139
|
+
- **`figma-api-variables.md`** — Variables API for resolving variable bindings (`boundVariables`, variable collections, modes)
|
|
1140
|
+
- **`design-to-code-layout.md`** — Companion module for layout (Auto Layout → Flexbox); visual and layout styles are generated independently and merged
|
|
1141
|
+
- **`design-to-code-typography.md`** — Text-specific fill handling (styled segments, per-character colors)
|
|
1142
|
+
- **`design-to-code-assets.md`** — Image asset resolution for IMAGE fills (export settings, deduplication, file naming), visual styles skipped for exported asset nodes
|
|
1143
|
+
- **`design-to-code-semantic.md`** — Semantic tag selection that determines whether visual styles are applied (skipped for `<img>` tags from vector containers), gradient text rendering integration
|
|
1144
|
+
- **`css-strategy.md`** — How visual CSS integrates with the layered CSS strategy (Tailwind + Custom Properties + CSS Modules). Property placement decision tree for visual properties (Layer 3).
|
|
1145
|
+
- **`design-tokens.md`** — Token extraction and promotion rules for colors, shadows, and radii collected from visual properties. HSL-based semantic color naming. Token lookup integration during CSS generation.
|