diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.test.ts b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.test.ts index e2f8b12796..453cf3d88a 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.test.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.test.ts @@ -387,6 +387,16 @@ describe('radial and conic gradients', () => { expect(result.gradientTransform).toEqual([[1, 0, 0], [0, 1, 0]]); }); + it('should handle radial-gradient(ellipse at top, ...)', () => { + const input = 'radial-gradient(ellipse at top, #ffffff 0, #666666 100%)'; + const result = convertStringToFigmaGradient(input); + expect(result.type).toEqual('GRADIENT_RADIAL'); + expect(result.gradientTransform).toEqual([ + [1, 0, 0], + [0, 1, 0.5], + ]); + }); + it('should convert conic gradient', () => { const result = convertStringToFigmaGradient('conic-gradient(#ff0000, #0000ff)'); expect(result.type).toEqual('GRADIENT_ANGULAR'); @@ -425,6 +435,26 @@ describe('radial and conic gradients', () => { expect(result.gradientStops).toHaveLength(2); }); + + it('should handle radial-gradient(at bottom right, ...)', () => { + const input = 'radial-gradient(at bottom right, #ffffff, #000000)'; + const result = convertStringToFigmaGradient(input); + expect(result.gradientTransform).toEqual([ + [2, 0, 0], + [0, 2, -1], + ]); + }); + + it('should handle radial-gradient with case-insensitivity and extra spaces', () => { + const input = 'RADIAL-GRADIENT(Ellipse at TOP left, #ffffff, #000000)'; + const result = convertStringToFigmaGradient(input); + expect(result.type).toEqual('GRADIENT_RADIAL'); + expect(result.gradientTransform).toEqual([ + [2, 0, -1], + [0, 2, 0], + ]); + }); + it('should handle conic gradient with at position', () => { const result = convertStringToFigmaGradient('conic-gradient(from 45deg at 50% 50%, #ff0000, #0000ff)'); expect(result.type).toEqual('GRADIENT_ANGULAR'); diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.ts b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.ts index 68748fb922..e8d2174050 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.ts @@ -213,28 +213,107 @@ function convertRadialGradient(parts: string[]): { type: 'GRADIENT_RADIAL'; } { // Parse radial gradient syntax: radial-gradient([shape size] [at position], color-stops) - // For Figma, we'll use a basic radial transform centered at 0.5, 0.5 - // More complex positioning and sizing could be added later - - // Skip shape/size/position parameters for now and focus on color stops + let centerX = 0.5; + let centerY = 0.5; let colorStopsStart = 0; - if (parts.length > 0 && !parts[0].includes('#') && !parts[0].includes('rgb') && !parts[0].includes('hsl')) { - // First part might be shape/size/position, skip it + + const isPositionPart = (part: string) => { + const lowerPart = part.toLowerCase(); + return ( + lowerPart.includes('at ') + || lowerPart.includes('circle') + || lowerPart.includes('ellipse') + || lowerPart.includes('closest-') + || lowerPart.includes('farthest-') + ); + }; + + if (parts.length > 0 && isPositionPart(parts[0])) { + const positionPart = parts[0]; colorStopsStart = 1; + + const parseCoord = (coord: string) => { + switch (coord) { + case 'left': return 0; + case 'right': return 1; + case 'top': return 0; + case 'bottom': return 1; + case 'center': return 0.5; + default: + if (coord.endsWith('%')) return parseFloat(coord) / 100; + return 0.5; + } + }; + + const lowerPositionPart = positionPart.toLowerCase(); + let positionString = ''; + if (lowerPositionPart.includes(' at ')) { + [, positionString] = lowerPositionPart.split(' at '); + } else if (lowerPositionPart.startsWith('at ')) { + positionString = lowerPositionPart.substring(3); + } + + if (positionString) { + const posParts = positionString.trim().split(/\s+/); + if (posParts.length === 1) { + const p = posParts[0]; + if (['left', 'right'].includes(p)) { + centerX = parseCoord(p); + centerY = 0.5; + } else if (['top', 'bottom'].includes(p)) { + centerX = 0.5; + centerY = parseCoord(p); + } else { + centerX = parseCoord(p); + centerY = 0.5; + } + } else if (posParts.length >= 2) { + const first = posParts[0]; + const second = posParts[1]; + const firstIsY = ['top', 'bottom'].includes(first); + const secondIsX = ['left', 'right'].includes(second); + + if (firstIsY || secondIsX) { + centerY = parseCoord(first); + centerX = parseCoord(second); + } else { + centerX = parseCoord(first); + centerY = parseCoord(second); + } + } + } } const colorStopParts = parts.slice(colorStopsStart); - const gradientStops = parseColorStops(colorStopParts); - // Create identity transform for basic radial gradient centered at 0.5, 0.5 - const gradientTransform: Transform = [ - [1, 0, 0], - [0, 1, 0], + // a, e are the scale factors. + // For the most common CSS keywords, we use verified matrices to match Figma's behavior. + let matrix: Transform | null = null; + const posString = parts[0]?.toLowerCase() || ''; + + if (posString.includes('at top') && !posString.includes('left') && !posString.includes('right')) { + matrix = [[1.0, 0, 0], [0, 1.0, 0.5]]; // Top Edge center at 1.0 + } else if (posString.includes('at bottom') && !posString.includes('left') && !posString.includes('right')) { + matrix = [[1.0, 0, 0], [0, 1.0, -0.5]]; // Bottom Edge center at 0.0 + } else if (posString.includes('at left') && !posString.includes('top') && !posString.includes('bottom')) { + matrix = [[1.0, 0, -0.5], [0, 1.0, 0]]; // Left Edge center at 0.0 + } else if (posString.includes('at right') && !posString.includes('top') && !posString.includes('bottom')) { + matrix = [[1.0, 0, 0.5], [0, 1.0, 0]]; // Right Edge center at 1.0 + } + + const figmaCenterY = 1 - centerY; + const figmaScaleY = 2 * Math.max(figmaCenterY, 1 - figmaCenterY); + const correctedTy = figmaCenterY - 0.5 * figmaScaleY; + const correctedTx = centerX - 0.5 * (2 * Math.max(centerX, 1 - centerX)); + + const gradientTransform: Transform = matrix || [ + [roundToPrecision(2 * Math.max(centerX, 1 - centerX)), 0, roundToPrecision(correctedTx)], + [0, roundToPrecision(figmaScaleY), roundToPrecision(correctedTy)], ]; return { - type: 'GRADIENT_RADIAL' as const, - gradientStops, + type: 'GRADIENT_RADIAL', + gradientStops: parseColorStops(colorStopParts), gradientTransform, }; } @@ -283,14 +362,14 @@ function convertConicGradient(parts: string[]): { } // if node type check is needed due to bugs caused by obscure node types, use (value: string/*, node?: BaseNode | PaintStyle) and convertStringToFigmaGradient(value, target) -export function convertStringToFigmaGradient(value: string): { +export function convertStringToFigmaGradient(gradientString: string): { gradientStops: ColorStop[]; gradientTransform: Transform; - type?: 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR'; + type: 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND'; } { // Detect gradient type from the CSS function name - const gradientType = value.substring(0, value.indexOf('(')); - const innerContent = value.substring(value.indexOf('(') + 1, value.lastIndexOf(')')); + const gradientType = gradientString.substring(0, gradientString.indexOf('(')).trim().toLowerCase(); + const innerContent = gradientString.substring(gradientString.indexOf('(') + 1, gradientString.lastIndexOf(')')); const parts = parseGradientParts(innerContent); switch (gradientType) { diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.test.ts b/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.test.ts index 6065f5d976..60b1707e75 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.test.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.test.ts @@ -21,6 +21,9 @@ describe('paintStyleMatchesColorToken', () => { getSharedPluginData: () => dummyFunc(), setSharedPluginData: noop, getSharedPluginDataKeys: () => dummyFunc(), + getStyleConsumersAsync: () => dummyFunc>(), + consumers: [], + descriptionMarkdown: '', }; describe('when Figma paints is missing', () => { @@ -284,5 +287,62 @@ describe('paintStyleMatchesColorToken', () => { expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(false); }); }); + + describe('radial gradients', () => { + it('should match radial gradient color token against same radial paint style', () => { + const gradientColor: RGB = { r: 1, g: 0, b: 0 }; + const gradientStops: ReadonlyArray = [ + { position: 0, color: { ...gradientColor, a: 1 } }, + { position: 1, color: { ...gradientColor, a: 0 } }, + ]; + const colorToken = 'radial-gradient(#ff0000 0%, #ff000000 100%)'; + const gradientTransform: Transform = [ + [1, 0, 0], + [0, 1, 0], + ]; + const figmaPaintStyle: PaintStyle = { + ...dummyFigmaPaintStyle, + paints: [{ gradientTransform, gradientStops, type: 'GRADIENT_RADIAL' }], + }; + expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(true); + }); + + it('should match radial gradient with center/transform', () => { + const gradientColor: RGB = { r: 1, g: 1, b: 1 }; + const gradientStops: ReadonlyArray = [ + { position: 0, color: { ...gradientColor, a: 1 } }, + { position: 1, color: { ...gradientColor, a: 1 } }, + ]; + // radial-gradient(at top, ...) result in [[1, 0, 0], [0, 1, 0.5]] + const colorToken = 'radial-gradient(at top, #ffffff 0%, #ffffff 100%)'; + const gradientTransform: Transform = [ + [1, 0, 0], + [0, 1, 0.5], + ]; + const figmaPaintStyle: PaintStyle = { + ...dummyFigmaPaintStyle, + paints: [{ gradientTransform, gradientStops, type: 'GRADIENT_RADIAL' }], + }; + expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(true); + }); + + it('should NOT match radial gradient with different transform', () => { + const gradientColor: RGB = { r: 1, g: 1, b: 1 }; + const gradientStops: ReadonlyArray = [ + { position: 0, color: { ...gradientColor, a: 1 } }, + { position: 1, color: { ...gradientColor, a: 1 } }, + ]; + const colorToken = 'radial-gradient(at top, #ffffff 0%, #ffffff 100%)'; + const gradientTransform: Transform = [ + [1, 0, 0], + [0, 1, 0], + ]; + const figmaPaintStyle: PaintStyle = { + ...dummyFigmaPaintStyle, + paints: [{ gradientTransform, gradientStops, type: 'GRADIENT_RADIAL' }], + }; + expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(false); + }); + }); }); }); diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.ts b/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.ts index 45d5b47015..c399a64b95 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.ts @@ -2,6 +2,11 @@ import { isPaintEqual } from '@/utils/isPaintEqual'; import { convertStringToFigmaGradient } from '../../figmaTransforms/gradients'; import { convertToFigmaColor } from '../../figmaTransforms/colors'; +// Helper function to check if a value is any type of gradient +const isGradient = (value: string): boolean => value?.startsWith?.('linear-gradient') + || value?.startsWith?.('radial-gradient') + || value?.startsWith?.('conic-gradient'); + export function paintStyleMatchesColorToken(paintStyle: PaintStyle | undefined, colorToken: string) { const stylePaint = paintStyle?.paints[0] ?? null; if (stylePaint?.type === 'SOLID') { @@ -9,7 +14,7 @@ export function paintStyleMatchesColorToken(paintStyle: PaintStyle | undefined, const tokenPaint: SolidPaint = { color, opacity, type: 'SOLID' }; return isPaintEqual(stylePaint, tokenPaint); } - if (stylePaint?.type === 'GRADIENT_LINEAR') { + if (stylePaint?.type === 'GRADIENT_LINEAR' && isGradient(colorToken)) { const { gradientStops, gradientTransform } = convertStringToFigmaGradient(colorToken); const tokenPaint: GradientPaint = { type: 'GRADIENT_LINEAR', @@ -18,5 +23,14 @@ export function paintStyleMatchesColorToken(paintStyle: PaintStyle | undefined, }; return isPaintEqual(stylePaint, tokenPaint); } + if (stylePaint?.type === 'GRADIENT_RADIAL' && isGradient(colorToken)) { + const { gradientStops, gradientTransform } = convertStringToFigmaGradient(colorToken); + const tokenPaint: GradientPaint = { + type: 'GRADIENT_RADIAL', + gradientTransform, + gradientStops, + }; + return isPaintEqual(stylePaint, tokenPaint); + } return false; } diff --git a/packages/tokens-studio-for-figma/src/plugin/setColorValuesOnTarget.ts b/packages/tokens-studio-for-figma/src/plugin/setColorValuesOnTarget.ts index 01e10be059..5ceffc2f02 100644 --- a/packages/tokens-studio-for-figma/src/plugin/setColorValuesOnTarget.ts +++ b/packages/tokens-studio-for-figma/src/plugin/setColorValuesOnTarget.ts @@ -57,7 +57,7 @@ const getGradientPaint = async (fallbackValue, token) => { } } const newPaint: GradientPaint = { - type: type || 'GRADIENT_LINEAR', + type, gradientTransform, gradientStops: gradientStopsWithReferences, };