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[][] {