Skip to content

Commit c052be1

Browse files
committed
Merge branch 'main' into akshay/write-to-studio
2 parents 4ec8d88 + 7d3a39e commit c052be1

17 files changed

Lines changed: 492 additions & 39 deletions

packages/tokens-studio-for-figma/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @tokens-studio/figma-plugin
22

3+
## 2.11.5
4+
5+
### Patch Changes
6+
7+
- e0ad95d87: Add ability to pull Variable Scopes and Code Syntaxes from New Studio, when pulling tokens.
8+
- 1d5c60fde: Support per-mode `baseFontSize` resolution via aliases, ensuring correct rem-to-pixel scaling when exporting variables across different themes. Also includes improved handling of numeric/aliased font sizes in variable exports and more robust testing for dimension conversions.
9+
- b0e1563de: Fix tracking and mapping of radial gradient tokens to properly apply radial transformations in Figma
10+
311
## 2.11.4
412

513
### Patch Changes

packages/tokens-studio-for-figma/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tokens-studio/figma-plugin",
3-
"version": "2.11.4",
3+
"version": "2.11.5",
44
"description": "Tokens Studio plugin for Figma",
55
"license": "MIT",
66
"private": true,

packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,16 @@ describe('radial and conic gradients', () => {
387387
expect(result.gradientTransform).toEqual([[1, 0, 0], [0, 1, 0]]);
388388
});
389389

390+
it('should handle radial-gradient(ellipse at top, ...)', () => {
391+
const input = 'radial-gradient(ellipse at top, #ffffff 0, #666666 100%)';
392+
const result = convertStringToFigmaGradient(input);
393+
expect(result.type).toEqual('GRADIENT_RADIAL');
394+
expect(result.gradientTransform).toEqual([
395+
[1, 0, 0],
396+
[0, 1, 0.5],
397+
]);
398+
});
399+
390400
it('should convert conic gradient', () => {
391401
const result = convertStringToFigmaGradient('conic-gradient(#ff0000, #0000ff)');
392402
expect(result.type).toEqual('GRADIENT_ANGULAR');
@@ -425,6 +435,26 @@ describe('radial and conic gradients', () => {
425435
expect(result.gradientStops).toHaveLength(2);
426436
});
427437

438+
439+
it('should handle radial-gradient(at bottom right, ...)', () => {
440+
const input = 'radial-gradient(at bottom right, #ffffff, #000000)';
441+
const result = convertStringToFigmaGradient(input);
442+
expect(result.gradientTransform).toEqual([
443+
[2, 0, 0],
444+
[0, 2, -1],
445+
]);
446+
});
447+
448+
it('should handle radial-gradient with case-insensitivity and extra spaces', () => {
449+
const input = 'RADIAL-GRADIENT(Ellipse at TOP left, #ffffff, #000000)';
450+
const result = convertStringToFigmaGradient(input);
451+
expect(result.type).toEqual('GRADIENT_RADIAL');
452+
expect(result.gradientTransform).toEqual([
453+
[2, 0, -1],
454+
[0, 2, 0],
455+
]);
456+
});
457+
428458
it('should handle conic gradient with at position', () => {
429459
const result = convertStringToFigmaGradient('conic-gradient(from 45deg at 50% 50%, #ff0000, #0000ff)');
430460
expect(result.type).toEqual('GRADIENT_ANGULAR');

packages/tokens-studio-for-figma/src/plugin/figmaTransforms/gradients.ts

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -213,28 +213,107 @@ function convertRadialGradient(parts: string[]): {
213213
type: 'GRADIENT_RADIAL';
214214
} {
215215
// Parse radial gradient syntax: radial-gradient([shape size] [at position], color-stops)
216-
// For Figma, we'll use a basic radial transform centered at 0.5, 0.5
217-
// More complex positioning and sizing could be added later
218-
219-
// Skip shape/size/position parameters for now and focus on color stops
216+
let centerX = 0.5;
217+
let centerY = 0.5;
220218
let colorStopsStart = 0;
221-
if (parts.length > 0 && !parts[0].includes('#') && !parts[0].includes('rgb') && !parts[0].includes('hsl')) {
222-
// First part might be shape/size/position, skip it
219+
220+
const isPositionPart = (part: string) => {
221+
const lowerPart = part.toLowerCase();
222+
return (
223+
lowerPart.includes('at ')
224+
|| lowerPart.includes('circle')
225+
|| lowerPart.includes('ellipse')
226+
|| lowerPart.includes('closest-')
227+
|| lowerPart.includes('farthest-')
228+
);
229+
};
230+
231+
if (parts.length > 0 && isPositionPart(parts[0])) {
232+
const positionPart = parts[0];
223233
colorStopsStart = 1;
234+
235+
const parseCoord = (coord: string) => {
236+
switch (coord) {
237+
case 'left': return 0;
238+
case 'right': return 1;
239+
case 'top': return 0;
240+
case 'bottom': return 1;
241+
case 'center': return 0.5;
242+
default:
243+
if (coord.endsWith('%')) return parseFloat(coord) / 100;
244+
return 0.5;
245+
}
246+
};
247+
248+
const lowerPositionPart = positionPart.toLowerCase();
249+
let positionString = '';
250+
if (lowerPositionPart.includes(' at ')) {
251+
[, positionString] = lowerPositionPart.split(' at ');
252+
} else if (lowerPositionPart.startsWith('at ')) {
253+
positionString = lowerPositionPart.substring(3);
254+
}
255+
256+
if (positionString) {
257+
const posParts = positionString.trim().split(/\s+/);
258+
if (posParts.length === 1) {
259+
const p = posParts[0];
260+
if (['left', 'right'].includes(p)) {
261+
centerX = parseCoord(p);
262+
centerY = 0.5;
263+
} else if (['top', 'bottom'].includes(p)) {
264+
centerX = 0.5;
265+
centerY = parseCoord(p);
266+
} else {
267+
centerX = parseCoord(p);
268+
centerY = 0.5;
269+
}
270+
} else if (posParts.length >= 2) {
271+
const first = posParts[0];
272+
const second = posParts[1];
273+
const firstIsY = ['top', 'bottom'].includes(first);
274+
const secondIsX = ['left', 'right'].includes(second);
275+
276+
if (firstIsY || secondIsX) {
277+
centerY = parseCoord(first);
278+
centerX = parseCoord(second);
279+
} else {
280+
centerX = parseCoord(first);
281+
centerY = parseCoord(second);
282+
}
283+
}
284+
}
224285
}
225286

226287
const colorStopParts = parts.slice(colorStopsStart);
227-
const gradientStops = parseColorStops(colorStopParts);
228288

229-
// Create identity transform for basic radial gradient centered at 0.5, 0.5
230-
const gradientTransform: Transform = [
231-
[1, 0, 0],
232-
[0, 1, 0],
289+
// a, e are the scale factors.
290+
// For the most common CSS keywords, we use verified matrices to match Figma's behavior.
291+
let matrix: Transform | null = null;
292+
const posString = parts[0]?.toLowerCase() || '';
293+
294+
if (posString.includes('at top') && !posString.includes('left') && !posString.includes('right')) {
295+
matrix = [[1.0, 0, 0], [0, 1.0, 0.5]]; // Top Edge center at 1.0
296+
} else if (posString.includes('at bottom') && !posString.includes('left') && !posString.includes('right')) {
297+
matrix = [[1.0, 0, 0], [0, 1.0, -0.5]]; // Bottom Edge center at 0.0
298+
} else if (posString.includes('at left') && !posString.includes('top') && !posString.includes('bottom')) {
299+
matrix = [[1.0, 0, -0.5], [0, 1.0, 0]]; // Left Edge center at 0.0
300+
} else if (posString.includes('at right') && !posString.includes('top') && !posString.includes('bottom')) {
301+
matrix = [[1.0, 0, 0.5], [0, 1.0, 0]]; // Right Edge center at 1.0
302+
}
303+
304+
const figmaCenterY = 1 - centerY;
305+
const figmaScaleY = 2 * Math.max(figmaCenterY, 1 - figmaCenterY);
306+
const correctedTy = figmaCenterY - 0.5 * figmaScaleY;
307+
const correctedTx = centerX - 0.5 * (2 * Math.max(centerX, 1 - centerX));
308+
309+
const gradientTransform: Transform = matrix || [
310+
[roundToPrecision(2 * Math.max(centerX, 1 - centerX)), 0, roundToPrecision(correctedTx)],
311+
[0, roundToPrecision(figmaScaleY), roundToPrecision(correctedTy)],
233312
];
234313

235314
return {
236-
type: 'GRADIENT_RADIAL' as const,
237-
gradientStops,
315+
type: 'GRADIENT_RADIAL',
316+
gradientStops: parseColorStops(colorStopParts),
238317
gradientTransform,
239318
};
240319
}
@@ -283,14 +362,14 @@ function convertConicGradient(parts: string[]): {
283362
}
284363

285364
// if node type check is needed due to bugs caused by obscure node types, use (value: string/*, node?: BaseNode | PaintStyle) and convertStringToFigmaGradient(value, target)
286-
export function convertStringToFigmaGradient(value: string): {
365+
export function convertStringToFigmaGradient(gradientString: string): {
287366
gradientStops: ColorStop[];
288367
gradientTransform: Transform;
289-
type?: 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR';
368+
type: 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND';
290369
} {
291370
// Detect gradient type from the CSS function name
292-
const gradientType = value.substring(0, value.indexOf('('));
293-
const innerContent = value.substring(value.indexOf('(') + 1, value.lastIndexOf(')'));
371+
const gradientType = gradientString.substring(0, gradientString.indexOf('(')).trim().toLowerCase();
372+
const innerContent = gradientString.substring(gradientString.indexOf('(') + 1, gradientString.lastIndexOf(')'));
294373
const parts = parseGradientParts(innerContent);
295374

296375
switch (gradientType) {

packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ describe('paintStyleMatchesColorToken', () => {
2121
getSharedPluginData: () => dummyFunc<string>(),
2222
setSharedPluginData: noop,
2323
getSharedPluginDataKeys: () => dummyFunc<string[]>(),
24+
getStyleConsumersAsync: () => dummyFunc<Promise<any>>(),
25+
consumers: [],
26+
descriptionMarkdown: '',
2427
};
2528

2629
describe('when Figma paints is missing', () => {
@@ -284,5 +287,62 @@ describe('paintStyleMatchesColorToken', () => {
284287
expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(false);
285288
});
286289
});
290+
291+
describe('radial gradients', () => {
292+
it('should match radial gradient color token against same radial paint style', () => {
293+
const gradientColor: RGB = { r: 1, g: 0, b: 0 };
294+
const gradientStops: ReadonlyArray<ColorStop> = [
295+
{ position: 0, color: { ...gradientColor, a: 1 } },
296+
{ position: 1, color: { ...gradientColor, a: 0 } },
297+
];
298+
const colorToken = 'radial-gradient(#ff0000 0%, #ff000000 100%)';
299+
const gradientTransform: Transform = [
300+
[1, 0, 0],
301+
[0, 1, 0],
302+
];
303+
const figmaPaintStyle: PaintStyle = {
304+
...dummyFigmaPaintStyle,
305+
paints: [{ gradientTransform, gradientStops, type: 'GRADIENT_RADIAL' }],
306+
};
307+
expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(true);
308+
});
309+
310+
it('should match radial gradient with center/transform', () => {
311+
const gradientColor: RGB = { r: 1, g: 1, b: 1 };
312+
const gradientStops: ReadonlyArray<ColorStop> = [
313+
{ position: 0, color: { ...gradientColor, a: 1 } },
314+
{ position: 1, color: { ...gradientColor, a: 1 } },
315+
];
316+
// radial-gradient(at top, ...) result in [[1, 0, 0], [0, 1, 0.5]]
317+
const colorToken = 'radial-gradient(at top, #ffffff 0%, #ffffff 100%)';
318+
const gradientTransform: Transform = [
319+
[1, 0, 0],
320+
[0, 1, 0.5],
321+
];
322+
const figmaPaintStyle: PaintStyle = {
323+
...dummyFigmaPaintStyle,
324+
paints: [{ gradientTransform, gradientStops, type: 'GRADIENT_RADIAL' }],
325+
};
326+
expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(true);
327+
});
328+
329+
it('should NOT match radial gradient with different transform', () => {
330+
const gradientColor: RGB = { r: 1, g: 1, b: 1 };
331+
const gradientStops: ReadonlyArray<ColorStop> = [
332+
{ position: 0, color: { ...gradientColor, a: 1 } },
333+
{ position: 1, color: { ...gradientColor, a: 1 } },
334+
];
335+
const colorToken = 'radial-gradient(at top, #ffffff 0%, #ffffff 100%)';
336+
const gradientTransform: Transform = [
337+
[1, 0, 0],
338+
[0, 1, 0],
339+
];
340+
const figmaPaintStyle: PaintStyle = {
341+
...dummyFigmaPaintStyle,
342+
paints: [{ gradientTransform, gradientStops, type: 'GRADIENT_RADIAL' }],
343+
};
344+
expect(paintStyleMatchesColorToken(figmaPaintStyle, colorToken)).toBe(false);
345+
});
346+
});
287347
});
288348
});

packages/tokens-studio-for-figma/src/plugin/figmaUtils/styleMatchers/paintStyleMatchesColorToken.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ import { isPaintEqual } from '@/utils/isPaintEqual';
22
import { convertStringToFigmaGradient } from '../../figmaTransforms/gradients';
33
import { convertToFigmaColor } from '../../figmaTransforms/colors';
44

5+
// Helper function to check if a value is any type of gradient
6+
const isGradient = (value: string): boolean => value?.startsWith?.('linear-gradient')
7+
|| value?.startsWith?.('radial-gradient')
8+
|| value?.startsWith?.('conic-gradient');
9+
510
export function paintStyleMatchesColorToken(paintStyle: PaintStyle | undefined, colorToken: string) {
611
const stylePaint = paintStyle?.paints[0] ?? null;
712
if (stylePaint?.type === 'SOLID') {
813
const { color, opacity } = convertToFigmaColor(colorToken);
914
const tokenPaint: SolidPaint = { color, opacity, type: 'SOLID' };
1015
return isPaintEqual(stylePaint, tokenPaint);
1116
}
12-
if (stylePaint?.type === 'GRADIENT_LINEAR') {
17+
if (stylePaint?.type === 'GRADIENT_LINEAR' && isGradient(colorToken)) {
1318
const { gradientStops, gradientTransform } = convertStringToFigmaGradient(colorToken);
1419
const tokenPaint: GradientPaint = {
1520
type: 'GRADIENT_LINEAR',
@@ -18,5 +23,14 @@ export function paintStyleMatchesColorToken(paintStyle: PaintStyle | undefined,
1823
};
1924
return isPaintEqual(stylePaint, tokenPaint);
2025
}
26+
if (stylePaint?.type === 'GRADIENT_RADIAL' && isGradient(colorToken)) {
27+
const { gradientStops, gradientTransform } = convertStringToFigmaGradient(colorToken);
28+
const tokenPaint: GradientPaint = {
29+
type: 'GRADIENT_RADIAL',
30+
gradientTransform,
31+
gradientStops,
32+
};
33+
return isPaintEqual(stylePaint, tokenPaint);
34+
}
2135
return false;
2236
}

packages/tokens-studio-for-figma/src/plugin/setBooleanValuesOnVariable.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ export default function setBooleanValuesOnVariable(variable: Variable, mode: str
44
try {
55
const existingVariableValue = variable.valuesByMode[mode];
66
if (
7-
existingVariableValue === undefined
8-
|| !(typeof existingVariableValue === 'boolean' || isVariableWithAliasReference(existingVariableValue))
7+
existingVariableValue !== undefined
8+
&& !(typeof existingVariableValue === 'boolean' || isVariableWithAliasReference(existingVariableValue))
99
) return;
1010

1111
const newValue = value === 'true';

packages/tokens-studio-for-figma/src/plugin/setColorValuesOnTarget.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const getGradientPaint = async (fallbackValue, token) => {
5757
}
5858
}
5959
const newPaint: GradientPaint = {
60-
type: type || 'GRADIENT_LINEAR',
60+
type,
6161
gradientTransform,
6262
gradientStops: gradientStopsWithReferences,
6363
};

packages/tokens-studio-for-figma/src/plugin/setColorValuesOnVariable.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export default function setColorValuesOnVariable(variable: Variable, mode: strin
2323
const { color, opacity } = convertToFigmaColor(value);
2424
const existingVariableValue = variable.valuesByMode[mode];
2525
if (
26-
!existingVariableValue
27-
|| !(isFigmaColorObject(existingVariableValue) || isVariableWithAliasReference(existingVariableValue))
26+
existingVariableValue
27+
&& !(isFigmaColorObject(existingVariableValue) || isVariableWithAliasReference(existingVariableValue))
2828
) return;
2929

3030
const newValue = { ...color, a: opacity };

packages/tokens-studio-for-figma/src/plugin/setNumberValuesOnVariable.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ export default function setNumberValuesOnVariable(variable: Variable, mode: stri
1616
}
1717
const existingVariableValue = variable.valuesByMode[mode];
1818
if (
19-
existingVariableValue === undefined
20-
|| !(typeof existingVariableValue === 'number' || isVariableWithAliasReference(existingVariableValue))
19+
existingVariableValue !== undefined
20+
&& !(typeof existingVariableValue === 'number' || isVariableWithAliasReference(existingVariableValue))
2121
) return;
2222

2323
// For direct number values, compare using threshold

0 commit comments

Comments
 (0)