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