diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index db78dd0..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npm run build:*)", - "Bash(cmd /c \"npm run build 2>&1\")", - "Bash(bun run build:*)", - "Bash(bun run tsc:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index 52f541d..79a729e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ -# Node -*.log -*.log.* -node_modules -.idea - -out/ -dist/ -code.js -coverage +# Node +*.log +*.log.* +node_modules +.idea + +out/ +dist/ +code.js +coverage + +.claude \ No newline at end of file diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap index e8f7da4..3ea73d9 100644 --- a/src/__tests__/__snapshots__/code.test.ts.snap +++ b/src/__tests__/__snapshots__/code.test.ts.snap @@ -1,36 +1,36 @@ -// Bun Snapshot v1, https://bun.sh/docs/test/snapshots - -exports[`registerCodegen should register codegen 1`] = ` -[ - { - "code": -"export function Test() { - return - }" -, - "language": "TYPESCRIPT", - "title": "Test - Components", - }, - { - "code": -"echo 'export function Test() { - return - }' > Test.tsx" -, - "language": "BASH", - "title": "Test - Components CLI", - }, -] -`; - -exports[`registerCodegen should register codegen 2`] = ` -[ - { - "code": "", - "language": "TYPESCRIPT", - "title": "Main", - }, -] -`; - -exports[`registerCodegen should register codegen 3`] = `[]`; +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`registerCodegen should register codegen 1`] = ` +[ + { + "code": +"export function Test() { + return + }" +, + "language": "TYPESCRIPT", + "title": "Test - Components", + }, + { + "code": +"echo 'export function Test() { + return + }' > Test.tsx" +, + "language": "BASH", + "title": "Test - Components CLI", + }, +] +`; + +exports[`registerCodegen should register codegen 2`] = ` +[ + { + "code": "", + "language": "TYPESCRIPT", + "title": "Main", + }, +] +`; + +exports[`registerCodegen should register codegen 3`] = `[]`; diff --git a/src/codegen/utils/__tests__/paint-to-css.test.ts b/src/codegen/utils/__tests__/paint-to-css.test.ts index 27d0b84..5593607 100644 --- a/src/codegen/utils/__tests__/paint-to-css.test.ts +++ b/src/codegen/utils/__tests__/paint-to-css.test.ts @@ -60,4 +60,515 @@ describe('paintToCSS', () => { ) expect(res).toBeNull() }) + + test('converts linear gradient with color token', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'primary-color' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_LINEAR', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + { + position: 1, + color: { r: 0, g: 0, b: 1, a: 1 }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('$primaryColor') + expect(res).toContain('linear-gradient') + }) + + test('converts linear gradient with color token and opacity < 1', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'primary-color' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_LINEAR', + visible: true, + opacity: 0.5, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('color-mix') + expect(res).toContain('$primaryColor') + expect(res).toContain('transparent 50%') + }) + + test('converts radial gradient with color token and opacity', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve({ name: 'accent' })), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_RADIAL', + visible: true, + opacity: 0.8, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('radial-gradient') + expect(res).toContain('color-mix') + expect(res).toContain('$accent') + }) + + test('converts angular gradient with color token', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve({ name: 'bg-color' })), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_ANGULAR', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('conic-gradient') + expect(res).toContain('$bgColor') + }) + + test('converts diamond gradient with color token and partial opacity', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'surface-color' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_DIAMOND', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 0.6 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('linear-gradient') + expect(res).toContain('color-mix') + expect(res).toContain('$surfaceColor') + expect(res).toContain('transparent 40%') + }) + + test('converts gradient with boundVariable but no variable name (fallback)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve(null)), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_LINEAR', + visible: true, + opacity: 0.5, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('linear-gradient') + expect(res).not.toContain('$') + expect(res).not.toContain('color-mix') + }) + + test('converts radial gradient with boundVariable but no variable found', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve({})), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_RADIAL', + visible: true, + opacity: 0.8, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('radial-gradient') + expect(res).not.toContain('$') + }) + + test('converts angular gradient with boundVariable but no variable found', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve({})), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_ANGULAR', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('conic-gradient') + expect(res).not.toContain('$') + }) + + test('converts diamond gradient with boundVariable but no variable found', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => Promise.resolve({})), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_DIAMOND', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('linear-gradient') + expect(res).not.toContain('$') + }) + + test('converts diamond gradient with color token and full opacity (no color-mix)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'primary-full' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_DIAMOND', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('linear-gradient') + expect(res).toContain('$primaryFull') + expect(res).not.toContain('color-mix') + }) + + test('converts angular gradient with color token and full opacity (no color-mix)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'secondary-full' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_ANGULAR', + visible: true, + opacity: 1, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('conic-gradient') + expect(res).toContain('$secondaryFull') + expect(res).not.toContain('color-mix') + }) + + test('converts angular gradient with color token and partial opacity (with color-mix)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + variables: { + getVariableByIdAsync: mock(() => + Promise.resolve({ name: 'accent-partial' }), + ), + }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_ANGULAR', + visible: true, + opacity: 0.6, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + boundVariables: { color: { id: 'var-1' } }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('conic-gradient') + expect(res).toContain('color-mix') + expect(res).toContain('$accentPartial') + expect(res).toContain('transparent 40%') + }) + + test('converts diamond gradient without color token (regular color)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_DIAMOND', + visible: true, + opacity: 0.8, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('linear-gradient') + expect(res).not.toContain('$') + expect(res).not.toContain('color-mix') + }) + + test('converts angular gradient without color token (regular color)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_ANGULAR', + visible: true, + opacity: 0.7, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('conic-gradient') + expect(res).not.toContain('$') + expect(res).not.toContain('color-mix') + }) + + test('converts radial gradient without color token (regular color)', async () => { + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + } as unknown as typeof figma + + const res = await paintToCSS( + { + type: 'GRADIENT_RADIAL', + visible: true, + opacity: 0.9, + gradientTransform: [ + [1, 0, 0.5], + [0, 1, 0.5], + ], + gradientStops: [ + { + position: 0, + color: { r: 1, g: 0, b: 0, a: 1 }, + }, + ], + } as unknown as GradientPaint, + { width: 100, height: 100 } as unknown as SceneNode, + false, + ) + + expect(res).toContain('radial-gradient') + expect(res).not.toContain('$') + expect(res).not.toContain('color-mix') + }) }) diff --git a/src/codegen/utils/paint-to-css.ts b/src/codegen/utils/paint-to-css.ts index 7ba4edf..a729cf4 100644 --- a/src/codegen/utils/paint-to-css.ts +++ b/src/codegen/utils/paint-to-css.ts @@ -1,5 +1,6 @@ import { optimizeHex } from '../../utils/optimize-hex' import { rgbaToHex } from '../../utils/rgba-to-hex' +import { toCamel } from '../../utils/to-camel' import { checkAssetNode } from './check-asset-node' import { fmtPct } from './fmtPct' import { solidToString } from './solid-to-string' @@ -24,13 +25,13 @@ export async function paintToCSS( ? await solidToString(fill) : await convertSolidLinearGradient(fill) case 'GRADIENT_LINEAR': - return convertGradientLinear(fill, node.width, node.height) + return await convertGradientLinear(fill, node.width, node.height) case 'GRADIENT_RADIAL': - return convertRadial(fill, node.width, node.height) + return await convertRadial(fill, node.width, node.height) case 'GRADIENT_ANGULAR': - return convertAngular(fill, node.width, node.height) + return await convertAngular(fill, node.width, node.height) case 'GRADIENT_DIAMOND': - return convertDiamond(fill, node.width, node.height) + return await convertDiamond(fill, node.width, node.height) case 'IMAGE': return convertImage(fill) case 'PATTERN': @@ -59,25 +60,51 @@ function convertImage(fill: ImagePaint): string { } } -function convertDiamond( +async function convertDiamond( fill: GradientPaint, _width: number, _height: number, -): string { +): Promise { // Handle opacity & visibility: if (!fill.visible) return 'transparent' if (fill.opacity === 0) return 'transparent' // 1. Map gradient stops with opacity - const stops = fill.gradientStops - .map((stop) => { - const colorWithOpacity = figma.util.rgba({ - ...stop.color, - a: stop.color.a * (fill.opacity ?? 1), - }) - return `${optimizeHex(rgbaToHex(colorWithOpacity))} ${fmtPct(stop.position * 50)}%` - }) - .join(', ') + const stopsArray = await Promise.all( + fill.gradientStops.map(async (stop) => { + let colorString: string + if (stop.boundVariables?.color) { + const variable = await figma.variables.getVariableByIdAsync( + stop.boundVariables.color.id as string, + ) + if (variable?.name) { + const tokenName = `$${toCamel(variable.name)}` + const finalAlpha = stop.color.a * (fill.opacity ?? 1) + + if (finalAlpha < 1) { + const transparentPercent = fmtPct((1 - finalAlpha) * 100) + colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)` + } else { + colorString = tokenName + } + } else { + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * (fill.opacity ?? 1), + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + } else { + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * (fill.opacity ?? 1), + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + return `${colorString} ${fmtPct(stop.position * 50)}%` + }), + ) + const stops = stopsArray.join(', ') // 2. Create 4 linear gradients for diamond effect // Each gradient goes from corner to center @@ -92,11 +119,11 @@ function convertDiamond( return gradients.join(', ') } -function convertAngular( +async function convertAngular( fill: GradientPaint, width: number, height: number, -): string { +): Promise { // Handle opacity & visibility: if (!fill.visible) return 'transparent' if (fill.opacity === 0) return 'transparent' @@ -113,25 +140,51 @@ function convertAngular( const centerY = fmtPct((center.y / height) * 100) // 3. Map gradient stops with opacity - const stops = fill.gradientStops - .map((stop) => { - const colorWithOpacity = figma.util.rgba({ - ...stop.color, - a: stop.color.a * (fill.opacity ?? 1), - }) - return `${optimizeHex(rgbaToHex(colorWithOpacity))} ${fmtPct(stop.position * 100)}%` - }) - .join(', ') + const stopsArray = await Promise.all( + fill.gradientStops.map(async (stop) => { + let colorString: string + if (stop.boundVariables?.color) { + const variable = await figma.variables.getVariableByIdAsync( + stop.boundVariables.color.id as string, + ) + if (variable?.name) { + const tokenName = `$${toCamel(variable.name)}` + const finalAlpha = stop.color.a * (fill.opacity ?? 1) + + if (finalAlpha < 1) { + const transparentPercent = fmtPct((1 - finalAlpha) * 100) + colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)` + } else { + colorString = tokenName + } + } else { + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * (fill.opacity ?? 1), + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + } else { + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * (fill.opacity ?? 1), + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + return `${colorString} ${fmtPct(stop.position * 100)}%` + }), + ) + const stops = stopsArray.join(', ') // 4. Generate CSS conic gradient string with calculated start angle return `conic-gradient(from ${fmtPct(startAngle)}deg at ${centerX}% ${centerY}%, ${stops})` } -function convertRadial( +async function convertRadial( fill: GradientPaint, width: number, height: number, -): string { +): Promise { // Handle opacity & visibility: if (!fill.visible) return 'transparent' if (fill.opacity === 0) return 'transparent' @@ -152,15 +205,41 @@ function convertRadial( const radiusPercentH = fmtPct((radiusH / height) * 100) // 4. Map gradient stops with opacity - const stops = fill.gradientStops - .map((stop) => { - const colorWithOpacity = figma.util.rgba({ - ...stop.color, - a: stop.color.a * (fill.opacity ?? 1), - }) - return `${optimizeHex(rgbaToHex(colorWithOpacity))} ${fmtPct(stop.position * 100)}%` - }) - .join(', ') + const stopsArray = await Promise.all( + fill.gradientStops.map(async (stop) => { + let colorString: string + if (stop.boundVariables?.color) { + const variable = await figma.variables.getVariableByIdAsync( + stop.boundVariables.color.id as string, + ) + if (variable?.name) { + const tokenName = `$${toCamel(variable.name)}` + const finalAlpha = stop.color.a * (fill.opacity ?? 1) + + if (finalAlpha < 1) { + const transparentPercent = fmtPct((1 - finalAlpha) * 100) + colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)` + } else { + colorString = tokenName + } + } else { + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * (fill.opacity ?? 1), + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + } else { + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * (fill.opacity ?? 1), + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + return `${colorString} ${fmtPct(stop.position * 100)}%` + }), + ) + const stops = stopsArray.join(', ') // 5. Generate CSS radial gradient string return `radial-gradient(${radiusPercentW}% ${radiusPercentH}% at ${centerX}% ${centerY}%, ${stops})` } @@ -217,11 +296,11 @@ async function convertSolidLinearGradient(fill: SolidPaint): Promise { return `linear-gradient(${color}, ${color})` } -function convertGradientLinear( +async function convertGradientLinear( gradientData: GradientPaint, width: number, height: number, -): string | null { +): Promise { // Handle opacity & visibility: if (!gradientData.visible) return null if (gradientData.opacity === 0) return 'transparent' @@ -257,7 +336,7 @@ function convertGradientLinear( ) // 6. Map Figma gradient stops to CSS space - const stops = _mapGradientStops( + const stops = await _mapGradientStops( gradientData.gradientStops, start, end, @@ -268,10 +347,7 @@ function convertGradientLinear( // 7. Generate CSS linear gradient string return `linear-gradient(${cssAngle}deg, ${stops - .map( - (stop) => - `${optimizeHex(rgbaToHex(stop.color))} ${fmtPct(stop.position * 100)}%`, - ) + .map((stop) => `${stop.colorString} ${fmtPct(stop.position * 100)}%`) .join(', ')})` } @@ -346,7 +422,7 @@ function _calculateCSSStartEnd( } } -function _mapGradientStops( +async function _mapGradientStops( stops: readonly ColorStop[], figmaStartPoint: Point, figmaEndPoint: Point, @@ -365,31 +441,65 @@ function _mapGradientStops( } const cssLengthSquared = cssVector.x ** 2 + cssVector.y ** 2 - return stops.map((stop) => { - // Calculate actual pixel position of stop in Figma space (offset) - const offsetX = figmaStartPoint.x + figmaVector.x * stop.position - const offsetY = figmaStartPoint.y + figmaVector.y * stop.position - - // Compute signed relative position along CSS gradient line (can be <0 or >1) - // t = dot(P - start, (end - start)) / |end - start|^2 - const pointFromStart = { - x: offsetX - cssStartPoint.x, - y: offsetY - cssStartPoint.y, - } - const dot = pointFromStart.x * cssVector.x + pointFromStart.y * cssVector.y - const relativePosition = cssLengthSquared === 0 ? 0 : dot / cssLengthSquared - - // Apply gradient opacity to the color stop - const colorWithOpacity = figma.util.rgba({ - ...stop.color, - a: stop.color.a * opacity, - }) - - return { - position: relativePosition, - color: colorWithOpacity, - } - }) + return await Promise.all( + stops.map(async (stop) => { + // Calculate actual pixel position of stop in Figma space (offset) + const offsetX = figmaStartPoint.x + figmaVector.x * stop.position + const offsetY = figmaStartPoint.y + figmaVector.y * stop.position + + // Compute signed relative position along CSS gradient line (can be <0 or >1) + // t = dot(P - start, (end - start)) / |end - start|^2 + const pointFromStart = { + x: offsetX - cssStartPoint.x, + y: offsetY - cssStartPoint.y, + } + const dot = + pointFromStart.x * cssVector.x + pointFromStart.y * cssVector.y + const relativePosition = + cssLengthSquared === 0 ? 0 : dot / cssLengthSquared + + // Check if this color stop uses a color token + let colorString: string + if (stop.boundVariables?.color) { + const variable = await figma.variables.getVariableByIdAsync( + stop.boundVariables.color.id as string, + ) + if (variable?.name) { + const tokenName = `$${toCamel(variable.name)}` + // Calculate final alpha combining stop alpha and gradient opacity + const finalAlpha = stop.color.a * opacity + + // Use color-mix to apply opacity to color token + if (finalAlpha < 1) { + const transparentPercent = fmtPct((1 - finalAlpha) * 100) + colorString = `color-mix(in srgb, ${tokenName}, transparent ${transparentPercent}%)` + } else { + colorString = tokenName + } + } else { + // Fallback to computed color with opacity + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * opacity, + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + } else { + // Apply gradient opacity to the color stop + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * opacity, + }) + colorString = optimizeHex(rgbaToHex(colorWithOpacity)) + } + + return { + position: relativePosition, + colorString, + hasToken: !!stop.boundVariables?.color, + } + }), + ) } function _inverseMatrix(matrix: number[][]): number[][] {