#
tokens: 8822/50000 1/52 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 2. Use http://codebase.md/glips/figma-context-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .changeset
│   ├── config.json
│   └── README.md
├── .env.example
├── .github
│   ├── actions
│   │   └── setup
│   │       └── action.yml
│   ├── changeset-beta-version.js
│   ├── changeset-version.js
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE
│   │   └── bug_report.md
│   └── workflows
│       ├── beta-release.yml
│       └── release.yml
├── .gitignore
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── CONTRIBUTING.md
├── docs
│   ├── cursor-MCP-settings.png
│   ├── figma-copy-link.png
│   └── verify-connection.png
├── eslint.config.js
├── jest.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.ja.md
├── README.ko.md
├── README.md
├── README.zh-cn.md
├── README.zh-tw.md
├── RELEASES.md
├── ROADMAP.md
├── server.json
├── src
│   ├── bin.ts
│   ├── config.ts
│   ├── extractors
│   │   ├── built-in.ts
│   │   ├── design-extractor.ts
│   │   ├── index.ts
│   │   ├── node-walker.ts
│   │   ├── README.md
│   │   └── types.ts
│   ├── index.ts
│   ├── mcp
│   │   ├── index.ts
│   │   └── tools
│   │       ├── download-figma-images-tool.ts
│   │       ├── get-figma-data-tool.ts
│   │       └── index.ts
│   ├── mcp-server.ts
│   ├── server.ts
│   ├── services
│   │   └── figma.ts
│   ├── tests
│   │   ├── benchmark.test.ts
│   │   └── integration.test.ts
│   ├── transformers
│   │   ├── component.ts
│   │   ├── effects.ts
│   │   ├── layout.ts
│   │   ├── style.ts
│   │   └── text.ts
│   └── utils
│       ├── common.ts
│       ├── fetch-with-retry.ts
│       ├── identity.ts
│       ├── image-processing.ts
│       └── logger.ts
├── tsconfig.json
└── tsup.config.ts
```

# Files

--------------------------------------------------------------------------------
/src/transformers/style.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type {
  2 |   Node as FigmaDocumentNode,
  3 |   Paint,
  4 |   Vector,
  5 |   RGBA,
  6 |   Transform,
  7 | } from "@figma/rest-api-spec";
  8 | import { generateCSSShorthand, isVisible } from "~/utils/common.js";
  9 | import { hasValue, isStrokeWeights } from "~/utils/identity.js";
 10 | 
 11 | export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
 12 | export type CSSHexColor = `#${string}`;
 13 | export interface ColorValue {
 14 |   hex: CSSHexColor;
 15 |   opacity: number;
 16 | }
 17 | 
 18 | /**
 19 |  * Simplified image fill with CSS properties and processing metadata
 20 |  *
 21 |  * This type represents an image fill that can be used as either:
 22 |  * - background-image (when parent node has children)
 23 |  * - <img> tag (when parent node has no children)
 24 |  *
 25 |  * The CSS properties are mutually exclusive based on usage context.
 26 |  */
 27 | export type SimplifiedImageFill = {
 28 |   type: "IMAGE";
 29 |   imageRef: string;
 30 |   scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH";
 31 |   /**
 32 |    * For TILE mode, the scaling factor relative to original image size
 33 |    */
 34 |   scalingFactor?: number;
 35 | 
 36 |   // CSS properties for background-image usage (when node has children)
 37 |   backgroundSize?: string;
 38 |   backgroundRepeat?: string;
 39 | 
 40 |   // CSS properties for <img> tag usage (when node has no children)
 41 |   isBackground?: boolean;
 42 |   objectFit?: string;
 43 | 
 44 |   // Image processing metadata (NOT for CSS translation)
 45 |   // Used by download tools to determine post-processing needs
 46 |   imageDownloadArguments?: {
 47 |     /**
 48 |      * Whether image needs cropping based on transform
 49 |      */
 50 |     needsCropping: boolean;
 51 |     /**
 52 |      * Whether CSS variables for dimensions are needed to calculate the background size for TILE mode
 53 |      *
 54 |      * Figma bases scalingFactor on the image's original size. In CSS, background size (as a percentage)
 55 |      * is calculated based on the size of the container. We need to pass back the original dimensions
 56 |      * after processing to calculate the intended background size when translated to code.
 57 |      */
 58 |     requiresImageDimensions: boolean;
 59 |     /**
 60 |      * Figma's transform matrix for Sharp processing
 61 |      */
 62 |     cropTransform?: Transform;
 63 |     /**
 64 |      * Suggested filename suffix to make cropped images unique
 65 |      * When the same imageRef is used multiple times with different crops,
 66 |      * this helps avoid overwriting conflicts
 67 |      */
 68 |     filenameSuffix?: string;
 69 |   };
 70 | };
 71 | 
 72 | export type SimplifiedGradientFill = {
 73 |   type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND";
 74 |   gradient: string;
 75 | };
 76 | 
 77 | export type SimplifiedPatternFill = {
 78 |   type: "PATTERN";
 79 |   patternSource: {
 80 |     /**
 81 |      * Hardcode to expect PNG for now, consider SVG detection in the future.
 82 |      *
 83 |      * SVG detection is a bit challenging because the nodeId in question isn't
 84 |      * guaranteed to be included in the response we're working with. No guaranteed
 85 |      * way to look into it and see if it's only composed of vector shapes.
 86 |      */
 87 |     type: "IMAGE-PNG";
 88 |     nodeId: string;
 89 |   };
 90 |   backgroundRepeat: string;
 91 |   backgroundSize: string;
 92 |   backgroundPosition: string;
 93 | };
 94 | 
 95 | export type SimplifiedFill =
 96 |   | SimplifiedImageFill
 97 |   | SimplifiedGradientFill
 98 |   | SimplifiedPatternFill
 99 |   | CSSRGBAColor
100 |   | CSSHexColor;
101 | 
102 | export type SimplifiedStroke = {
103 |   colors: SimplifiedFill[];
104 |   strokeWeight?: string;
105 |   strokeDashes?: number[];
106 |   strokeWeights?: string;
107 | };
108 | 
109 | /**
110 |  * Translate Figma scale modes to CSS properties based on usage context
111 |  *
112 |  * @param scaleMode - The Figma scale mode (FILL, FIT, TILE, STRETCH)
113 |  * @param isBackground - Whether this image will be used as background-image (true) or <img> tag (false)
114 |  * @param scalingFactor - For TILE mode, the scaling factor relative to original image size
115 |  * @returns Object containing CSS properties and processing metadata
116 |  */
117 | function translateScaleMode(
118 |   scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH",
119 |   hasChildren: boolean,
120 |   scalingFactor?: number,
121 | ): {
122 |   css: Partial<SimplifiedImageFill>;
123 |   processing: NonNullable<SimplifiedImageFill["imageDownloadArguments"]>;
124 | } {
125 |   const isBackground = hasChildren;
126 | 
127 |   switch (scaleMode) {
128 |     case "FILL":
129 |       // Image covers entire container, may be cropped
130 |       return {
131 |         css: isBackground
132 |           ? { backgroundSize: "cover", backgroundRepeat: "no-repeat", isBackground: true }
133 |           : { objectFit: "cover", isBackground: false },
134 |         processing: { needsCropping: false, requiresImageDimensions: false },
135 |       };
136 | 
137 |     case "FIT":
138 |       // Image fits entirely within container, may have empty space
139 |       return {
140 |         css: isBackground
141 |           ? { backgroundSize: "contain", backgroundRepeat: "no-repeat", isBackground: true }
142 |           : { objectFit: "contain", isBackground: false },
143 |         processing: { needsCropping: false, requiresImageDimensions: false },
144 |       };
145 | 
146 |     case "TILE":
147 |       // Image repeats to fill container at specified scale
148 |       // Always treat as background image (can't tile an <img> tag)
149 |       return {
150 |         css: {
151 |           backgroundRepeat: "repeat",
152 |           backgroundSize: scalingFactor
153 |             ? `calc(var(--original-width) * ${scalingFactor}) calc(var(--original-height) * ${scalingFactor})`
154 |             : "auto",
155 |           isBackground: true,
156 |         },
157 |         processing: { needsCropping: false, requiresImageDimensions: true },
158 |       };
159 | 
160 |     case "STRETCH":
161 |       // Figma calls crop "STRETCH" in its API.
162 |       return {
163 |         css: isBackground
164 |           ? { backgroundSize: "100% 100%", backgroundRepeat: "no-repeat", isBackground: true }
165 |           : { objectFit: "fill", isBackground: false },
166 |         processing: { needsCropping: false, requiresImageDimensions: false },
167 |       };
168 | 
169 |     default:
170 |       return {
171 |         css: {},
172 |         processing: { needsCropping: false, requiresImageDimensions: false },
173 |       };
174 |   }
175 | }
176 | 
177 | /**
178 |  * Generate a short hash from a transform matrix to create unique filenames
179 |  * @param transform - The transform matrix to hash
180 |  * @returns Short hash string for filename suffix
181 |  */
182 | function generateTransformHash(transform: Transform): string {
183 |   const values = transform.flat();
184 |   const hash = values.reduce((acc, val) => {
185 |     // Simple hash function - convert to string and create checksum
186 |     const str = val.toString();
187 |     for (let i = 0; i < str.length; i++) {
188 |       acc = ((acc << 5) - acc + str.charCodeAt(i)) & 0xffffffff;
189 |     }
190 |     return acc;
191 |   }, 0);
192 | 
193 |   // Convert to positive hex string, take first 6 chars
194 |   return Math.abs(hash).toString(16).substring(0, 6);
195 | }
196 | 
197 | /**
198 |  * Handle imageTransform for post-processing (not CSS translation)
199 |  *
200 |  * When Figma includes an imageTransform matrix, it means the image is cropped/transformed.
201 |  * This function converts the transform into processing instructions for Sharp.
202 |  *
203 |  * @param imageTransform - Figma's 2x3 transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]]
204 |  * @returns Processing metadata for image cropping
205 |  */
206 | function handleImageTransform(
207 |   imageTransform: Transform,
208 | ): NonNullable<SimplifiedImageFill["imageDownloadArguments"]> {
209 |   const transformHash = generateTransformHash(imageTransform);
210 |   return {
211 |     needsCropping: true,
212 |     requiresImageDimensions: false,
213 |     cropTransform: imageTransform,
214 |     filenameSuffix: `${transformHash}`,
215 |   };
216 | }
217 | 
218 | /**
219 |  * Build simplified stroke information from a Figma node
220 |  *
221 |  * @param n - The Figma node to extract stroke information from
222 |  * @param hasChildren - Whether the node has children (affects paint processing)
223 |  * @returns Simplified stroke object with colors and properties
224 |  */
225 | export function buildSimplifiedStrokes(
226 |   n: FigmaDocumentNode,
227 |   hasChildren: boolean = false,
228 | ): SimplifiedStroke {
229 |   let strokes: SimplifiedStroke = { colors: [] };
230 |   if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) {
231 |     strokes.colors = n.strokes.filter(isVisible).map((stroke) => parsePaint(stroke, hasChildren));
232 |   }
233 | 
234 |   if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {
235 |     strokes.strokeWeight = `${n.strokeWeight}px`;
236 |   }
237 | 
238 |   if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) {
239 |     strokes.strokeDashes = n.strokeDashes;
240 |   }
241 | 
242 |   if (hasValue("individualStrokeWeights", n, isStrokeWeights)) {
243 |     strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights);
244 |   }
245 | 
246 |   return strokes;
247 | }
248 | 
249 | /**
250 |  * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill
251 |  * @param raw - The Figma paint to convert
252 |  * @param hasChildren - Whether the node has children (determines CSS properties)
253 |  * @returns The converted SimplifiedFill
254 |  */
255 | export function parsePaint(raw: Paint, hasChildren: boolean = false): SimplifiedFill {
256 |   if (raw.type === "IMAGE") {
257 |     const baseImageFill: SimplifiedImageFill = {
258 |       type: "IMAGE",
259 |       imageRef: raw.imageRef,
260 |       scaleMode: raw.scaleMode as "FILL" | "FIT" | "TILE" | "STRETCH",
261 |       scalingFactor: raw.scalingFactor,
262 |     };
263 | 
264 |     // Get CSS properties and processing metadata from scale mode
265 |     // TILE mode always needs to be treated as background image (can't tile an <img> tag)
266 |     const isBackground = hasChildren || baseImageFill.scaleMode === "TILE";
267 |     const { css, processing } = translateScaleMode(
268 |       baseImageFill.scaleMode,
269 |       isBackground,
270 |       raw.scalingFactor,
271 |     );
272 | 
273 |     // Combine scale mode processing with transform processing if needed
274 |     // Transform processing (cropping) takes precedence over scale mode processing
275 |     let finalProcessing = processing;
276 |     if (raw.imageTransform) {
277 |       const transformProcessing = handleImageTransform(raw.imageTransform);
278 |       finalProcessing = {
279 |         ...processing,
280 |         ...transformProcessing,
281 |         // Keep requiresImageDimensions from scale mode (needed for TILE)
282 |         requiresImageDimensions:
283 |           processing.requiresImageDimensions || transformProcessing.requiresImageDimensions,
284 |       };
285 |     }
286 | 
287 |     return {
288 |       ...baseImageFill,
289 |       ...css,
290 |       imageDownloadArguments: finalProcessing,
291 |     };
292 |   } else if (raw.type === "SOLID") {
293 |     // treat as SOLID
294 |     const { hex, opacity } = convertColor(raw.color!, raw.opacity);
295 |     if (opacity === 1) {
296 |       return hex;
297 |     } else {
298 |       return formatRGBAColor(raw.color!, opacity);
299 |     }
300 |   } else if (raw.type === "PATTERN") {
301 |     return parsePatternPaint(raw);
302 |   } else if (
303 |     ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes(
304 |       raw.type,
305 |     )
306 |   ) {
307 |     return {
308 |       type: raw.type as
309 |         | "GRADIENT_LINEAR"
310 |         | "GRADIENT_RADIAL"
311 |         | "GRADIENT_ANGULAR"
312 |         | "GRADIENT_DIAMOND",
313 |       gradient: convertGradientToCss(raw),
314 |     };
315 |   } else {
316 |     throw new Error(`Unknown paint type: ${raw.type}`);
317 |   }
318 | }
319 | 
320 | /**
321 |  * Convert a Figma PatternPaint to a CSS-like pattern fill.
322 |  *
323 |  * Ignores `tileType` and `spacing` from the Figma API currently as there's
324 |  * no great way to translate them to CSS.
325 |  *
326 |  * @param raw - The Figma PatternPaint to convert
327 |  * @returns The converted pattern SimplifiedFill
328 |  */
329 | function parsePatternPaint(
330 |   raw: Extract<Paint, { type: "PATTERN" }>,
331 | ): Extract<SimplifiedFill, { type: "PATTERN" }> {
332 |   /**
333 |    * The only CSS-like repeat value supported by Figma is repeat.
334 |    *
335 |    * They also have hexagonal horizontal and vertical repeats, but
336 |    * those aren't easy to pull off in CSS, so we just use repeat.
337 |    */
338 |   let backgroundRepeat = "repeat";
339 | 
340 |   let horizontal = "left";
341 |   switch (raw.horizontalAlignment) {
342 |     case "START":
343 |       horizontal = "left";
344 |       break;
345 |     case "CENTER":
346 |       horizontal = "center";
347 |       break;
348 |     case "END":
349 |       horizontal = "right";
350 |       break;
351 |   }
352 | 
353 |   let vertical = "top";
354 |   switch (raw.verticalAlignment) {
355 |     case "START":
356 |       vertical = "top";
357 |       break;
358 |     case "CENTER":
359 |       vertical = "center";
360 |       break;
361 |     case "END":
362 |       vertical = "bottom";
363 |       break;
364 |   }
365 | 
366 |   return {
367 |     type: raw.type,
368 |     patternSource: {
369 |       type: "IMAGE-PNG",
370 |       nodeId: raw.sourceNodeId,
371 |     },
372 |     backgroundRepeat,
373 |     backgroundSize: `${Math.round(raw.scalingFactor * 100)}%`,
374 |     backgroundPosition: `${horizontal} ${vertical}`,
375 |   };
376 | }
377 | 
378 | /**
379 |  * Convert hex color value and opacity to rgba format
380 |  * @param hex - Hexadecimal color value (e.g., "#FF0000" or "#F00")
381 |  * @param opacity - Opacity value (0-1)
382 |  * @returns Color string in rgba format
383 |  */
384 | export function hexToRgba(hex: string, opacity: number = 1): string {
385 |   // Remove possible # prefix
386 |   hex = hex.replace("#", "");
387 | 
388 |   // Handle shorthand hex values (e.g., #FFF)
389 |   if (hex.length === 3) {
390 |     hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
391 |   }
392 | 
393 |   // Convert hex to RGB values
394 |   const r = parseInt(hex.substring(0, 2), 16);
395 |   const g = parseInt(hex.substring(2, 4), 16);
396 |   const b = parseInt(hex.substring(4, 6), 16);
397 | 
398 |   // Ensure opacity is in the 0-1 range
399 |   const validOpacity = Math.min(Math.max(opacity, 0), 1);
400 | 
401 |   return `rgba(${r}, ${g}, ${b}, ${validOpacity})`;
402 | }
403 | 
404 | /**
405 |  * Convert color from RGBA to { hex, opacity }
406 |  *
407 |  * @param color - The color to convert, including alpha channel
408 |  * @param opacity - The opacity of the color, if not included in alpha channel
409 |  * @returns The converted color
410 |  **/
411 | export function convertColor(color: RGBA, opacity = 1): ColorValue {
412 |   const r = Math.round(color.r * 255);
413 |   const g = Math.round(color.g * 255);
414 |   const b = Math.round(color.b * 255);
415 | 
416 |   // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative
417 |   const a = Math.round(opacity * color.a * 100) / 100;
418 | 
419 |   const hex = ("#" +
420 |     ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor;
421 | 
422 |   return { hex, opacity: a };
423 | }
424 | 
425 | /**
426 |  * Convert color from Figma RGBA to rgba(#, #, #, #) CSS format
427 |  *
428 |  * @param color - The color to convert, including alpha channel
429 |  * @param opacity - The opacity of the color, if not included in alpha channel
430 |  * @returns The converted color
431 |  **/
432 | export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor {
433 |   const r = Math.round(color.r * 255);
434 |   const g = Math.round(color.g * 255);
435 |   const b = Math.round(color.b * 255);
436 |   // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative
437 |   const a = Math.round(opacity * color.a * 100) / 100;
438 | 
439 |   return `rgba(${r}, ${g}, ${b}, ${a})`;
440 | }
441 | 
442 | /**
443 |  * Map gradient stops from Figma's handle-based coordinate system to CSS percentages
444 |  */
445 | function mapGradientStops(
446 |   gradient: Extract<
447 |     Paint,
448 |     { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" }
449 |   >,
450 |   elementBounds: { width: number; height: number } = { width: 1, height: 1 },
451 | ): { stops: string; cssGeometry: string } {
452 |   const handles = gradient.gradientHandlePositions;
453 |   if (!handles || handles.length < 2) {
454 |     const stops = gradient.gradientStops
455 |       .map(({ position, color }) => {
456 |         const cssColor = formatRGBAColor(color, 1);
457 |         return `${cssColor} ${Math.round(position * 100)}%`;
458 |       })
459 |       .join(", ");
460 |     return { stops, cssGeometry: "0deg" };
461 |   }
462 | 
463 |   const [handle1, handle2, handle3] = handles;
464 | 
465 |   switch (gradient.type) {
466 |     case "GRADIENT_LINEAR": {
467 |       return mapLinearGradient(gradient.gradientStops, handle1, handle2, elementBounds);
468 |     }
469 |     case "GRADIENT_RADIAL": {
470 |       return mapRadialGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds);
471 |     }
472 |     case "GRADIENT_ANGULAR": {
473 |       return mapAngularGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds);
474 |     }
475 |     case "GRADIENT_DIAMOND": {
476 |       return mapDiamondGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds);
477 |     }
478 |     default: {
479 |       const stops = gradient.gradientStops
480 |         .map(({ position, color }) => {
481 |           const cssColor = formatRGBAColor(color, 1);
482 |           return `${cssColor} ${Math.round(position * 100)}%`;
483 |         })
484 |         .join(", ");
485 |       return { stops, cssGeometry: "0deg" };
486 |     }
487 |   }
488 | }
489 | 
490 | /**
491 |  * Map linear gradient from Figma handles to CSS
492 |  */
493 | function mapLinearGradient(
494 |   gradientStops: { position: number; color: RGBA }[],
495 |   start: Vector,
496 |   end: Vector,
497 |   elementBounds: { width: number; height: number },
498 | ): { stops: string; cssGeometry: string } {
499 |   // Calculate the gradient line in element space
500 |   const dx = end.x - start.x;
501 |   const dy = end.y - start.y;
502 |   const gradientLength = Math.sqrt(dx * dx + dy * dy);
503 | 
504 |   // Handle degenerate case
505 |   if (gradientLength === 0) {
506 |     const stops = gradientStops
507 |       .map(({ position, color }) => {
508 |         const cssColor = formatRGBAColor(color, 1);
509 |         return `${cssColor} ${Math.round(position * 100)}%`;
510 |       })
511 |       .join(", ");
512 |     return { stops, cssGeometry: "0deg" };
513 |   }
514 | 
515 |   // Calculate angle for CSS
516 |   const angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90;
517 | 
518 |   // Find where the extended gradient line intersects the element boundaries
519 |   const extendedIntersections = findExtendedLineIntersections(start, end);
520 | 
521 |   if (extendedIntersections.length >= 2) {
522 |     // The gradient line extended to fill the element
523 |     const fullLineStart = Math.min(extendedIntersections[0], extendedIntersections[1]);
524 |     const fullLineEnd = Math.max(extendedIntersections[0], extendedIntersections[1]);
525 |     const fullLineLength = fullLineEnd - fullLineStart;
526 | 
527 |     // Map gradient stops from the Figma line segment to the full CSS line
528 |     const mappedStops = gradientStops.map(({ position, color }) => {
529 |       const cssColor = formatRGBAColor(color, 1);
530 | 
531 |       // Position along the Figma gradient line (0 = start handle, 1 = end handle)
532 |       const figmaLinePosition = position;
533 | 
534 |       // The Figma line spans from t=0 to t=1
535 |       // The full extended line spans from fullLineStart to fullLineEnd
536 |       // Map the figma position to the extended line
537 |       const tOnExtendedLine = figmaLinePosition * (1 - 0) + 0; // This is just figmaLinePosition
538 |       const extendedPosition = (tOnExtendedLine - fullLineStart) / (fullLineEnd - fullLineStart);
539 |       const clampedPosition = Math.max(0, Math.min(1, extendedPosition));
540 | 
541 |       return `${cssColor} ${Math.round(clampedPosition * 100)}%`;
542 |     });
543 | 
544 |     return {
545 |       stops: mappedStops.join(", "),
546 |       cssGeometry: `${Math.round(angle)}deg`,
547 |     };
548 |   }
549 | 
550 |   // Fallback to simple gradient if intersection calculation fails
551 |   const mappedStops = gradientStops.map(({ position, color }) => {
552 |     const cssColor = formatRGBAColor(color, 1);
553 |     return `${cssColor} ${Math.round(position * 100)}%`;
554 |   });
555 | 
556 |   return {
557 |     stops: mappedStops.join(", "),
558 |     cssGeometry: `${Math.round(angle)}deg`,
559 |   };
560 | }
561 | 
562 | /**
563 |  * Find where the extended gradient line intersects with the element boundaries
564 |  */
565 | function findExtendedLineIntersections(start: Vector, end: Vector): number[] {
566 |   const dx = end.x - start.x;
567 |   const dy = end.y - start.y;
568 | 
569 |   // Handle degenerate case
570 |   if (Math.abs(dx) < 1e-10 && Math.abs(dy) < 1e-10) {
571 |     return [];
572 |   }
573 | 
574 |   const intersections: number[] = [];
575 | 
576 |   // Check intersection with each edge of the unit square [0,1] x [0,1]
577 |   // Top edge (y = 0)
578 |   if (Math.abs(dy) > 1e-10) {
579 |     const t = -start.y / dy;
580 |     const x = start.x + t * dx;
581 |     if (x >= 0 && x <= 1) {
582 |       intersections.push(t);
583 |     }
584 |   }
585 | 
586 |   // Bottom edge (y = 1)
587 |   if (Math.abs(dy) > 1e-10) {
588 |     const t = (1 - start.y) / dy;
589 |     const x = start.x + t * dx;
590 |     if (x >= 0 && x <= 1) {
591 |       intersections.push(t);
592 |     }
593 |   }
594 | 
595 |   // Left edge (x = 0)
596 |   if (Math.abs(dx) > 1e-10) {
597 |     const t = -start.x / dx;
598 |     const y = start.y + t * dy;
599 |     if (y >= 0 && y <= 1) {
600 |       intersections.push(t);
601 |     }
602 |   }
603 | 
604 |   // Right edge (x = 1)
605 |   if (Math.abs(dx) > 1e-10) {
606 |     const t = (1 - start.x) / dx;
607 |     const y = start.y + t * dy;
608 |     if (y >= 0 && y <= 1) {
609 |       intersections.push(t);
610 |     }
611 |   }
612 | 
613 |   // Remove duplicates and sort
614 |   const uniqueIntersections = [
615 |     ...new Set(intersections.map((t) => Math.round(t * 1000000) / 1000000)),
616 |   ];
617 |   return uniqueIntersections.sort((a, b) => a - b);
618 | }
619 | 
620 | /**
621 |  * Find where a line intersects with the unit square (0,0) to (1,1)
622 |  */
623 | function findLineIntersections(start: Vector, end: Vector): number[] {
624 |   const dx = end.x - start.x;
625 |   const dy = end.y - start.y;
626 |   const intersections: number[] = [];
627 | 
628 |   // Check intersection with each edge of the unit square
629 |   const edges = [
630 |     { x: 0, y: 0, dx: 1, dy: 0 }, // top edge
631 |     { x: 1, y: 0, dx: 0, dy: 1 }, // right edge
632 |     { x: 1, y: 1, dx: -1, dy: 0 }, // bottom edge
633 |     { x: 0, y: 1, dx: 0, dy: -1 }, // left edge
634 |   ];
635 | 
636 |   for (const edge of edges) {
637 |     const t = lineIntersection(start, { x: dx, y: dy }, edge, { x: edge.dx, y: edge.dy });
638 |     if (t !== null && t >= 0 && t <= 1) {
639 |       intersections.push(t);
640 |     }
641 |   }
642 | 
643 |   return intersections.sort((a, b) => a - b);
644 | }
645 | 
646 | /**
647 |  * Calculate line intersection parameter
648 |  */
649 | function lineIntersection(
650 |   p1: Vector,
651 |   d1: Vector,
652 |   p2: { x: number; y: number },
653 |   d2: Vector,
654 | ): number | null {
655 |   const denominator = d1.x * d2.y - d1.y * d2.x;
656 |   if (Math.abs(denominator) < 1e-10) return null; // Lines are parallel
657 | 
658 |   const dx = p2.x - p1.x;
659 |   const dy = p2.y - p1.y;
660 |   const t = (dx * d2.y - dy * d2.x) / denominator;
661 | 
662 |   return t;
663 | }
664 | 
665 | /**
666 |  * Map radial gradient from Figma handles to CSS
667 |  */
668 | function mapRadialGradient(
669 |   gradientStops: { position: number; color: RGBA }[],
670 |   center: Vector,
671 |   edge: Vector,
672 |   widthHandle: Vector,
673 |   elementBounds: { width: number; height: number },
674 | ): { stops: string; cssGeometry: string } {
675 |   const centerX = Math.round(center.x * 100);
676 |   const centerY = Math.round(center.y * 100);
677 | 
678 |   const stops = gradientStops
679 |     .map(({ position, color }) => {
680 |       const cssColor = formatRGBAColor(color, 1);
681 |       return `${cssColor} ${Math.round(position * 100)}%`;
682 |     })
683 |     .join(", ");
684 | 
685 |   return {
686 |     stops,
687 |     cssGeometry: `circle at ${centerX}% ${centerY}%`,
688 |   };
689 | }
690 | 
691 | /**
692 |  * Map angular gradient from Figma handles to CSS
693 |  */
694 | function mapAngularGradient(
695 |   gradientStops: { position: number; color: RGBA }[],
696 |   center: Vector,
697 |   angleHandle: Vector,
698 |   widthHandle: Vector,
699 |   elementBounds: { width: number; height: number },
700 | ): { stops: string; cssGeometry: string } {
701 |   const centerX = Math.round(center.x * 100);
702 |   const centerY = Math.round(center.y * 100);
703 | 
704 |   const angle =
705 |     Math.atan2(angleHandle.y - center.y, angleHandle.x - center.x) * (180 / Math.PI) + 90;
706 | 
707 |   const stops = gradientStops
708 |     .map(({ position, color }) => {
709 |       const cssColor = formatRGBAColor(color, 1);
710 |       return `${cssColor} ${Math.round(position * 100)}%`;
711 |     })
712 |     .join(", ");
713 | 
714 |   return {
715 |     stops,
716 |     cssGeometry: `from ${Math.round(angle)}deg at ${centerX}% ${centerY}%`,
717 |   };
718 | }
719 | 
720 | /**
721 |  * Map diamond gradient from Figma handles to CSS (approximate with ellipse)
722 |  */
723 | function mapDiamondGradient(
724 |   gradientStops: { position: number; color: RGBA }[],
725 |   center: Vector,
726 |   edge: Vector,
727 |   widthHandle: Vector,
728 |   elementBounds: { width: number; height: number },
729 | ): { stops: string; cssGeometry: string } {
730 |   const centerX = Math.round(center.x * 100);
731 |   const centerY = Math.round(center.y * 100);
732 | 
733 |   const stops = gradientStops
734 |     .map(({ position, color }) => {
735 |       const cssColor = formatRGBAColor(color, 1);
736 |       return `${cssColor} ${Math.round(position * 100)}%`;
737 |     })
738 |     .join(", ");
739 | 
740 |   return {
741 |     stops,
742 |     cssGeometry: `ellipse at ${centerX}% ${centerY}%`,
743 |   };
744 | }
745 | 
746 | /**
747 |  * Convert a Figma gradient to CSS gradient syntax
748 |  */
749 | function convertGradientToCss(
750 |   gradient: Extract<
751 |     Paint,
752 |     { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" }
753 |   >,
754 | ): string {
755 |   // Sort stops by position to ensure proper order
756 |   const sortedGradient = {
757 |     ...gradient,
758 |     gradientStops: [...gradient.gradientStops].sort((a, b) => a.position - b.position),
759 |   };
760 | 
761 |   // Map gradient stops using handle-based geometry
762 |   const { stops, cssGeometry } = mapGradientStops(sortedGradient);
763 | 
764 |   switch (gradient.type) {
765 |     case "GRADIENT_LINEAR": {
766 |       return `linear-gradient(${cssGeometry}, ${stops})`;
767 |     }
768 | 
769 |     case "GRADIENT_RADIAL": {
770 |       return `radial-gradient(${cssGeometry}, ${stops})`;
771 |     }
772 | 
773 |     case "GRADIENT_ANGULAR": {
774 |       return `conic-gradient(${cssGeometry}, ${stops})`;
775 |     }
776 | 
777 |     case "GRADIENT_DIAMOND": {
778 |       return `radial-gradient(${cssGeometry}, ${stops})`;
779 |     }
780 | 
781 |     default:
782 |       return `linear-gradient(0deg, ${stops})`;
783 |   }
784 | }
785 | 
```
Page 2/2FirstPrevNextLast