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 |
```