From 776441aadae7f8016b2d94afdfd04753e7f35fbb Mon Sep 17 00:00:00 2001 From: Lukas Heddendorp Date: Fri, 23 Oct 2026 18:09:24 +0200 Subject: [PATCH 1/2] Import Figma composed colors with authored opacity Co-authored-by: Codex --- .changeset/figma-composed-colors.md | 5 + .../cli/src/import/figma/composed-color.ts | 114 +++++++++ packages/cli/src/import/figma/variables.ts | 48 ++-- .../cli/test/figma-composed-colors.test.ts | 216 ++++++++++++++++++ 4 files changed, 363 insertions(+), 20 deletions(-) create mode 100644 .changeset/figma-composed-colors.md create mode 100644 packages/cli/src/import/figma/composed-color.ts create mode 100644 packages/cli/test/figma-composed-colors.test.ts diff --git a/.changeset/figma-composed-colors.md b/.changeset/figma-composed-colors.md new file mode 100644 index 00000000..cc19ca5a --- /dev/null +++ b/.changeset/figma-composed-colors.md @@ -0,0 +1,5 @@ +--- +"@terrazzo/cli": patch +--- + +Import Figma composed color variables with numeric or aliased opacity instead of emitting undefined color components. Resolve composed values for the corresponding named mode (or the referenced collection default), include nested alias dependencies, and detect cyclic references. diff --git a/packages/cli/src/import/figma/composed-color.ts b/packages/cli/src/import/figma/composed-color.ts new file mode 100644 index 00000000..4117b10b --- /dev/null +++ b/packages/cli/src/import/figma/composed-color.ts @@ -0,0 +1,114 @@ +import type { LocalVariable, LocalVariableCollection, RGBA } from '@figma/rest-api-spec'; + +import { formatName } from './lib.js'; + +export function getAliasID(value: unknown): string | undefined { + if ( + typeof value === 'object' && + value && + 'type' in value && + value.type === 'VARIABLE_ALIAS' && + 'id' in value && + typeof value.id === 'string' + ) { + return value.id; + } + return undefined; +} + +export function isComposedColor(value: unknown): value is { color: unknown; opacity: unknown } { + return typeof value === 'object' && value !== null && 'color' in value && 'opacity' in value; +} + +export function getAliasIDs(value: unknown): string[] { + const id = getAliasID(value); + if (id) { + return [id]; + } + return isComposedColor(value) ? [...getAliasIDs(value.color), ...getAliasIDs(value.opacity)] : []; +} + +export function resolveComposedColor( + value: { color: unknown; opacity: unknown }, + modeID: string, + { + variables, + collections, + remoteIDs, + }: { + variables: Record; + collections: Record; + remoteIDs: Set; + }, +): RGBA | undefined { + const modeName = Object.values(collections) + .flatMap((collection) => collection.modes) + .find((mode) => mode.modeId === modeID)?.name; + const active = new Set(); + + function resolve(input: unknown): unknown { + const id = getAliasID(input); + if (id) { + const variable = variables[id]; + if (!variable) { + remoteIDs.add(id); + return undefined; + } + if (active.has(id)) { + throw new Error(`Cyclic Figma composed color alias: ${id}`); + } + const collection = collections[variable.variableCollectionId]; + if (!collection) { + throw new Error(`Missing Figma collection for composed color alias: ${id}`); + } + const targetModeID = + modeID in variable.valuesByMode + ? modeID + : (collection.modes.find( + (mode) => modeName !== undefined && formatName(mode.name) === formatName(modeName), + )?.modeId ?? collection.defaultModeId); + if (!(targetModeID in variable.valuesByMode)) { + throw new Error(`Missing Figma mode value for composed color alias: ${id}`); + } + active.add(id); + const resolved = resolve(variable.valuesByMode[targetModeID]); + active.delete(id); + return resolved; + } + if (isComposedColor(input)) { + const color = resolve(input.color); + const opacity = resolve(input.opacity); + if (color === undefined || opacity === undefined) { + return undefined; + } + if ( + typeof color !== 'object' || + color === null || + !['r', 'g', 'b'].every( + (channel) => + channel in color && + typeof (color as Record)[channel] === 'number' && + Number.isFinite((color as Record)[channel]), + ) + ) { + throw new TypeError('Figma composed color must resolve to RGB or RGBA.'); + } + if ( + typeof opacity !== 'number' || + !Number.isFinite(opacity) || + opacity < 0 || + opacity > 100 + ) { + throw new TypeError( + 'Figma composed color opacity must resolve to a percentage from 0 to 100.', + ); + } + const { r, g, b } = color as RGBA; + // Composed opacity is independently authored, not multiplied by the source alpha. + return { r, g, b, a: opacity / 100 }; + } + return input; + } + + return resolve(value) as RGBA | undefined; +} diff --git a/packages/cli/src/import/figma/variables.ts b/packages/cli/src/import/figma/variables.ts index c8e5027e..ff8f146b 100644 --- a/packages/cli/src/import/figma/variables.ts +++ b/packages/cli/src/import/figma/variables.ts @@ -1,22 +1,14 @@ import type { LocalVariable, LocalVariableCollection, RGBA } from '@figma/rest-api-spec'; import type { Logger } from '@terrazzo/parser'; +import { + getAliasID, + getAliasIDs, + isComposedColor, + resolveComposedColor, +} from './composed-color.js'; import { formatName, getFileLocalVariables, getFilePublishedVariables } from './lib.js'; -function getAliasID(value: unknown): string | undefined { - if ( - typeof value === 'object' && - value && - 'type' in value && - value.type === 'VARIABLE_ALIAS' && - 'id' in value && - typeof value.id === 'string' - ) { - return value.id; - } - return undefined; -} - /** /v1/files/:file_key/variables/published | /v1/files/:file_key/variables/local */ export async function getVariables( fileKey: string, @@ -75,12 +67,13 @@ export async function getVariables( continue; } for (const value of Object.values(variable.valuesByMode)) { - const aliasID = getAliasID(value); - if (!aliasID || !allVariables[aliasID] || aliasID in finalVariables) { - continue; + for (const aliasID of getAliasIDs(value)) { + if (!allVariables[aliasID] || aliasID in finalVariables) { + continue; + } + finalVariables[aliasID] = allVariables[aliasID]!; + pendingIDs.push(aliasID); } - finalVariables[aliasID] = allVariables[aliasID]!; - pendingIDs.push(aliasID); } } @@ -139,7 +132,22 @@ export async function getVariables( // If this token is an alias of another, keep this as a value override const isAliasOfID = getAliasID(value); - if (isAliasOfID) { + if (variable.resolvedType === 'COLOR' && isComposedColor(value)) { + const color = resolveComposedColor(value, modeID, { + variables: allVariables, + collections: variableCollections, + remoteIDs, + }); + if (!color) { + continue; + } + tokenBase.$type = 'color'; + tokenBase.$value = { + colorSpace: 'srgb', + components: [color.r, color.g, color.b], + alpha: color.a, + }; + } else if (isAliasOfID) { if (allVariables[isAliasOfID]) { tokenBase.$type = matches || diff --git a/packages/cli/test/figma-composed-colors.test.ts b/packages/cli/test/figma-composed-colors.test.ts new file mode 100644 index 00000000..082c5c95 --- /dev/null +++ b/packages/cli/test/figma-composed-colors.test.ts @@ -0,0 +1,216 @@ +import type { LocalVariable, LocalVariableCollection } from '@figma/rest-api-spec'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getAliasIDs, resolveComposedColor } from '../src/import/figma/composed-color.js'; +import { getVariables } from '../src/import/figma/variables.js'; + +function alias(id: string) { + return { type: 'VARIABLE_ALIAS', id }; +} +const collections = { + theme: { + key: 'theme-key', + remote: false, + hiddenFromPublishing: false, + variableIds: [], + id: 'theme', + name: 'theme', + defaultModeId: 'light', + modes: [ + { modeId: 'light', name: 'Light' }, + { modeId: 'dark', name: 'Dark' }, + ], + }, + base: { + key: 'base-key', + remote: false, + hiddenFromPublishing: false, + variableIds: [], + id: 'base', + name: 'base', + defaultModeId: 'base-default', + modes: [{ modeId: 'base-default', name: 'Default' }], + }, + other: { + key: 'other-key', + remote: false, + hiddenFromPublishing: false, + variableIds: [], + id: 'other', + name: 'other', + defaultModeId: 'other-light', + modes: [ + { modeId: 'other-light', name: 'light' }, + { modeId: 'other-dark', name: 'dark' }, + ], + }, +} as Record; + +function variable( + id: string, + resolvedType: 'COLOR' | 'FLOAT', + valuesByMode: Record, +): LocalVariable { + return { + id, + name: id, + key: `${id}-key`, + variableCollectionId: 'base', + resolvedType, + valuesByMode, + description: '', + codeSyntax: {}, + hiddenFromPublishing: true, + } as LocalVariable; +} + +function fixtures(): Record { + const variables = { + palette: variable('palette', 'COLOR', { 'base-default': { r: 0.1, g: 0.2, b: 0.3, a: 0.8 } }), + opacity: variable('opacity', 'FLOAT', { 'base-default': 50 }), + semantic: variable('semantic', 'COLOR', { + light: { color: alias('palette'), opacity: alias('opacity') }, + dark: { color: alias('palette'), opacity: 0 }, + }), + }; + variables.semantic.variableCollectionId = 'theme'; + return variables; +} + +describe('Figma composed colors', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('discovers both nested dependency aliases', () => { + expect(getAliasIDs({ color: alias('palette'), opacity: alias('opacity') })).toEqual([ + 'palette', + 'opacity', + ]); + expect(getAliasIDs(alias('palette'))).toEqual(['palette']); + }); + + it.each([0, 25, 50, 100])('replaces source alpha with authored %s percent opacity', (opacity) => { + expect( + resolveComposedColor({ color: alias('palette'), opacity }, 'light', { + variables: fixtures(), + collections, + remoteIDs: new Set(), + }), + ).toEqual({ r: 0.1, g: 0.2, b: 0.3, a: opacity / 100 }); + }); + + it('resolves literal RGB with an aliased opacity', () => { + expect( + resolveComposedColor({ color: { r: 1, g: 0, b: 0 }, opacity: alias('opacity') }, 'light', { + variables: fixtures(), + collections, + remoteIDs: new Set(), + }), + ).toEqual({ r: 1, g: 0, b: 0, a: 0.5 }); + }); + + it('resolves alias chains through another composed color', () => { + expect( + resolveComposedColor({ color: alias('semantic'), opacity: 25 }, 'dark', { + variables: fixtures(), + collections, + remoteIDs: new Set(), + }), + ).toEqual({ r: 0.1, g: 0.2, b: 0.3, a: 0.25 }); + }); + + it('uses a matching mode name in another collection', () => { + const variables = fixtures(); + variables.opacity = variable('opacity', 'FLOAT', { 'other-light': 100, 'other-dark': 25 }); + variables.opacity.variableCollectionId = 'other'; + expect( + resolveComposedColor({ color: alias('palette'), opacity: alias('opacity') }, 'dark', { + variables, + collections, + remoteIDs: new Set(), + })?.a, + ).toBe(0.25); + }); + + it('reports unavailable nested aliases without emitting a color', () => { + const remoteIDs = new Set(); + expect( + resolveComposedColor({ color: alias('missing'), opacity: 50 }, 'light', { + variables: fixtures(), + collections, + remoteIDs, + }), + ).toBeUndefined(); + expect(remoteIDs).toEqual(new Set(['missing'])); + }); + + it('rejects cyclic alias chains', () => { + const variables = fixtures(); + variables.palette = variable('palette', 'COLOR', { 'base-default': alias('palette') }); + expect(() => + resolveComposedColor({ color: alias('palette'), opacity: 50 }, 'light', { + variables, + collections, + remoteIDs: new Set(), + }), + ).toThrow('Cyclic Figma composed color alias'); + }); + + it.each([-1, 101, Number.NaN, '50'])('rejects invalid opacity %s', (opacity) => { + expect(() => + resolveComposedColor({ color: alias('palette'), opacity }, 'light', { + variables: fixtures(), + collections, + remoteIDs: new Set(), + }), + ).toThrow('opacity must resolve to a percentage'); + }); + + it('rejects a non-color target instead of serializing null components', () => { + expect(() => + resolveComposedColor({ color: alias('opacity'), opacity: 50 }, 'light', { + variables: fixtures(), + collections, + remoteIDs: new Set(), + }), + ).toThrow('must resolve to RGB or RGBA'); + }); + + it.each([false, true])( + 'imports nested hidden dependencies with unpublished=%s', + async (unpublished) => { + const variables = fixtures(); + variables.semantic!.hiddenFromPublishing = false; + vi.stubEnv('FIGMA_ACCESS_TOKEN', 'fig_fake_token'); + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => + Promise.resolve( + Response.json({ + meta: url.endsWith('/local') + ? { variables, variableCollections: collections } + : { variables: { semantic: { id: 'semantic' } } }, + }), + ), + ), + ); + const result = await getVariables('test', { + logger: { warn() {} } as never, + unpublished, + matchers: { fontFamily: undefined, fontWeight: undefined, number: undefined }, + }); + expect(result.count).toBe(3); + expect(result.remoteCount).toBe(0); + expect(result.code.modifiers.theme.contexts.light[0].semantic.$value).toEqual({ + colorSpace: 'srgb', + components: [0.1, 0.2, 0.3], + alpha: 0.5, + }); + expect(result.code.modifiers.theme.contexts.dark[0].semantic.$value.alpha).toBe(0); + expect(result.code.sets.base.sources[0]).toHaveProperty('palette'); + expect(result.code.sets.base.sources[0]).toHaveProperty('opacity'); + }, + ); +}); From 0054310c6d9a64109bc496e799cc75a1509720ae Mon Sep 17 00:00:00 2001 From: Lukas Heddendorp Date: Fri, 23 Oct 2026 18:18:46 +0200 Subject: [PATCH 2/2] Scope composed color modes to their source collection Co-authored-by: Codex --- .../cli/src/import/figma/composed-color.ts | 12 ++-- packages/cli/src/import/figma/variables.ts | 1 + .../cli/test/figma-composed-colors.test.ts | 66 +++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/import/figma/composed-color.ts b/packages/cli/src/import/figma/composed-color.ts index 4117b10b..9e560458 100644 --- a/packages/cli/src/import/figma/composed-color.ts +++ b/packages/cli/src/import/figma/composed-color.ts @@ -35,15 +35,19 @@ export function resolveComposedColor( variables, collections, remoteIDs, + sourceCollectionID, }: { variables: Record; collections: Record; remoteIDs: Set; + sourceCollectionID: string; }, ): RGBA | undefined { - const modeName = Object.values(collections) - .flatMap((collection) => collection.modes) - .find((mode) => mode.modeId === modeID)?.name; + const sourceCollection = collections[sourceCollectionID]; + if (!sourceCollection) { + throw new Error(`Missing Figma source collection: ${sourceCollectionID}`); + } + const modeName = sourceCollection.modes.find((mode) => mode.modeId === modeID)?.name; const active = new Set(); function resolve(input: unknown): unknown { @@ -62,7 +66,7 @@ export function resolveComposedColor( throw new Error(`Missing Figma collection for composed color alias: ${id}`); } const targetModeID = - modeID in variable.valuesByMode + variable.variableCollectionId === sourceCollectionID && modeID in variable.valuesByMode ? modeID : (collection.modes.find( (mode) => modeName !== undefined && formatName(mode.name) === formatName(modeName), diff --git a/packages/cli/src/import/figma/variables.ts b/packages/cli/src/import/figma/variables.ts index ff8f146b..21a6d990 100644 --- a/packages/cli/src/import/figma/variables.ts +++ b/packages/cli/src/import/figma/variables.ts @@ -134,6 +134,7 @@ export async function getVariables( const isAliasOfID = getAliasID(value); if (variable.resolvedType === 'COLOR' && isComposedColor(value)) { const color = resolveComposedColor(value, modeID, { + sourceCollectionID: variable.variableCollectionId, variables: allVariables, collections: variableCollections, remoteIDs, diff --git a/packages/cli/test/figma-composed-colors.test.ts b/packages/cli/test/figma-composed-colors.test.ts index 082c5c95..d0645a53 100644 --- a/packages/cli/test/figma-composed-colors.test.ts +++ b/packages/cli/test/figma-composed-colors.test.ts @@ -78,6 +78,64 @@ function fixtures(): Record { } describe('Figma composed colors', () => { + it.each([false, true])( + 'scopes reused IDs to the source collection with matching name=%s', + (matchingName) => { + const variables = fixtures(); + const source = { + ...collections.theme!, + id: 'source', + defaultModeId: 'shared', + modes: [{ modeId: 'shared', name: 'Light colorblind' }], + }; + const target = { + ...collections.other!, + id: 'target', + defaultModeId: 'fallback', + modes: [ + { modeId: 'shared', name: 'Light protanopia deuteranopia' }, + { modeId: 'fallback', name: 'Default' }, + ...(matchingName ? [{ modeId: 'matching', name: 'Light colorblind' }] : []), + ], + }; + variables.palette = variable('palette', 'COLOR', { + shared: { r: 1, g: 0, b: 0, a: 1 }, + matching: { r: 0, g: 1, b: 0, a: 1 }, + fallback: { r: 0, g: 0, b: 1, a: 1 }, + }); + variables.palette.variableCollectionId = 'target'; + variables.opacity = variable('opacity', 'FLOAT', { shared: 100, matching: 25, fallback: 50 }); + variables.opacity.variableCollectionId = 'target'; + const result = resolveComposedColor( + { color: alias('palette'), opacity: alias('opacity') }, + 'shared', + { + sourceCollectionID: 'source', + variables, + collections: { target, source }, + remoteIDs: new Set(), + }, + ); + expect(result).toEqual( + matchingName ? { r: 0, g: 1, b: 0, a: 0.25 } : { r: 0, g: 0, b: 1, a: 0.5 }, + ); + }, + ); + + it('reuses a mode ID for references within the source collection', () => { + const variables = fixtures(); + variables.opacity = variable('opacity', 'FLOAT', { light: 100, dark: 25 }); + variables.opacity.variableCollectionId = 'theme'; + expect( + resolveComposedColor({ color: alias('palette'), opacity: alias('opacity') }, 'dark', { + sourceCollectionID: 'theme', + variables, + collections, + remoteIDs: new Set(), + })?.a, + ).toBe(0.25); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); @@ -95,6 +153,7 @@ describe('Figma composed colors', () => { expect( resolveComposedColor({ color: alias('palette'), opacity }, 'light', { variables: fixtures(), + sourceCollectionID: 'theme', collections, remoteIDs: new Set(), }), @@ -105,6 +164,7 @@ describe('Figma composed colors', () => { expect( resolveComposedColor({ color: { r: 1, g: 0, b: 0 }, opacity: alias('opacity') }, 'light', { variables: fixtures(), + sourceCollectionID: 'theme', collections, remoteIDs: new Set(), }), @@ -115,6 +175,7 @@ describe('Figma composed colors', () => { expect( resolveComposedColor({ color: alias('semantic'), opacity: 25 }, 'dark', { variables: fixtures(), + sourceCollectionID: 'theme', collections, remoteIDs: new Set(), }), @@ -128,6 +189,7 @@ describe('Figma composed colors', () => { expect( resolveComposedColor({ color: alias('palette'), opacity: alias('opacity') }, 'dark', { variables, + sourceCollectionID: 'theme', collections, remoteIDs: new Set(), })?.a, @@ -139,6 +201,7 @@ describe('Figma composed colors', () => { expect( resolveComposedColor({ color: alias('missing'), opacity: 50 }, 'light', { variables: fixtures(), + sourceCollectionID: 'theme', collections, remoteIDs, }), @@ -152,6 +215,7 @@ describe('Figma composed colors', () => { expect(() => resolveComposedColor({ color: alias('palette'), opacity: 50 }, 'light', { variables, + sourceCollectionID: 'theme', collections, remoteIDs: new Set(), }), @@ -162,6 +226,7 @@ describe('Figma composed colors', () => { expect(() => resolveComposedColor({ color: alias('palette'), opacity }, 'light', { variables: fixtures(), + sourceCollectionID: 'theme', collections, remoteIDs: new Set(), }), @@ -172,6 +237,7 @@ describe('Figma composed colors', () => { expect(() => resolveComposedColor({ color: alias('opacity'), opacity: 50 }, 'light', { variables: fixtures(), + sourceCollectionID: 'theme', collections, remoteIDs: new Set(), }),