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.
Files changed (34) hide show
  1. package/README.md +133 -0
  2. package/bin/install.js +328 -0
  3. package/knowledge/README.md +62 -0
  4. package/knowledge/css-strategy.md +973 -0
  5. package/knowledge/design-to-code-assets.md +855 -0
  6. package/knowledge/design-to-code-layout.md +929 -0
  7. package/knowledge/design-to-code-semantic.md +1085 -0
  8. package/knowledge/design-to-code-typography.md +1003 -0
  9. package/knowledge/design-to-code-visual.md +1145 -0
  10. package/knowledge/design-tokens-variables.md +1261 -0
  11. package/knowledge/design-tokens.md +960 -0
  12. package/knowledge/figma-api-devmode.md +894 -0
  13. package/knowledge/figma-api-plugin.md +920 -0
  14. package/knowledge/figma-api-rest.md +742 -0
  15. package/knowledge/figma-api-variables.md +848 -0
  16. package/knowledge/figma-api-webhooks.md +876 -0
  17. package/knowledge/payload-blocks.md +1184 -0
  18. package/knowledge/payload-figma-mapping.md +1210 -0
  19. package/knowledge/payload-visual-builder.md +1004 -0
  20. package/knowledge/plugin-architecture.md +1176 -0
  21. package/knowledge/plugin-best-practices.md +1206 -0
  22. package/knowledge/plugin-codegen.md +1313 -0
  23. package/package.json +31 -0
  24. package/skills/README.md +103 -0
  25. package/skills/audit-plugin/SKILL.md +244 -0
  26. package/skills/build-codegen-plugin/SKILL.md +279 -0
  27. package/skills/build-importer/SKILL.md +320 -0
  28. package/skills/build-plugin/SKILL.md +199 -0
  29. package/skills/build-token-pipeline/SKILL.md +363 -0
  30. package/skills/ref-html/SKILL.md +290 -0
  31. package/skills/ref-layout/SKILL.md +150 -0
  32. package/skills/ref-payload-block/SKILL.md +415 -0
  33. package/skills/ref-react/SKILL.md +222 -0
  34. 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.