Skip to main content
Glama

Figma MCP Server

MIT License
106,929
10,969
  • Linux
  • Apple
style.ts24.2 kB
import type { Node as FigmaDocumentNode, Paint, Vector, RGBA, Transform, } from "@figma/rest-api-spec"; import { generateCSSShorthand, isVisible } from "~/utils/common.js"; import { hasValue, isStrokeWeights } from "~/utils/identity.js"; export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`; export type CSSHexColor = `#${string}`; export interface ColorValue { hex: CSSHexColor; opacity: number; } /** * Simplified image fill with CSS properties and processing metadata * * This type represents an image fill that can be used as either: * - background-image (when parent node has children) * - <img> tag (when parent node has no children) * * The CSS properties are mutually exclusive based on usage context. */ export type SimplifiedImageFill = { type: "IMAGE"; imageRef: string; scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH"; /** * For TILE mode, the scaling factor relative to original image size */ scalingFactor?: number; // CSS properties for background-image usage (when node has children) backgroundSize?: string; backgroundRepeat?: string; // CSS properties for <img> tag usage (when node has no children) isBackground?: boolean; objectFit?: string; // Image processing metadata (NOT for CSS translation) // Used by download tools to determine post-processing needs imageDownloadArguments?: { /** * Whether image needs cropping based on transform */ needsCropping: boolean; /** * Whether CSS variables for dimensions are needed to calculate the background size for TILE mode * * Figma bases scalingFactor on the image's original size. In CSS, background size (as a percentage) * is calculated based on the size of the container. We need to pass back the original dimensions * after processing to calculate the intended background size when translated to code. */ requiresImageDimensions: boolean; /** * Figma's transform matrix for Sharp processing */ cropTransform?: Transform; /** * Suggested filename suffix to make cropped images unique * When the same imageRef is used multiple times with different crops, * this helps avoid overwriting conflicts */ filenameSuffix?: string; }; }; export type SimplifiedGradientFill = { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND"; gradient: string; }; export type SimplifiedPatternFill = { type: "PATTERN"; patternSource: { /** * Hardcode to expect PNG for now, consider SVG detection in the future. * * SVG detection is a bit challenging because the nodeId in question isn't * guaranteed to be included in the response we're working with. No guaranteed * way to look into it and see if it's only composed of vector shapes. */ type: "IMAGE-PNG"; nodeId: string; }; backgroundRepeat: string; backgroundSize: string; backgroundPosition: string; }; export type SimplifiedFill = | SimplifiedImageFill | SimplifiedGradientFill | SimplifiedPatternFill | CSSRGBAColor | CSSHexColor; export type SimplifiedStroke = { colors: SimplifiedFill[]; strokeWeight?: string; strokeDashes?: number[]; strokeWeights?: string; }; /** * Translate Figma scale modes to CSS properties based on usage context * * @param scaleMode - The Figma scale mode (FILL, FIT, TILE, STRETCH) * @param isBackground - Whether this image will be used as background-image (true) or <img> tag (false) * @param scalingFactor - For TILE mode, the scaling factor relative to original image size * @returns Object containing CSS properties and processing metadata */ function translateScaleMode( scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH", hasChildren: boolean, scalingFactor?: number, ): { css: Partial<SimplifiedImageFill>; processing: NonNullable<SimplifiedImageFill["imageDownloadArguments"]>; } { const isBackground = hasChildren; switch (scaleMode) { case "FILL": // Image covers entire container, may be cropped return { css: isBackground ? { backgroundSize: "cover", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "cover", isBackground: false }, processing: { needsCropping: false, requiresImageDimensions: false }, }; case "FIT": // Image fits entirely within container, may have empty space return { css: isBackground ? { backgroundSize: "contain", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "contain", isBackground: false }, processing: { needsCropping: false, requiresImageDimensions: false }, }; case "TILE": // Image repeats to fill container at specified scale // Always treat as background image (can't tile an <img> tag) return { css: { backgroundRepeat: "repeat", backgroundSize: scalingFactor ? `calc(var(--original-width) * ${scalingFactor}) calc(var(--original-height) * ${scalingFactor})` : "auto", isBackground: true, }, processing: { needsCropping: false, requiresImageDimensions: true }, }; case "STRETCH": // Figma calls crop "STRETCH" in its API. return { css: isBackground ? { backgroundSize: "100% 100%", backgroundRepeat: "no-repeat", isBackground: true } : { objectFit: "fill", isBackground: false }, processing: { needsCropping: false, requiresImageDimensions: false }, }; default: return { css: {}, processing: { needsCropping: false, requiresImageDimensions: false }, }; } } /** * Generate a short hash from a transform matrix to create unique filenames * @param transform - The transform matrix to hash * @returns Short hash string for filename suffix */ function generateTransformHash(transform: Transform): string { const values = transform.flat(); const hash = values.reduce((acc, val) => { // Simple hash function - convert to string and create checksum const str = val.toString(); for (let i = 0; i < str.length; i++) { acc = ((acc << 5) - acc + str.charCodeAt(i)) & 0xffffffff; } return acc; }, 0); // Convert to positive hex string, take first 6 chars return Math.abs(hash).toString(16).substring(0, 6); } /** * Handle imageTransform for post-processing (not CSS translation) * * When Figma includes an imageTransform matrix, it means the image is cropped/transformed. * This function converts the transform into processing instructions for Sharp. * * @param imageTransform - Figma's 2x3 transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]] * @returns Processing metadata for image cropping */ function handleImageTransform( imageTransform: Transform, ): NonNullable<SimplifiedImageFill["imageDownloadArguments"]> { const transformHash = generateTransformHash(imageTransform); return { needsCropping: true, requiresImageDimensions: false, cropTransform: imageTransform, filenameSuffix: `${transformHash}`, }; } /** * Build simplified stroke information from a Figma node * * @param n - The Figma node to extract stroke information from * @param hasChildren - Whether the node has children (affects paint processing) * @returns Simplified stroke object with colors and properties */ export function buildSimplifiedStrokes( n: FigmaDocumentNode, hasChildren: boolean = false, ): SimplifiedStroke { let strokes: SimplifiedStroke = { colors: [] }; if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) { strokes.colors = n.strokes.filter(isVisible).map((stroke) => parsePaint(stroke, hasChildren)); } if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) { strokes.strokeWeight = `${n.strokeWeight}px`; } if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) { strokes.strokeDashes = n.strokeDashes; } if (hasValue("individualStrokeWeights", n, isStrokeWeights)) { strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights); } return strokes; } /** * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill * @param raw - The Figma paint to convert * @param hasChildren - Whether the node has children (determines CSS properties) * @returns The converted SimplifiedFill */ export function parsePaint(raw: Paint, hasChildren: boolean = false): SimplifiedFill { if (raw.type === "IMAGE") { const baseImageFill: SimplifiedImageFill = { type: "IMAGE", imageRef: raw.imageRef, scaleMode: raw.scaleMode as "FILL" | "FIT" | "TILE" | "STRETCH", scalingFactor: raw.scalingFactor, }; // Get CSS properties and processing metadata from scale mode // TILE mode always needs to be treated as background image (can't tile an <img> tag) const isBackground = hasChildren || baseImageFill.scaleMode === "TILE"; const { css, processing } = translateScaleMode( baseImageFill.scaleMode, isBackground, raw.scalingFactor, ); // Combine scale mode processing with transform processing if needed // Transform processing (cropping) takes precedence over scale mode processing let finalProcessing = processing; if (raw.imageTransform) { const transformProcessing = handleImageTransform(raw.imageTransform); finalProcessing = { ...processing, ...transformProcessing, // Keep requiresImageDimensions from scale mode (needed for TILE) requiresImageDimensions: processing.requiresImageDimensions || transformProcessing.requiresImageDimensions, }; } return { ...baseImageFill, ...css, imageDownloadArguments: finalProcessing, }; } else if (raw.type === "SOLID") { // treat as SOLID const { hex, opacity } = convertColor(raw.color!, raw.opacity); if (opacity === 1) { return hex; } else { return formatRGBAColor(raw.color!, opacity); } } else if (raw.type === "PATTERN") { return parsePatternPaint(raw); } else if ( ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes( raw.type, ) ) { return { type: raw.type as | "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND", gradient: convertGradientToCss(raw), }; } else { throw new Error(`Unknown paint type: ${raw.type}`); } } /** * Convert a Figma PatternPaint to a CSS-like pattern fill. * * Ignores `tileType` and `spacing` from the Figma API currently as there's * no great way to translate them to CSS. * * @param raw - The Figma PatternPaint to convert * @returns The converted pattern SimplifiedFill */ function parsePatternPaint( raw: Extract<Paint, { type: "PATTERN" }>, ): Extract<SimplifiedFill, { type: "PATTERN" }> { /** * The only CSS-like repeat value supported by Figma is repeat. * * They also have hexagonal horizontal and vertical repeats, but * those aren't easy to pull off in CSS, so we just use repeat. */ let backgroundRepeat = "repeat"; let horizontal = "left"; switch (raw.horizontalAlignment) { case "START": horizontal = "left"; break; case "CENTER": horizontal = "center"; break; case "END": horizontal = "right"; break; } let vertical = "top"; switch (raw.verticalAlignment) { case "START": vertical = "top"; break; case "CENTER": vertical = "center"; break; case "END": vertical = "bottom"; break; } return { type: raw.type, patternSource: { type: "IMAGE-PNG", nodeId: raw.sourceNodeId, }, backgroundRepeat, backgroundSize: `${Math.round(raw.scalingFactor * 100)}%`, backgroundPosition: `${horizontal} ${vertical}`, }; } /** * Convert hex color value and opacity to rgba format * @param hex - Hexadecimal color value (e.g., "#FF0000" or "#F00") * @param opacity - Opacity value (0-1) * @returns Color string in rgba format */ export function hexToRgba(hex: string, opacity: number = 1): string { // Remove possible # prefix hex = hex.replace("#", ""); // Handle shorthand hex values (e.g., #FFF) if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } // Convert hex to RGB values const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); // Ensure opacity is in the 0-1 range const validOpacity = Math.min(Math.max(opacity, 0), 1); return `rgba(${r}, ${g}, ${b}, ${validOpacity})`; } /** * Convert color from RGBA to { hex, opacity } * * @param color - The color to convert, including alpha channel * @param opacity - The opacity of the color, if not included in alpha channel * @returns The converted color **/ export function convertColor(color: RGBA, opacity = 1): ColorValue { const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative const a = Math.round(opacity * color.a * 100) / 100; const hex = ("#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor; return { hex, opacity: a }; } /** * Convert color from Figma RGBA to rgba(#, #, #, #) CSS format * * @param color - The color to convert, including alpha channel * @param opacity - The opacity of the color, if not included in alpha channel * @returns The converted color **/ export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor { const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative const a = Math.round(opacity * color.a * 100) / 100; return `rgba(${r}, ${g}, ${b}, ${a})`; } /** * Map gradient stops from Figma's handle-based coordinate system to CSS percentages */ function mapGradientStops( gradient: Extract< Paint, { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" } >, elementBounds: { width: number; height: number } = { width: 1, height: 1 }, ): { stops: string; cssGeometry: string } { const handles = gradient.gradientHandlePositions; if (!handles || handles.length < 2) { const stops = gradient.gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: "0deg" }; } const [handle1, handle2, handle3] = handles; switch (gradient.type) { case "GRADIENT_LINEAR": { return mapLinearGradient(gradient.gradientStops, handle1, handle2, elementBounds); } case "GRADIENT_RADIAL": { return mapRadialGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds); } case "GRADIENT_ANGULAR": { return mapAngularGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds); } case "GRADIENT_DIAMOND": { return mapDiamondGradient(gradient.gradientStops, handle1, handle2, handle3, elementBounds); } default: { const stops = gradient.gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: "0deg" }; } } } /** * Map linear gradient from Figma handles to CSS */ function mapLinearGradient( gradientStops: { position: number; color: RGBA }[], start: Vector, end: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { // Calculate the gradient line in element space const dx = end.x - start.x; const dy = end.y - start.y; const gradientLength = Math.sqrt(dx * dx + dy * dy); // Handle degenerate case if (gradientLength === 0) { const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: "0deg" }; } // Calculate angle for CSS const angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; // Find where the extended gradient line intersects the element boundaries const extendedIntersections = findExtendedLineIntersections(start, end); if (extendedIntersections.length >= 2) { // The gradient line extended to fill the element const fullLineStart = Math.min(extendedIntersections[0], extendedIntersections[1]); const fullLineEnd = Math.max(extendedIntersections[0], extendedIntersections[1]); const fullLineLength = fullLineEnd - fullLineStart; // Map gradient stops from the Figma line segment to the full CSS line const mappedStops = gradientStops.map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); // Position along the Figma gradient line (0 = start handle, 1 = end handle) const figmaLinePosition = position; // The Figma line spans from t=0 to t=1 // The full extended line spans from fullLineStart to fullLineEnd // Map the figma position to the extended line const tOnExtendedLine = figmaLinePosition * (1 - 0) + 0; // This is just figmaLinePosition const extendedPosition = (tOnExtendedLine - fullLineStart) / (fullLineEnd - fullLineStart); const clampedPosition = Math.max(0, Math.min(1, extendedPosition)); return `${cssColor} ${Math.round(clampedPosition * 100)}%`; }); return { stops: mappedStops.join(", "), cssGeometry: `${Math.round(angle)}deg`, }; } // Fallback to simple gradient if intersection calculation fails const mappedStops = gradientStops.map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }); return { stops: mappedStops.join(", "), cssGeometry: `${Math.round(angle)}deg`, }; } /** * Find where the extended gradient line intersects with the element boundaries */ function findExtendedLineIntersections(start: Vector, end: Vector): number[] { const dx = end.x - start.x; const dy = end.y - start.y; // Handle degenerate case if (Math.abs(dx) < 1e-10 && Math.abs(dy) < 1e-10) { return []; } const intersections: number[] = []; // Check intersection with each edge of the unit square [0,1] x [0,1] // Top edge (y = 0) if (Math.abs(dy) > 1e-10) { const t = -start.y / dy; const x = start.x + t * dx; if (x >= 0 && x <= 1) { intersections.push(t); } } // Bottom edge (y = 1) if (Math.abs(dy) > 1e-10) { const t = (1 - start.y) / dy; const x = start.x + t * dx; if (x >= 0 && x <= 1) { intersections.push(t); } } // Left edge (x = 0) if (Math.abs(dx) > 1e-10) { const t = -start.x / dx; const y = start.y + t * dy; if (y >= 0 && y <= 1) { intersections.push(t); } } // Right edge (x = 1) if (Math.abs(dx) > 1e-10) { const t = (1 - start.x) / dx; const y = start.y + t * dy; if (y >= 0 && y <= 1) { intersections.push(t); } } // Remove duplicates and sort const uniqueIntersections = [ ...new Set(intersections.map((t) => Math.round(t * 1000000) / 1000000)), ]; return uniqueIntersections.sort((a, b) => a - b); } /** * Find where a line intersects with the unit square (0,0) to (1,1) */ function findLineIntersections(start: Vector, end: Vector): number[] { const dx = end.x - start.x; const dy = end.y - start.y; const intersections: number[] = []; // Check intersection with each edge of the unit square const edges = [ { x: 0, y: 0, dx: 1, dy: 0 }, // top edge { x: 1, y: 0, dx: 0, dy: 1 }, // right edge { x: 1, y: 1, dx: -1, dy: 0 }, // bottom edge { x: 0, y: 1, dx: 0, dy: -1 }, // left edge ]; for (const edge of edges) { const t = lineIntersection(start, { x: dx, y: dy }, edge, { x: edge.dx, y: edge.dy }); if (t !== null && t >= 0 && t <= 1) { intersections.push(t); } } return intersections.sort((a, b) => a - b); } /** * Calculate line intersection parameter */ function lineIntersection( p1: Vector, d1: Vector, p2: { x: number; y: number }, d2: Vector, ): number | null { const denominator = d1.x * d2.y - d1.y * d2.x; if (Math.abs(denominator) < 1e-10) return null; // Lines are parallel const dx = p2.x - p1.x; const dy = p2.y - p1.y; const t = (dx * d2.y - dy * d2.x) / denominator; return t; } /** * Map radial gradient from Figma handles to CSS */ function mapRadialGradient( gradientStops: { position: number; color: RGBA }[], center: Vector, edge: Vector, widthHandle: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { const centerX = Math.round(center.x * 100); const centerY = Math.round(center.y * 100); const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: `circle at ${centerX}% ${centerY}%`, }; } /** * Map angular gradient from Figma handles to CSS */ function mapAngularGradient( gradientStops: { position: number; color: RGBA }[], center: Vector, angleHandle: Vector, widthHandle: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { const centerX = Math.round(center.x * 100); const centerY = Math.round(center.y * 100); const angle = Math.atan2(angleHandle.y - center.y, angleHandle.x - center.x) * (180 / Math.PI) + 90; const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: `from ${Math.round(angle)}deg at ${centerX}% ${centerY}%`, }; } /** * Map diamond gradient from Figma handles to CSS (approximate with ellipse) */ function mapDiamondGradient( gradientStops: { position: number; color: RGBA }[], center: Vector, edge: Vector, widthHandle: Vector, elementBounds: { width: number; height: number }, ): { stops: string; cssGeometry: string } { const centerX = Math.round(center.x * 100); const centerY = Math.round(center.y * 100); const stops = gradientStops .map(({ position, color }) => { const cssColor = formatRGBAColor(color, 1); return `${cssColor} ${Math.round(position * 100)}%`; }) .join(", "); return { stops, cssGeometry: `ellipse at ${centerX}% ${centerY}%`, }; } /** * Convert a Figma gradient to CSS gradient syntax */ function convertGradientToCss( gradient: Extract< Paint, { type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" } >, ): string { // Sort stops by position to ensure proper order const sortedGradient = { ...gradient, gradientStops: [...gradient.gradientStops].sort((a, b) => a.position - b.position), }; // Map gradient stops using handle-based geometry const { stops, cssGeometry } = mapGradientStops(sortedGradient); switch (gradient.type) { case "GRADIENT_LINEAR": { return `linear-gradient(${cssGeometry}, ${stops})`; } case "GRADIENT_RADIAL": { return `radial-gradient(${cssGeometry}, ${stops})`; } case "GRADIENT_ANGULAR": { return `conic-gradient(${cssGeometry}, ${stops})`; } case "GRADIENT_DIAMOND": { return `radial-gradient(${cssGeometry}, ${stops})`; } default: return `linear-gradient(0deg, ${stops})`; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/GLips/Figma-Context-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server