From 1ab4b83cbf526906a07e1727cfdce027ce9960fb Mon Sep 17 00:00:00 2001 From: Stefan Attoh Date: Tue, 17 Feb 2026 09:01:14 -0700 Subject: [PATCH 1/5] resolve incorrect token references and missing border-radius in tailwind output --- .../transformers/tailwind/generators.test.ts | 65 ++++- .../convert-to-generator-tokens.test.ts | 233 ++++++++++++++++++ .../src/transformers/tailwind/generators.ts | 48 +++- .../variants/convert-to-generator-tokens.ts | 22 +- 4 files changed, 355 insertions(+), 13 deletions(-) create mode 100644 packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts diff --git a/packages/figma/src/tests/transformers/tailwind/generators.test.ts b/packages/figma/src/tests/transformers/tailwind/generators.test.ts index c8bba1b..64b858d 100644 --- a/packages/figma/src/tests/transformers/tailwind/generators.test.ts +++ b/packages/figma/src/tests/transformers/tailwind/generators.test.ts @@ -53,6 +53,7 @@ vi.mock('../../../theme-tokens', () => ({ }, borderRadius: { '4px': 'DEFAULT', + '50%': 'full', }, }, })); @@ -254,6 +255,24 @@ describe('generateTailwindBorderClass', () => { }; const result = generateTailwindBorderClass(borderTokenRgba); expect(result).toBe('border-[rgba(0,0,0,0.03)]'); + it('should use semantic variable name for border color when available', () => { + const borderTokenWithSemantic = { + ...borderToken, + rawValue: '1px solid #4f5153', + semanticVariableName: 'navigation-border-neutral-footer-divider', + }; + const result = generateTailwindBorderClass(borderTokenWithSemantic); + expect(result).toBe('border border-solid border-navigation-border-neutral-footer-divider'); + }); + + it('should strip colors- prefix from semantic variable in border color', () => { + const borderTokenWithPrefix = { + ...borderToken, + rawValue: '2px solid #4f5153', + semanticVariableName: 'colors-navigation-border-neutral-footer-divider', + }; + const result = generateTailwindBorderClass(borderTokenWithPrefix); + expect(result).toBe('border-2 border-solid border-navigation-border-neutral-footer-divider'); }); }); @@ -265,7 +284,7 @@ describe('generateTailwindBorderRadiusClass', () => { }; it('should return tailwind utilities for border radius when given one property', () => { const result = generateTailwindBorderRadiusClass(borderRadiusToken); - expect(result).toBe('rounded-tl-[20px] rounded-tr-[20px] rounded-br-[20px] rounded-bl-[20px]'); + expect(result).toBe('rounded-[20px]'); }); it('should return tailwind utilities for border radius when given one property', () => { @@ -275,7 +294,17 @@ describe('generateTailwindBorderRadiusClass', () => { rawValue: '4px', // "1px" : "DEFAULT" in borderRadius }; const result = generateTailwindBorderRadiusClass(defaultBorderRadiusToken); - expect(result).toBe('rounded-tl rounded-tr rounded-br rounded-bl'); + expect(result).toBe('rounded'); + }); + + it('should return rounded-full for 50% border radius (ellipse/circle elements)', () => { + const circularToken = { + ...basicToken, + property: 'border-radius', + rawValue: '50%', + }; + const result = generateTailwindBorderRadiusClass(circularToken); + expect(result).toBe('rounded-full'); }); it('should return tailwind utilities for border radius when given two properties', () => { @@ -589,6 +618,38 @@ describe('generateTailwindBoxShadowClass', () => { expect(result).toBe('shadow-[0_4px_6px_rgba(0,0,0,0.1),inset_0_1px_0_0_var(--blue-900)]'); }); + it('should use semantic variable name for box-shadow color when available', () => { + const insetShadowToken = { + ...boxShadowToken, + rawValue: 'inset 0 1px 0 0 #4f5153', + semanticVariableName: 'form-border-neutral-disabled', + }; + const result = generateTailwindBoxShadowClass(insetShadowToken); + expect(result).toBe('shadow-[inset_0_1px_0_0_var(--form-border-neutral-disabled)]'); + }); + + it('should strip colors- prefix from semantic variable in box-shadow color', () => { + const insetShadowToken = { + ...boxShadowToken, + rawValue: 'inset 0 1px 0 0 #4f5153', + semanticVariableName: 'colors-form-border-neutral-disabled', + }; + const result = generateTailwindBoxShadowClass(insetShadowToken); + expect(result).toBe('shadow-[inset_0_1px_0_0_var(--form-border-neutral-disabled)]'); + }); + + it('should use semantic variable for all hex colors in multiple box shadows', () => { + const multiShadowToken = { + ...boxShadowToken, + rawValue: 'inset 0 1px 0 0 #4f5153, inset -1px 0 0 0 #4f5153', + semanticVariableName: 'form-border-neutral-disabled', + }; + const result = generateTailwindBoxShadowClass(multiShadowToken); + expect(result).toBe( + 'shadow-[inset_0_1px_0_0_var(--form-border-neutral-disabled),inset_-1px_0_0_0_var(--form-border-neutral-disabled)]', + ); + }); + it('should use dynamic theme tokens when available', () => { const shadowToken = { ...boxShadowToken, diff --git a/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts b/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts new file mode 100644 index 0000000..0cf94be --- /dev/null +++ b/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from 'vitest'; +import { convertToGeneratorTokens } from '../../../transformers/variants/convert-to-generator-tokens'; +import { StyleToken, VariableToken } from '../../../types'; + +/** + * Helper to build a minimal parsedStyleToken input for convertToGeneratorTokens. + * Mirrors the shape returned by convertVariantGroupBy. + */ +function createParsedStyleToken({ + property, + rawValue, + variableToken, + variableKey, + path = [], +}: { + property: string; + rawValue: string; + variableToken?: VariableToken; + /** The key under which the variable is stored in variableTokenMapByProperty */ + variableKey?: string; + path?: SceneNode[]; +}) { + const variableTokenMapByProperty = new Map(); + if (variableToken && variableKey) { + variableTokenMapByProperty.set(variableKey, variableToken); + } + + const styleToken: StyleToken = { + type: 'style', + name: 'test', + value: rawValue, + rawValue, + valueType: 'px', + property, + path, + metadata: { figmaId: 'test-node' }, + variableTokenMapByProperty, + }; + + return [ + { + key: 'test-variant', + path, + componentId: null, + componentSetId: null, + variants: {}, + styles: { [property]: rawValue }, + tokens: [styleToken], + }, + ] as unknown as ReturnType< + typeof import('../../../transformers/variants/convert-variant-group-by').convertVariantGroupBy + >; +} + +function createVariableToken( + overrides: Partial & { name: string; primitiveRef?: string }, +): VariableToken { + return { + type: 'variable', + path: [], + property: 'color', + value: overrides.name, + rawValue: '#4f5153', + valueType: null, + metadata: { + figmaId: 'var-node', + variableId: 'var-1', + variableName: overrides.name, + variableTokenType: 'semantic', + }, + ...overrides, + } as VariableToken; +} + +describe('convertToGeneratorTokens', () => { + describe('semantic variable name detection', () => { + it('should set semanticVariableName for a true alias (name !== primitiveRef)', () => { + const variableToken = createVariableToken({ + name: 'navigation-border-neutral-footer-divider', + primitiveRef: 'gray-800', + }); + + const input = createParsedStyleToken({ + property: 'border', + rawValue: '1px solid #4f5153', + variableToken, + variableKey: 'border', + }); + + const result = convertToGeneratorTokens(input); + expect(result[0].tokens[0].semanticVariableName).toBe( + 'navigation-border-neutral-footer-divider', + ); + }); + + it('should NOT set semanticVariableName for a primitive (name === primitiveRef)', () => { + const variableToken = createVariableToken({ + name: 'blue-500', + primitiveRef: 'blue-500', + }); + + const input = createParsedStyleToken({ + property: 'background', + rawValue: '#0028a0', + variableToken, + variableKey: 'background', + }); + + const result = convertToGeneratorTokens(input); + expect(result[0].tokens[0].semanticVariableName).toBeUndefined(); + }); + + it('should set semanticVariableName when primitiveRef is undefined (legacy tokens)', () => { + const variableToken = createVariableToken({ + name: 'action-bg-primary', + primitiveRef: undefined, + }); + + const input = createParsedStyleToken({ + property: 'background', + rawValue: '#0028a0', + variableToken, + variableKey: 'background', + }); + + const result = convertToGeneratorTokens(input); + expect(result[0].tokens[0].semanticVariableName).toBe('action-bg-primary'); + }); + + it('should NOT set semanticVariableName when variableTokenType is primitive', () => { + const variableToken = createVariableToken({ + name: 'gray-800', + primitiveRef: 'gray-800', + metadata: { + figmaId: 'var-node', + variableId: 'var-1', + variableName: 'gray-800', + variableTokenType: 'primitive', + }, + }); + + const input = createParsedStyleToken({ + property: 'color', + rawValue: '#4f5153', + variableToken, + variableKey: 'color', + }); + + const result = convertToGeneratorTokens(input); + expect(result[0].tokens[0].semanticVariableName).toBeUndefined(); + }); + }); + + describe('box-shadow strokes fallback', () => { + it('should use strokes key for box-shadow semantic variable when property key has no variable', () => { + const strokeToken = createVariableToken({ + name: 'form-border-neutral-disabled', + primitiveRef: 'gray-800', + }); + + const variableTokenMapByProperty = new Map(); + variableTokenMapByProperty.set('strokes', strokeToken); + + const styleToken: StyleToken = { + type: 'style', + name: 'test', + value: 'inset 0 1px 0 0 #4f5153', + rawValue: 'inset 0 1px 0 0 #4f5153', + valueType: 'px', + property: 'box-shadow', + path: [], + metadata: { figmaId: 'test-node' }, + variableTokenMapByProperty, + }; + + const input = [ + { + key: 'test-variant', + path: [], + componentId: null, + componentSetId: null, + variants: {}, + styles: { 'box-shadow': 'inset 0 1px 0 0 #4f5153' }, + tokens: [styleToken], + }, + ] as unknown as ReturnType< + typeof import('../../../transformers/variants/convert-variant-group-by').convertVariantGroupBy + >; + + const result = convertToGeneratorTokens(input); + expect(result[0].tokens[0].semanticVariableName).toBe('form-border-neutral-disabled'); + }); + + it('should NOT use strokes key when the stroke variable is a primitive (name === primitiveRef)', () => { + const strokeToken = createVariableToken({ + name: 'gray-800', + primitiveRef: 'gray-800', + }); + + const variableTokenMapByProperty = new Map(); + variableTokenMapByProperty.set('strokes', strokeToken); + + const styleToken: StyleToken = { + type: 'style', + name: 'test', + value: 'inset 0 1px 0 0 #4f5153', + rawValue: 'inset 0 1px 0 0 #4f5153', + valueType: 'px', + property: 'box-shadow', + path: [], + metadata: { figmaId: 'test-node' }, + variableTokenMapByProperty, + }; + + const input = [ + { + key: 'test-variant', + path: [], + componentId: null, + componentSetId: null, + variants: {}, + styles: { 'box-shadow': 'inset 0 1px 0 0 #4f5153' }, + tokens: [styleToken], + }, + ] as unknown as ReturnType< + typeof import('../../../transformers/variants/convert-variant-group-by').convertVariantGroupBy + >; + + const result = convertToGeneratorTokens(input); + expect(result[0].tokens[0].semanticVariableName).toBeUndefined(); + }); + }); +}); diff --git a/packages/figma/src/transformers/tailwind/generators.ts b/packages/figma/src/transformers/tailwind/generators.ts index 38f15a7..2bb7457 100644 --- a/packages/figma/src/transformers/tailwind/generators.ts +++ b/packages/figma/src/transformers/tailwind/generators.ts @@ -210,10 +210,21 @@ export function parseBorderShorthand(border: string) { - "5px 10px 15px 20px" → top-left, top-right, bottom-right, bottom-left */ export const generateTailwindBorderRadiusClass: Generator = ({ rawValue }, dynamicTheme?) => { - const borderRadiusMapping = dynamicTheme?.borderRadius || borderRadius; + // Merge static fallbacks with theme mappings. 50% is used by ellipse/circle + // elements and should map to 'full' (equivalent to Tailwind's rounded-full). + const borderRadiusMapping = { '50%': 'full', ...(dynamicTheme?.borderRadius || borderRadius) }; const radiusCorners = ['tl', 'tr', 'br', 'bl'] as const; - return normalizeBorderRadius(rawValue) - .map((v) => (v === '0' ? '0px' : v)) //changing 0 to 0px tailwind utility picks it up + + const normalizedCorners = normalizeBorderRadius(rawValue).map((v) => (v === '0' ? '0px' : v)); + + // When all corners have the same value, use the shorthand form + // (e.g., rounded-full instead of rounded-tl-full rounded-tr-full ...) + if (normalizedCorners.every((v) => v === normalizedCorners[0])) { + const normalizedToken = normalizeTailwindToken(borderRadiusMapping, normalizedCorners[0]); + return normalizedToken ? `rounded-${normalizedToken}` : 'rounded'; + } + + return normalizedCorners .map((sizeValue, i) => { const normalizedToken = normalizeTailwindToken(borderRadiusMapping, sizeValue); @@ -249,13 +260,21 @@ export const generateTailwindBorderClass: Generator = (token, dynamicTheme?) => } if (color) { - borderResult.push( - `${borderPropertyToShorthand[token.property]}-${normalizeTailwindToken( - colorsMapping, - color, - color, - )}`, - ); + // When a semantic variable is available, use it for the color part instead + // of resolving through the primitive hex-to-name mapping. This ensures + // border colors reference semantic tokens that adapt to theme changes. + if (token.semanticVariableName) { + const cleanName = token.semanticVariableName.replace(/^colors-/, '').replace(/^color-/, ''); + borderResult.push(`${borderPropertyToShorthand[token.property]}-${cleanName}`); + } else { + borderResult.push( + `${borderPropertyToShorthand[token.property]}-${normalizeTailwindToken( + colorsMapping, + color, + color, + )}`, + ); + } } return borderResult.join(' '); @@ -357,6 +376,15 @@ export const generateTailwindBoxShadowClass: Generator = (token, dynamicTheme?) const colorPattern = /#[0-9a-fA-F]{6}/g; resolvedShadow = resolvedShadow.replace(colorPattern, (hexColor) => { + // When a semantic variable is available (e.g., for INSIDE border colors), + // use it instead of the primitive hex-to-name mapping. This ensures + // box-shadow border colors reference semantic tokens that adapt to themes. + if (token.semanticVariableName) { + const cleanName = token.semanticVariableName + .replace(/^colors-/, '') + .replace(/^color-/, ''); + return `var(--${cleanName})`; + } const normalizedHex = hexColor.toLowerCase(); const colorThemeName = colorMapping[normalizedHex]; return colorThemeName ? `var(--${colorThemeName})` : hexColor; diff --git a/packages/figma/src/transformers/variants/convert-to-generator-tokens.ts b/packages/figma/src/transformers/variants/convert-to-generator-tokens.ts index 042676d..49851c4 100644 --- a/packages/figma/src/transformers/variants/convert-to-generator-tokens.ts +++ b/packages/figma/src/transformers/variants/convert-to-generator-tokens.ts @@ -18,7 +18,27 @@ export const convertToGeneratorTokens = ( if (styleToken?.variableTokenMapByProperty) { const variableToken = styleToken.variableTokenMapByProperty.get(property); if (variableToken?.metadata?.variableTokenType === 'semantic') { - semanticVariableName = variableToken.name; + // Only use semantic variable name if the variable is a TRUE alias + // (its name differs from its primitiveRef). Primitives bound directly + // to nodes have name === primitiveRef and should NOT be used as semantic + // utility names since those utilities may not exist in the design system + // (e.g., prevents generating 'icon-blue-500' instead of 'icon-text-primary-default'). + if (!variableToken.primitiveRef || variableToken.name !== variableToken.primitiveRef) { + semanticVariableName = variableToken.name; + } + } + + // For box-shadow property, also check 'strokes' key since INSIDE borders + // use stroke color variables but the style property is 'box-shadow' + if (!semanticVariableName && property === 'box-shadow') { + const strokeToken = styleToken.variableTokenMapByProperty.get('strokes'); + if ( + strokeToken?.metadata?.variableTokenType === 'semantic' && + strokeToken.primitiveRef && + strokeToken.name !== strokeToken.primitiveRef + ) { + semanticVariableName = strokeToken.name; + } } } From 1ace04cae1c409aff52ccff8f280b19d7ba352c5 Mon Sep 17 00:00:00 2001 From: Stefan Attoh Date: Tue, 17 Feb 2026 09:05:00 -0700 Subject: [PATCH 2/5] update comment --- packages/figma/src/transformers/tailwind/generators.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/figma/src/transformers/tailwind/generators.ts b/packages/figma/src/transformers/tailwind/generators.ts index 2bb7457..71b7271 100644 --- a/packages/figma/src/transformers/tailwind/generators.ts +++ b/packages/figma/src/transformers/tailwind/generators.ts @@ -210,8 +210,11 @@ export function parseBorderShorthand(border: string) { - "5px 10px 15px 20px" → top-left, top-right, bottom-right, bottom-left */ export const generateTailwindBorderRadiusClass: Generator = ({ rawValue }, dynamicTheme?) => { - // Merge static fallbacks with theme mappings. 50% is used by ellipse/circle - // elements and should map to 'full' (equivalent to Tailwind's rounded-full). + // Figma's border.processor emits '50%' for ELLIPSE nodes (circles/ovals) since + // CSS `border-radius: 50%` is how circles are defined. Tailwind's default theme + // uses '9999px' for 'full', so '50%' has no match in theme-tokens. We add it as + // a static fallback here so ellipses correctly produce `rounded-full`. + // The spread order lets a dynamicTheme override '50%' if a design system needs to. const borderRadiusMapping = { '50%': 'full', ...(dynamicTheme?.borderRadius || borderRadius) }; const radiusCorners = ['tl', 'tr', 'br', 'bl'] as const; From c1836f26aa352699f12911c24ff0ad1ba80a8d1c Mon Sep 17 00:00:00 2001 From: Stefan Attoh Date: Mon, 2 Mar 2026 11:03:42 -0700 Subject: [PATCH 3/5] fix import --- .../variants/convert-to-generator-tokens.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts b/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts index 0cf94be..87bb583 100644 --- a/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts +++ b/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { convertToGeneratorTokens } from '../../../transformers/variants/convert-to-generator-tokens'; import { StyleToken, VariableToken } from '../../../types'; +import type { convertVariantGroupBy } from '../../../transformers/variants/convert-variant-group-by'; /** * Helper to build a minimal parsedStyleToken input for convertToGeneratorTokens. @@ -47,9 +48,7 @@ function createParsedStyleToken({ styles: { [property]: rawValue }, tokens: [styleToken], }, - ] as unknown as ReturnType< - typeof import('../../../transformers/variants/convert-variant-group-by').convertVariantGroupBy - >; + ] as unknown as ReturnType; } function createVariableToken( From 57d6eaea3df433a3f73f4fdbc1b3a481fbb4f540 Mon Sep 17 00:00:00 2001 From: Stefan Attoh Date: Tue, 3 Mar 2026 09:37:00 -0700 Subject: [PATCH 4/5] missing bracket --- .../figma/src/tests/transformers/tailwind/generators.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/figma/src/tests/transformers/tailwind/generators.test.ts b/packages/figma/src/tests/transformers/tailwind/generators.test.ts index 64b858d..66d7475 100644 --- a/packages/figma/src/tests/transformers/tailwind/generators.test.ts +++ b/packages/figma/src/tests/transformers/tailwind/generators.test.ts @@ -255,6 +255,8 @@ describe('generateTailwindBorderClass', () => { }; const result = generateTailwindBorderClass(borderTokenRgba); expect(result).toBe('border-[rgba(0,0,0,0.03)]'); + }); + it('should use semantic variable name for border color when available', () => { const borderTokenWithSemantic = { ...borderToken, From 2f4723c11da3ebec6203802ccd5cd22922a700d4 Mon Sep 17 00:00:00 2001 From: Stefan Attoh Date: Tue, 3 Mar 2026 09:51:42 -0700 Subject: [PATCH 5/5] fix typing --- .../variants/convert-to-generator-tokens.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts b/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts index 87bb583..34400ab 100644 --- a/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts +++ b/packages/figma/src/tests/transformers/variants/convert-to-generator-tokens.test.ts @@ -48,7 +48,7 @@ function createParsedStyleToken({ styles: { [property]: rawValue }, tokens: [styleToken], }, - ] as unknown as ReturnType; + ] as unknown as ReturnType['selectors']; } function createVariableToken( @@ -184,7 +184,7 @@ describe('convertToGeneratorTokens', () => { }, ] as unknown as ReturnType< typeof import('../../../transformers/variants/convert-variant-group-by').convertVariantGroupBy - >; + >['selectors']; const result = convertToGeneratorTokens(input); expect(result[0].tokens[0].semanticVariableName).toBe('form-border-neutral-disabled'); @@ -223,7 +223,7 @@ describe('convertToGeneratorTokens', () => { }, ] as unknown as ReturnType< typeof import('../../../transformers/variants/convert-variant-group-by').convertVariantGroupBy - >; + >['selectors']; const result = convertToGeneratorTokens(input); expect(result[0].tokens[0].semanticVariableName).toBeUndefined();