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,1003 @@
1
+ # Text Extraction & CSS Typography
2
+
3
+ ## Purpose
4
+
5
+ Authoritative reference for extracting Figma text properties and generating accurate CSS typography. Encodes production-proven mapping rules covering the complete pipeline: extracting font families, weights, sizes, line heights, letter spacing, alignment, decoration, and text case from Figma TextNodes, handling mixed-style (rich text) segments with per-character styling and hyperlinks, and generating pixel-accurate CSS. This module covers everything needed to convert a Figma TEXT node into correct HTML and CSS.
6
+
7
+ ## When to Use
8
+
9
+ Reference this module when you need to:
10
+
11
+ - Convert a Figma `fontFamily` to a CSS `font-family` with appropriate fallback stack
12
+ - Map Figma font style names ("Semi Bold", "Bold Italic") to numeric CSS `font-weight`
13
+ - Convert Figma line height (percentage or pixel) to unitless CSS `line-height` ratio
14
+ - Handle Figma `letterSpacing` in pixels and percentages
15
+ - Map Figma text alignment (horizontal and vertical) to CSS properties
16
+ - Convert `textDecoration` and `textCase` to CSS `text-decoration` and `text-transform`
17
+ - Map `textAutoResize` behavior to CSS wrapping and overflow properties
18
+ - Extract and render multi-style text (styled segments with different colors, weights, sizes)
19
+ - Handle hyperlinks within text segments (`<a href="...">`)
20
+ - Generate ordered and unordered list HTML from Figma list metadata
21
+ - Apply typography token lookup for CSS custom property substitution
22
+ - Generate Google Fonts URLs from collected font data
23
+ - Avoid common typography conversion pitfalls (line height %, "Regular" weight, mixed-style nodes)
24
+
25
+ ---
26
+
27
+ ## Content
28
+
29
+ ### 1. Font Family Mapping
30
+
31
+ Figma stores the font family as a `fontFamily` string property on TextNode (e.g., `"Inter"`, `"Roboto Mono"`, `"SF Pro Display"`). This must be converted to a CSS `font-family` declaration with an appropriate fallback stack.
32
+
33
+ #### Extraction
34
+
35
+ ```typescript
36
+ // From Figma TextNode
37
+ const fontFamily: string = textNode.fontName.family
38
+ // e.g., "Inter", "Roboto", "Fira Code", "Georgia"
39
+ ```
40
+
41
+ When `fontName` is `figma.mixed` (the node has multiple fonts), extract the first character's font as the dominant family:
42
+
43
+ ```typescript
44
+ let fontName: FontName
45
+ if (typeof textNode.fontName === 'symbol') {
46
+ // Mixed fonts - use first character as dominant
47
+ fontName = textNode.getRangeFontName(0, 1) as FontName
48
+ } else {
49
+ fontName = textNode.fontName
50
+ }
51
+ ```
52
+
53
+ #### CSS Generation
54
+
55
+ **Default (no token lookup):** Wrap in quotes and add a generic fallback:
56
+
57
+ ```css
58
+ font-family: 'Inter', sans-serif;
59
+ font-family: 'Roboto Mono', monospace;
60
+ font-family: 'Georgia', serif;
61
+ ```
62
+
63
+ **With token lookup:** If a font family token exists, use the CSS custom property:
64
+
65
+ ```css
66
+ font-family: var(--font-primary);
67
+ font-family: var(--font-mono);
68
+ ```
69
+
70
+ #### Fallback Stack Selection
71
+
72
+ | Font Category | Detection | Fallback |
73
+ |---------------|-----------|----------|
74
+ | Sans-serif | Default assumption | `sans-serif` |
75
+ | Monospace | Name contains "mono", "code", "consolas", "courier", "menlo", etc. | `monospace` |
76
+ | Serif | Name contains "serif", "georgia", "times", "garamond", "baskerville" | `serif` |
77
+
78
+ #### Monospace Font Detection
79
+
80
+ The following font name patterns indicate a monospace font:
81
+
82
+ ```
83
+ mono, code, consolas, courier, fira code, jetbrains, menlo,
84
+ monaco, source code, ubuntu mono, sf mono, roboto mono, ibm plex mono
85
+ ```
86
+
87
+ Detection is case-insensitive substring matching against the font family name.
88
+
89
+ #### System Fonts
90
+
91
+ System fonts are already available on the user's OS and should NOT be loaded from Google Fonts. Common system fonts:
92
+
93
+ ```
94
+ Arial, Helvetica, Helvetica Neue, Times, Times New Roman, Georgia,
95
+ Verdana, Tahoma, Courier, Courier New, Lucida Console, Monaco,
96
+ Segoe UI, SF Pro, SF Pro Display, SF Pro Text, system-ui, -apple-system,
97
+ Optima, Futura, Avenir, Avenir Next, Gill Sans, Baskerville, Didot,
98
+ Consolas, Menlo, Palatino, Garamond, Century Gothic, Arial Black
99
+ ```
100
+
101
+ #### Google Fonts URL Generation
102
+
103
+ For non-system fonts, generate a Google Fonts `<link>` URL:
104
+
105
+ ```
106
+ https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Roboto+Mono:wght@400&display=swap
107
+ ```
108
+
109
+ **Rules:**
110
+ - Group variants by family
111
+ - Include all used weights per family
112
+ - Append `italic` suffix for italic variants (e.g., `700italic`)
113
+ - Replace spaces with `+` in family names
114
+ - Always add `display=swap` for performance
115
+ - Exclude system fonts from the URL
116
+
117
+ ---
118
+
119
+ ### 2. Font Weight from Style String
120
+
121
+ Figma does not store font weight as a number. Instead, it uses a style name string (e.g., `"Regular"`, `"Bold"`, `"Semi Bold"`, `"Bold Italic"`). This must be parsed into a numeric CSS `font-weight`.
122
+
123
+ #### Weight Mapping Table
124
+
125
+ | Figma Style Name | CSS `font-weight` | Notes |
126
+ |------------------|-------------------|-------|
127
+ | `Thin` | `100` | Hairline weight |
128
+ | `ExtraLight` | `200` | Also "Ultra Light" |
129
+ | `Light` | `300` | |
130
+ | `Regular` | `400` | Default. **NOT** output as `font-weight: regular` |
131
+ | `Medium` | `500` | |
132
+ | `SemiBold` | `600` | Also "Semi Bold", "Demi Bold" |
133
+ | `Bold` | `700` | |
134
+ | `ExtraBold` | `800` | Also "Ultra Bold" |
135
+ | `Black` | `900` | Also "Heavy" |
136
+
137
+ #### Extraction Logic
138
+
139
+ The style-to-weight mapping uses substring matching to handle compound styles:
140
+
141
+ ```typescript
142
+ function styleToWeight(style: string): number {
143
+ const weightMap: Record<string, number> = {
144
+ 'Thin': 100,
145
+ 'ExtraLight': 200,
146
+ 'Light': 300,
147
+ 'Regular': 400,
148
+ 'Medium': 500,
149
+ 'SemiBold': 600,
150
+ 'Bold': 700,
151
+ 'ExtraBold': 800,
152
+ 'Black': 900,
153
+ }
154
+
155
+ // Handle compound styles like "Bold Italic"
156
+ for (const [name, weight] of Object.entries(weightMap)) {
157
+ if (style.includes(name)) return weight
158
+ }
159
+ return 400 // Default to regular
160
+ }
161
+ ```
162
+
163
+ **Key behavior:** The loop checks in insertion order. "ExtraBold" is checked before "Bold", and "ExtraLight" before "Light", preventing false matches on compound names.
164
+
165
+ #### Italic Detection
166
+
167
+ Italic is detected separately from weight by checking if the style string contains "Italic":
168
+
169
+ ```typescript
170
+ if (text.font.style.toLowerCase().includes('italic')) {
171
+ styles.fontStyle = 'italic'
172
+ }
173
+ ```
174
+
175
+ #### Combined Style Examples
176
+
177
+ | Figma Style | CSS Output |
178
+ |-------------|------------|
179
+ | `"Regular"` | `font-weight: 400;` |
180
+ | `"Bold"` | `font-weight: 700;` |
181
+ | `"Bold Italic"` | `font-weight: 700; font-style: italic;` |
182
+ | `"SemiBold Italic"` | `font-weight: 600; font-style: italic;` |
183
+ | `"Light"` | `font-weight: 300;` |
184
+ | `"Thin Italic"` | `font-weight: 100; font-style: italic;` |
185
+
186
+ #### Token Integration
187
+
188
+ With token lookup, weight maps to a CSS variable:
189
+
190
+ ```css
191
+ /* Without tokens */
192
+ font-weight: 700;
193
+
194
+ /* With tokens */
195
+ font-weight: var(--font-bold);
196
+ ```
197
+
198
+ Token names follow the pattern `--font-{weightName}`:
199
+
200
+ | Weight | Token Name |
201
+ |--------|------------|
202
+ | 100 | `--font-thin` |
203
+ | 200 | `--font-extralight` |
204
+ | 300 | `--font-light` |
205
+ | 400 | `--font-regular` |
206
+ | 500 | `--font-medium` |
207
+ | 600 | `--font-semibold` |
208
+ | 700 | `--font-bold` |
209
+ | 800 | `--font-extrabold` |
210
+ | 900 | `--font-black` |
211
+
212
+ ---
213
+
214
+ ### 3. Font Size
215
+
216
+ Figma stores `fontSize` as a number in pixels (e.g., `16`, `24`, `48`).
217
+
218
+ #### Extraction
219
+
220
+ ```typescript
221
+ let fontSize: number
222
+ if (typeof textNode.fontSize === 'symbol') {
223
+ // Mixed sizes - use first character's size
224
+ fontSize = textNode.getRangeFontSize(0, 1) as number
225
+ } else {
226
+ fontSize = textNode.fontSize
227
+ }
228
+ ```
229
+
230
+ #### CSS Generation
231
+
232
+ **Default:** Direct pixel output:
233
+
234
+ ```css
235
+ font-size: 16px;
236
+ font-size: 24px;
237
+ ```
238
+
239
+ **With token lookup:** Map to a CSS variable using a type scale:
240
+
241
+ ```css
242
+ font-size: var(--text-base); /* 16px */
243
+ font-size: var(--text-lg); /* 18px */
244
+ font-size: var(--text-2xl); /* 24px */
245
+ ```
246
+
247
+ #### Type Scale Naming
248
+
249
+ Font size tokens use a T-shirt size scale centered on a "base" size (the collected size closest to 16px):
250
+
251
+ | Scale Name | Position Relative to Base |
252
+ |------------|--------------------------|
253
+ | `--text-xs` | base - 2 |
254
+ | `--text-sm` | base - 1 |
255
+ | `--text-base` | base (closest to 16px) |
256
+ | `--text-lg` | base + 1 |
257
+ | `--text-xl` | base + 2 |
258
+ | `--text-2xl` | base + 3 |
259
+ | `--text-3xl` | base + 4 |
260
+ | `--text-4xl` | base + 5 |
261
+ | `--text-5xl` | base + 6 |
262
+ | `--text-6xl` | base + 7 |
263
+
264
+ If a size falls outside the scale range, it uses `--text-{px}` (e.g., `--text-72`).
265
+
266
+ ---
267
+
268
+ ### 4. Line Height Conversion (Critical)
269
+
270
+ Line height is one of the most error-prone Figma-to-CSS conversions. Figma stores line height in multiple units, and all must be converted to **unitless CSS ratios** for correct scaling.
271
+
272
+ #### Figma Line Height Units
273
+
274
+ | Figma `lineHeight.unit` | Figma Value | Meaning |
275
+ |--------------------------|-------------|---------|
276
+ | `AUTO` | N/A | Browser default line height |
277
+ | `PERCENT` | e.g., `150` | 150% of font size |
278
+ | `PIXELS` | e.g., `24` | Fixed 24px line height |
279
+
280
+ #### Conversion Rules
281
+
282
+ ```typescript
283
+ function getLineHeight(lineHeight: LineHeight | symbol, fontSize: number): number | 'auto' {
284
+ if (typeof lineHeight === 'symbol') return 'auto' // figma.mixed
285
+ if (lineHeight.unit === 'AUTO') return 'auto'
286
+ if (lineHeight.unit === 'PERCENT') {
287
+ // Percentage → unitless ratio: 150% → 1.5
288
+ return lineHeight.value / 100
289
+ }
290
+ // PIXELS → unitless ratio: 24px on 16px font → 1.5
291
+ return lineHeight.value / fontSize
292
+ }
293
+ ```
294
+
295
+ #### Critical: Percentage Means % of Font Size
296
+
297
+ In Figma, a line height of "150%" means **150% of the font size**, not 150% of some other dimension. A 16px font with 150% line height has a computed line height of 24px.
298
+
299
+ **Conversion formula:** `lineHeightPercentage / 100 = unitless ratio`
300
+
301
+ - 120% -> `line-height: 1.2`
302
+ - 150% -> `line-height: 1.5`
303
+ - 200% -> `line-height: 2`
304
+
305
+ #### Critical: Always Use Unitless Ratios in CSS
306
+
307
+ **Why unitless?** Unitless `line-height` scales correctly when `font-size` changes (e.g., responsive scaling, user zoom). Pixel or percentage `line-height` in CSS does NOT scale.
308
+
309
+ ```css
310
+ /* CORRECT — unitless ratio, scales with font-size */
311
+ line-height: 1.50;
312
+
313
+ /* AVOID — fixed pixel value, does not scale */
314
+ line-height: 24px;
315
+
316
+ /* AVOID — percentage in CSS behaves differently than in Figma */
317
+ line-height: 150%;
318
+ ```
319
+
320
+ #### CSS Generation
321
+
322
+ ```typescript
323
+ if (text.lineHeight === 'auto') {
324
+ styles.lineHeight = 'normal'
325
+ } else {
326
+ styles.lineHeight = text.lineHeight.toFixed(2)
327
+ }
328
+ ```
329
+
330
+ | Figma Line Height | Font Size | CSS Output |
331
+ |-------------------|-----------|------------|
332
+ | AUTO | any | `line-height: normal;` |
333
+ | 150% | 16px | `line-height: 1.50;` |
334
+ | 120% | 16px | `line-height: 1.20;` |
335
+ | 24px | 16px | `line-height: 1.50;` |
336
+ | 144px | 128px | `line-height: 1.13;` |
337
+
338
+ ---
339
+
340
+ ### 5. Letter Spacing
341
+
342
+ Figma stores `letterSpacing` with a unit (PIXELS or PERCENT) and a numeric value.
343
+
344
+ #### Extraction
345
+
346
+ ```typescript
347
+ function getLetterSpacing(letterSpacing: LetterSpacing | symbol): number {
348
+ if (typeof letterSpacing === 'symbol') return 0 // figma.mixed
349
+ if (letterSpacing.unit === 'PERCENT') {
350
+ // Percentage of font size - returned as raw value for CSS gen
351
+ return letterSpacing.value
352
+ }
353
+ return letterSpacing.value // PIXELS
354
+ }
355
+ ```
356
+
357
+ #### CSS Generation
358
+
359
+ **Pixel values:** Output directly:
360
+
361
+ ```css
362
+ letter-spacing: 0.5px;
363
+ letter-spacing: -0.2px;
364
+ ```
365
+
366
+ **Percentage values:** Figma percentage letter spacing is relative to font size. In CSS, this maps to `em` units:
367
+
368
+ ```css
369
+ /* Figma: 5% letter spacing on any font size */
370
+ letter-spacing: 0.05em;
371
+
372
+ /* Figma: -2% letter spacing */
373
+ letter-spacing: -0.02em;
374
+ ```
375
+
376
+ **Zero or near-zero:** Omit the property entirely:
377
+
378
+ ```typescript
379
+ if (text.letterSpacing !== 0) {
380
+ styles.letterSpacing = `${text.letterSpacing}px`
381
+ }
382
+ ```
383
+
384
+ ---
385
+
386
+ ### 6. Text Alignment
387
+
388
+ Figma text nodes have both horizontal and vertical alignment.
389
+
390
+ #### Horizontal Alignment
391
+
392
+ | Figma `textAlignHorizontal` | CSS `text-align` |
393
+ |-----------------------------|------------------|
394
+ | `LEFT` | `left` |
395
+ | `CENTER` | `center` |
396
+ | `RIGHT` | `right` |
397
+ | `JUSTIFIED` | `justify` |
398
+
399
+ Extraction maps directly to CSS-ready values:
400
+
401
+ ```typescript
402
+ function mapHorizontalAlign(align: string): 'left' | 'center' | 'right' | 'justify' {
403
+ const map: Record<string, string> = {
404
+ 'LEFT': 'left',
405
+ 'CENTER': 'center',
406
+ 'RIGHT': 'right',
407
+ 'JUSTIFIED': 'justify',
408
+ }
409
+ return map[align] || 'left'
410
+ }
411
+ ```
412
+
413
+ #### Vertical Alignment
414
+
415
+ Figma text nodes support vertical alignment within their bounding box. This requires flexbox in CSS because `vertical-align` only works on inline/table elements.
416
+
417
+ | Figma `textAlignVertical` | CSS Output |
418
+ |---------------------------|------------|
419
+ | `TOP` | _(omit - default)_ |
420
+ | `CENTER` | `display: flex; align-items: center;` |
421
+ | `BOTTOM` | `display: flex; align-items: flex-end;` |
422
+
423
+ ```typescript
424
+ if (text.align.vertical !== 'top') {
425
+ styles.display = 'flex'
426
+ styles.alignItems = text.align.vertical === 'center' ? 'center' : 'flex-end'
427
+ }
428
+ ```
429
+
430
+ **Warning:** Adding `display: flex` to a text element for vertical alignment can conflict with the text's natural inline layout. This is an acceptable trade-off for fixed-dimension text boxes with vertical centering, but be aware of potential interaction with `text-align` (which still works on flex items via `justify-content` or `text-align` on the flex item's text content).
431
+
432
+ ---
433
+
434
+ ### 7. Text Decoration & Transform
435
+
436
+ #### Text Decoration
437
+
438
+ Figma uses `textDecoration` with values `NONE`, `UNDERLINE`, and `STRIKETHROUGH`.
439
+
440
+ | Figma `textDecoration` | CSS `text-decoration` |
441
+ |------------------------|-----------------------|
442
+ | `NONE` | _(omit)_ |
443
+ | `UNDERLINE` | `underline` |
444
+ | `STRIKETHROUGH` | `line-through` |
445
+
446
+ ```typescript
447
+ if (text.decoration !== 'none') {
448
+ styles.textDecoration = text.decoration === 'strikethrough'
449
+ ? 'line-through'
450
+ : text.decoration
451
+ }
452
+ ```
453
+
454
+ **Note:** Figma's `STRIKETHROUGH` maps to CSS `line-through`, not `strikethrough`. This is a common mapping error.
455
+
456
+ When `textDecoration` is `figma.mixed` (the symbol type), default to `'none'`:
457
+
458
+ ```typescript
459
+ function mapDecoration(decoration: string | symbol): 'none' | 'underline' | 'strikethrough' {
460
+ if (typeof decoration === 'symbol') return 'none' // figma.mixed
461
+ const map = { 'NONE': 'none', 'UNDERLINE': 'underline', 'STRIKETHROUGH': 'strikethrough' }
462
+ return map[decoration] || 'none'
463
+ }
464
+ ```
465
+
466
+ #### Text Transform (Case)
467
+
468
+ Figma uses `textCase` with values `ORIGINAL`, `UPPER`, `LOWER`, and `TITLE`.
469
+
470
+ | Figma `textCase` | CSS `text-transform` |
471
+ |-------------------|---------------------|
472
+ | `ORIGINAL` | _(omit)_ |
473
+ | `UPPER` | `uppercase` |
474
+ | `LOWER` | `lowercase` |
475
+ | `TITLE` | `capitalize` |
476
+
477
+ ```typescript
478
+ if (text.textCase !== 'none') {
479
+ styles.textTransform = text.textCase
480
+ }
481
+ ```
482
+
483
+ The extraction maps `ORIGINAL` to `'none'`, which is then omitted from CSS output.
484
+
485
+ ---
486
+
487
+ ### 8. Text Auto-Resize Behavior
488
+
489
+ Figma's `textAutoResize` property controls how a text node's bounding box responds to its content. This maps to CSS wrapping, overflow, and sizing behavior.
490
+
491
+ #### Mapping Table
492
+
493
+ | Figma `textAutoResize` | Behavior | CSS Output |
494
+ |-------------------------|----------|------------|
495
+ | `WIDTH_AND_HEIGHT` | Text box resizes both dimensions to fit content. Single line, no wrapping. | `white-space: nowrap;` |
496
+ | `HEIGHT` | Width is fixed, height grows to fit. Text wraps at fixed width. | _(default CSS behavior - no extra properties)_ |
497
+ | `NONE` | Both dimensions are fixed. Text may overflow or clip. | `overflow: hidden;` (may add `width` and `height`) |
498
+ | `TRUNCATE` | Fixed dimensions with ellipsis on overflow. | `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;` |
499
+
500
+ #### CSS Generation
501
+
502
+ ```typescript
503
+ // WIDTH_AND_HEIGHT: single-line text, no wrap
504
+ if (text.textAutoResize === 'WIDTH_AND_HEIGHT') {
505
+ styles.whiteSpace = 'nowrap'
506
+ }
507
+
508
+ // TRUNCATE: ellipsis overflow
509
+ if (text.textAutoResize === 'TRUNCATE') {
510
+ styles.overflow = 'hidden'
511
+ styles.textOverflow = 'ellipsis'
512
+ styles.whiteSpace = 'nowrap'
513
+ }
514
+
515
+ // NONE: fixed dimensions, clip overflow
516
+ if (text.textAutoResize === 'NONE') {
517
+ styles.overflow = 'hidden'
518
+ }
519
+
520
+ // HEIGHT: default wrapping behavior, no extra CSS needed
521
+ ```
522
+
523
+ #### Interaction with Layout Sizing
524
+
525
+ `textAutoResize` interacts with the parent Auto Layout's sizing modes:
526
+
527
+ - **WIDTH_AND_HEIGHT** in a FILL container: The `white-space: nowrap` prevents wrapping, but `flex-grow: 1` still allows the text to claim available space. The text will not wrap even if the container is narrower.
528
+ - **HEIGHT** in a FILL container: The text width fills the container and wraps normally. This is the most common scenario for body text.
529
+ - **TRUNCATE** in a fixed-width container: Ellipsis appears when text overflows the fixed width.
530
+
531
+ ---
532
+
533
+ ### 9. Styled Text Segments (Rich Text)
534
+
535
+ Figma supports per-character styling within a single TEXT node. A heading might have one word in bold and another in a different color. This is handled through **styled segments** -- ranges of characters with their own styling properties.
536
+
537
+ #### When Styled Segments Are Needed
538
+
539
+ Segment extraction is triggered when any of these conditions are true:
540
+
541
+ ```typescript
542
+ const hasMixedFills = typeof textNode.fills === 'symbol' // Different colors
543
+ const hasMixedFont = typeof textNode.fontName === 'symbol' // Different fonts
544
+ const hasMixedSize = typeof textNode.fontSize === 'symbol' // Different sizes
545
+ const hasMixedWeight = typeof textNode.fontWeight === 'symbol' // Different weights
546
+ const hasLinks = hasHyperlinks(textNode) // Hyperlinks present
547
+ ```
548
+
549
+ If none of these conditions are true, the text node has uniform styling and styled segments are not extracted (the node-level properties are sufficient).
550
+
551
+ #### Extraction via getStyledTextSegments
552
+
553
+ Figma's `getStyledTextSegments` API returns segments with consistent styling:
554
+
555
+ ```typescript
556
+ const segments = textNode.getStyledTextSegments([
557
+ 'fills',
558
+ 'fontName',
559
+ 'fontSize',
560
+ 'fontWeight',
561
+ 'letterSpacing',
562
+ 'lineHeight',
563
+ 'textDecoration',
564
+ 'textCase',
565
+ 'hyperlink',
566
+ ])
567
+ ```
568
+
569
+ Each segment contains:
570
+ - `start` / `end` -- character indices
571
+ - `characters` -- the text content
572
+ - `fills` -- array of paint fills for this segment
573
+ - `fontName` -- `{ family, style }` for this segment
574
+ - `fontSize` -- size for this segment
575
+ - `hyperlink` -- `{ type: 'URL' | 'NODE', value: string }` if linked
576
+
577
+ #### Intermediate Type
578
+
579
+ ```typescript
580
+ interface StyledSegment {
581
+ start: number
582
+ end: number
583
+ text: string
584
+ fills?: Array<{ color: string; opacity?: number; variable?: VariableReference }>
585
+ font?: {
586
+ family: string
587
+ style: string
588
+ size: number
589
+ weight: number
590
+ }
591
+ letterSpacing?: number
592
+ lineHeight?: number | 'auto'
593
+ decoration?: 'none' | 'underline' | 'strikethrough'
594
+ textCase?: 'none' | 'uppercase' | 'lowercase' | 'capitalize'
595
+ hyperlink?: HyperlinkData
596
+ }
597
+
598
+ interface HyperlinkData {
599
+ type: 'URL' | 'NODE'
600
+ value: string
601
+ }
602
+ ```
603
+
604
+ #### HTML Generation for Styled Segments
605
+
606
+ Each segment becomes a `<span>` (or `<a>` for hyperlinks) with only the properties that differ from the base text style:
607
+
608
+ ```html
609
+ <!-- Base style on parent element -->
610
+ <p class="hero__heading" style="font-family: 'Inter', sans-serif; font-size: 48px; font-weight: 700;">
611
+ <!-- Segment with different color -->
612
+ <span class="hero__heading__segment-1" style="color: #1A1A1A;">Build </span>
613
+ <!-- Segment with different color and weight -->
614
+ <span class="hero__heading__segment-2" style="color: #6366F1; font-weight: 800;">beautiful </span>
615
+ <!-- Segment matching base - still wrapped for consistency -->
616
+ <span class="hero__heading__segment-3" style="color: #1A1A1A;">interfaces</span>
617
+ </p>
618
+ ```
619
+
620
+ #### Hyperlink Segments
621
+
622
+ When a segment has a hyperlink, it renders as an `<a>` tag instead of `<span>`:
623
+
624
+ ```html
625
+ <p class="footer__text">
626
+ <span class="footer__text__segment-1">Read our </span>
627
+ <a class="footer__text__segment-2" href="https://example.com/privacy"
628
+ target="_blank" rel="noopener noreferrer">privacy policy</a>
629
+ <span class="footer__text__segment-3"> for details.</span>
630
+ </p>
631
+ ```
632
+
633
+ **Nested anchor prevention:** If the text node itself is already rendered as an `<a>` tag (e.g., from semantic name detection like "link-with-icon"), hyperlink segments render as `<span>` instead of `<a>` to avoid invalid nested anchor elements.
634
+
635
+ #### Hyperlink Detection
636
+
637
+ ```typescript
638
+ function hasHyperlinks(textNode: TextNode): boolean {
639
+ // Uniform hyperlink (all text is linked)
640
+ if (textNode.hyperlink && typeof textNode.hyperlink !== 'symbol') {
641
+ return true
642
+ }
643
+ // Mixed hyperlinks (some ranges are linked)
644
+ if (typeof textNode.hyperlink === 'symbol') {
645
+ return true
646
+ }
647
+ return false
648
+ }
649
+ ```
650
+
651
+ Hyperlinks can be of two types:
652
+ - `URL` -- external web link (`{ type: 'URL', value: 'https://...' }`)
653
+ - `NODE` -- internal Figma node link (`{ type: 'NODE', value: 'nodeId' }`) -- typically not converted to HTML href
654
+
655
+ #### Variable Binding on Segment Colors
656
+
657
+ Styled segments can have Figma Variables bound to their fill colors. The extraction resolves these asynchronously:
658
+
659
+ ```typescript
660
+ const variable = await extractPaintVariableAsync(fill)
661
+ // Returns: { id: string, name: string, isLocal: boolean } | undefined
662
+ ```
663
+
664
+ When a variable is present, the CSS uses `var()` instead of a raw hex value:
665
+
666
+ ```css
667
+ /* Local variable */
668
+ color: var(--color-link-default);
669
+
670
+ /* External library variable (with fallback) */
671
+ color: var(--color-brand-primary, #6366F1);
672
+ ```
673
+
674
+ ---
675
+
676
+ ### 10. List Styles
677
+
678
+ Figma text nodes can contain list formatting. The `listOptions` property on the text node indicates the list type.
679
+
680
+ #### Detection
681
+
682
+ ```typescript
683
+ const listOptions = (textNode as any).listOptions
684
+ if (listOptions && typeof listOptions !== 'symbol') {
685
+ if (listOptions.type === 'ORDERED') {
686
+ textData.listStyle = 'ordered'
687
+ } else if (listOptions.type === 'UNORDERED') {
688
+ textData.listStyle = 'unordered'
689
+ }
690
+ }
691
+ ```
692
+
693
+ **Note:** `listOptions` may not be available in all Figma API contexts. The extraction wraps access in a try/catch.
694
+
695
+ #### HTML Generation
696
+
697
+ List text is split by newlines, and each line becomes a `<li>`:
698
+
699
+ ```typescript
700
+ if (node.text.listStyle) {
701
+ const lines = node.text.content.split(/\r?\n/).filter(line => line.trim())
702
+ const listTag = node.text.listStyle === 'ordered' ? 'ol' : 'ul'
703
+
704
+ const listItems = lines.map((line, index) => ({
705
+ tag: 'li',
706
+ className: `${className}__item-${index + 1}`,
707
+ children: [line.trim()],
708
+ }))
709
+
710
+ // Wrap in list container
711
+ children.push({
712
+ tag: listTag,
713
+ className: `${className}__list`,
714
+ children: listItems,
715
+ })
716
+ }
717
+ ```
718
+
719
+ #### Generated HTML Examples
720
+
721
+ **Unordered list:**
722
+
723
+ ```html
724
+ <div class="features__text">
725
+ <ul class="features__text__list">
726
+ <li class="features__text__item-1">Fast performance</li>
727
+ <li class="features__text__item-2">Easy to use</li>
728
+ <li class="features__text__item-3">Beautiful design</li>
729
+ </ul>
730
+ </div>
731
+ ```
732
+
733
+ **Ordered list:**
734
+
735
+ ```html
736
+ <div class="steps__text">
737
+ <ol class="steps__text__list">
738
+ <li class="steps__text__item-1">Create your account</li>
739
+ <li class="steps__text__item-2">Choose a template</li>
740
+ <li class="steps__text__item-3">Publish your site</li>
741
+ </ol>
742
+ </div>
743
+ ```
744
+
745
+ ---
746
+
747
+ ### 11. Typography Token Integration
748
+
749
+ The typography token system collects font families, sizes, and weights from the entire extracted node tree and generates semantic CSS custom property names.
750
+
751
+ #### Token Collection
752
+
753
+ The `collectTypography` function traverses the ExtractedNode tree and collects:
754
+
755
+ - **Font families** -- unique family names with usage counts
756
+ - **Font sizes** -- unique pixel sizes with usage counts
757
+ - **Font weights** -- unique numeric weights with usage counts
758
+
759
+ ```typescript
760
+ interface TypographyTokens {
761
+ families: FontFamilyToken[] // e.g., [{ name: '--font-primary', value: 'Inter', usageCount: 42 }]
762
+ sizes: FontSizeToken[] // e.g., [{ name: '--text-base', value: 16, usageCount: 28 }]
763
+ weights: FontWeightToken[] // e.g., [{ name: '--font-bold', value: 700, usageCount: 15 }]
764
+ }
765
+ ```
766
+
767
+ #### Font Family Token Naming
768
+
769
+ Families are named based on their role:
770
+
771
+ | Role | Detection | Token Name |
772
+ |------|-----------|------------|
773
+ | Primary | Most-used font family | `--font-primary` |
774
+ | Monospace | Name matches monospace patterns | `--font-mono` |
775
+ | Secondary | Second font (first non-primary) | `--font-secondary` |
776
+ | Others | Remaining fonts by index | `--font-family-{n}` |
777
+
778
+ #### Font Size Token Naming
779
+
780
+ Sizes use the type scale (Section 3) with the "base" position anchored to the collected size closest to 16px.
781
+
782
+ **Example collected sizes:** `[12, 14, 16, 18, 24, 32, 48]`
783
+
784
+ | Size | Scale Position | Token Name |
785
+ |------|---------------|------------|
786
+ | 12px | base - 2 | `--text-xs` |
787
+ | 14px | base - 1 | `--text-sm` |
788
+ | 16px | base (closest to 16) | `--text-base` |
789
+ | 18px | base + 1 | `--text-lg` |
790
+ | 24px | base + 2 | `--text-xl` |
791
+ | 32px | base + 3 | `--text-2xl` |
792
+ | 48px | base + 4 | `--text-3xl` |
793
+
794
+ #### Font Weight Token Naming
795
+
796
+ Weights use standard CSS weight names:
797
+
798
+ | Weight | Token Name |
799
+ |--------|------------|
800
+ | 100 | `--font-thin` |
801
+ | 200 | `--font-extralight` |
802
+ | 300 | `--font-light` |
803
+ | 400 | `--font-regular` |
804
+ | 500 | `--font-medium` |
805
+ | 600 | `--font-semibold` |
806
+ | 700 | `--font-bold` |
807
+ | 800 | `--font-extrabold` |
808
+ | 900 | `--font-black` |
809
+
810
+ Non-standard weights (e.g., 450) use `--font-weight-{n}`.
811
+
812
+ #### Token Lookup During Generation
813
+
814
+ During CSS generation, raw values are checked against the token lookup map:
815
+
816
+ ```typescript
817
+ // Font family lookup
818
+ styles.fontFamily = lookupFontFamily(lookup, text.font.family)
819
+ // Returns: var(--font-primary) if found, else 'Inter', sans-serif
820
+
821
+ // Font size lookup
822
+ styles.fontSize = lookupFontSize(lookup, text.font.size)
823
+ // Returns: var(--text-lg) if found, else '18px'
824
+
825
+ // Font weight lookup
826
+ styles.fontWeight = lookupFontWeight(lookup, text.font.weight)
827
+ // Returns: var(--font-bold) if found, else '700'
828
+ ```
829
+
830
+ #### Generated CSS with Tokens
831
+
832
+ ```css
833
+ /* Without tokens */
834
+ .heading {
835
+ font-family: 'Inter', sans-serif;
836
+ font-size: 32px;
837
+ font-weight: 700;
838
+ line-height: 1.25;
839
+ }
840
+
841
+ /* With tokens */
842
+ .heading {
843
+ font-family: var(--font-primary);
844
+ font-size: var(--text-2xl);
845
+ font-weight: var(--font-bold);
846
+ line-height: 1.25;
847
+ }
848
+ ```
849
+
850
+ ---
851
+
852
+ ### 12. Common Pitfalls
853
+
854
+ #### Pitfall: Line Height Percentage Misinterpretation
855
+
856
+ **Problem:** Figma's 150% line height is treated as CSS `line-height: 150%` instead of `line-height: 1.5`.
857
+
858
+ **Rule:** Always convert Figma line height percentages to unitless ratios by dividing by 100. Figma's percentage means "% of font size", which is exactly what a unitless CSS `line-height` ratio represents. Never output `line-height: 150%` from Figma data.
859
+
860
+ ```css
861
+ /* CORRECT */
862
+ line-height: 1.50;
863
+
864
+ /* WRONG */
865
+ line-height: 150%;
866
+ line-height: 24px;
867
+ ```
868
+
869
+ #### Pitfall: "Regular" Is Not a CSS Font Weight
870
+
871
+ **Problem:** Outputting `font-weight: regular` instead of `font-weight: 400`.
872
+
873
+ **Rule:** The Figma style name "Regular" must be mapped to the numeric value `400`. CSS `font-weight` only accepts numbers (100-900) or keywords (`normal`, `bold`, `lighter`, `bolder`). The word "regular" is not valid CSS.
874
+
875
+ #### Pitfall: Mixed-Style Nodes Need Segment Extraction
876
+
877
+ **Problem:** Reading `textNode.fontName` on a node with mixed fonts returns `figma.mixed` (a Symbol), not a FontName object. Treating it as a string or object causes runtime errors.
878
+
879
+ **Rule:** Always check `typeof textNode.fontName === 'symbol'` before accessing `.family` or `.style`. When mixed, either:
880
+ 1. Use `getRangeFontName(0, 1)` for the dominant style
881
+ 2. Extract styled segments for per-character accuracy
882
+
883
+ The same applies to `fontSize`, `fills`, `fontWeight`, `letterSpacing`, `lineHeight`, `textDecoration`, and `textCase` -- all can be `figma.mixed`.
884
+
885
+ #### Pitfall: Vertical Alignment Requires Flex
886
+
887
+ **Problem:** Setting `vertical-align: middle` on a block-level text element (it only works on inline or table cells).
888
+
889
+ **Rule:** Figma's vertical text alignment (CENTER, BOTTOM) requires `display: flex` with `align-items` on the text element. This is the only reliable cross-browser approach for vertically aligning text within a fixed-height container.
890
+
891
+ ```css
892
+ /* CORRECT - vertical center */
893
+ display: flex;
894
+ align-items: center;
895
+ text-align: left;
896
+
897
+ /* WRONG */
898
+ vertical-align: middle;
899
+ ```
900
+
901
+ #### Pitfall: Flex on Text Conflicts with Inline Content
902
+
903
+ **Problem:** Adding `display: flex` for vertical alignment changes the text element from block/inline to flex container, which can affect how inline children (spans, links) are laid out.
904
+
905
+ **Rule:** When a text node has both vertical alignment (CENTER/BOTTOM) and styled segments, the flex container will lay out the `<span>` children as flex items. This is generally acceptable because text spans naturally flow inline-like within a flex container with `flex-wrap: wrap`, but test with long mixed-style text.
906
+
907
+ #### Pitfall: Strikethrough Mapping
908
+
909
+ **Problem:** Using CSS `text-decoration: strikethrough` (not valid CSS).
910
+
911
+ **Rule:** Figma's `STRIKETHROUGH` maps to CSS `text-decoration: line-through`. The extraction stores the intermediate value as `'strikethrough'` and the generation step converts it:
912
+
913
+ ```typescript
914
+ styles.textDecoration = text.decoration === 'strikethrough' ? 'line-through' : text.decoration
915
+ ```
916
+
917
+ #### Pitfall: Text Color Comes from Fills, Not a Color Property
918
+
919
+ **Problem:** Looking for a `color` property on Figma text nodes.
920
+
921
+ **Rule:** Figma text nodes use the `fills` array for their text color, the same way non-text nodes use fills for background color. For text nodes, the first SOLID fill becomes `color` (not `background-color`):
922
+
923
+ ```typescript
924
+ if (node.text && node.fills && node.fills.length > 0) {
925
+ const fill = node.fills[0]
926
+ if (fill.type === 'SOLID') {
927
+ styles.color = fill.color // Text color, NOT background
928
+ } else if (fill.type === 'GRADIENT') {
929
+ // Gradient text uses background-clip technique
930
+ styles.backgroundImage = gradientCSS
931
+ styles.backgroundClip = 'text'
932
+ styles.WebkitBackgroundClip = 'text'
933
+ styles.color = 'transparent'
934
+ }
935
+ }
936
+ ```
937
+
938
+ #### Pitfall: Gradient Text Requires Special CSS
939
+
940
+ **Problem:** Applying a gradient fill to text as `background-color`.
941
+
942
+ **Rule:** Gradient text in CSS requires the `background-clip: text` technique:
943
+
944
+ ```css
945
+ .gradient-text {
946
+ background-image: linear-gradient(90deg, #6366F1 0%, #EC4899 100%);
947
+ background-clip: text;
948
+ -webkit-background-clip: text;
949
+ color: transparent;
950
+ }
951
+ ```
952
+
953
+ #### Edge Case: Paragraph Spacing
954
+
955
+ Figma's `paragraphSpacing` (space between paragraphs separated by newlines) does not have a direct CSS equivalent. When present and non-zero, it can be approximated with `margin-bottom` on paragraph elements or `padding-bottom` on line groups, but this requires splitting text at paragraph boundaries.
956
+
957
+ #### Edge Case: Single Segment Optimization
958
+
959
+ If `getStyledTextSegments` returns only one segment and there are no hyperlinks, the styled segments are discarded and the node-level properties are used instead. This avoids unnecessary `<span>` wrapping for uniformly-styled text.
960
+
961
+ ---
962
+
963
+ ### Intermediate Type Reference
964
+
965
+ The complete intermediate type used between extraction and generation:
966
+
967
+ ```typescript
968
+ interface TextData {
969
+ content: string
970
+ font: {
971
+ family: string // e.g., "Inter"
972
+ style: string // e.g., "Bold Italic"
973
+ size: number // e.g., 16
974
+ weight: number // e.g., 700
975
+ }
976
+ align: {
977
+ horizontal: 'left' | 'center' | 'right' | 'justify'
978
+ vertical: 'top' | 'center' | 'bottom'
979
+ }
980
+ letterSpacing: number // In pixels (0 = omit)
981
+ lineHeight: number | 'auto' // Unitless ratio or 'auto'
982
+ paragraphSpacing: number // Pixels between paragraphs
983
+ decoration: 'none' | 'underline' | 'strikethrough'
984
+ textCase: 'none' | 'uppercase' | 'lowercase' | 'capitalize'
985
+ textAutoResize?: 'NONE' | 'WIDTH_AND_HEIGHT' | 'HEIGHT' | 'TRUNCATE'
986
+ listStyle?: 'ordered' | 'unordered'
987
+ styledSegments?: StyledSegment[]
988
+ }
989
+ ```
990
+
991
+ ---
992
+
993
+ ## Cross-References
994
+
995
+ - **`figma-api-rest.md`** -- Node text properties in REST API response (`characters`, `style` object with font family/weight/size), text node type identification
996
+ - **`figma-api-plugin.md`** -- TextNode API, `fontName`, `fontSize`, `textAlignHorizontal/Vertical`, `textAutoResize`, `getStyledTextSegments()`, `getRangeFontName()`, `getRangeHyperlink()`, `hyperlink`, `listOptions`, `figma.mixed` handling
997
+ - **`figma-api-variables.md`** -- Variable resolution for text fill colors, `figma.variables.getVariableByIdAsync()`, `boundVariables` on SolidPaint fills, local vs external variable handling
998
+ - **`design-to-code-layout.md`** -- Parent Auto Layout context that determines text sizing behavior (FILL width, HUG height), flex container interaction with vertical text alignment
999
+ - **`design-to-code-visual.md`** -- Fill extraction patterns shared with text color extraction (SOLID, GRADIENT), RGB-to-hex conversion, opacity handling, variable binding resolution
1000
+ - **`design-to-code-assets.md`** -- Asset detection on text nodes with image fills (image fill on text = background image, not image replacement)
1001
+ - **`design-to-code-semantic.md`** -- Semantic HTML tag selection for text elements (h1-h6, p, span, a), heading hierarchy with single h1 rule, BEM class naming for text segments, interactive context preventing headings inside buttons/links
1002
+ - **`css-strategy.md`** -- Typography tokens as CSS custom properties in the tokens layer (Layer 2), font-family in Tailwind config, property placement for typography properties (Layer 3).
1003
+ - **`design-tokens.md`** -- Typography token extraction (font families, sizes, weights), type scale naming, token lookup during CSS generation, threshold-based promotion.