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..9e560458 --- /dev/null +++ b/packages/cli/src/import/figma/composed-color.ts @@ -0,0 +1,118 @@ +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, + sourceCollectionID, + }: { + variables: Record; + collections: Record; + remoteIDs: Set; + sourceCollectionID: string; + }, +): RGBA | undefined { + 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 { + 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 = + variable.variableCollectionId === sourceCollectionID && 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..21a6d990 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,23 @@ 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, { + sourceCollectionID: variable.variableCollectionId, + 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..d0645a53 --- /dev/null +++ b/packages/cli/test/figma-composed-colors.test.ts @@ -0,0 +1,282 @@ +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', () => { + 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(); + }); + + 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(), + sourceCollectionID: 'theme', + 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(), + sourceCollectionID: 'theme', + 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(), + sourceCollectionID: 'theme', + 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, + sourceCollectionID: 'theme', + 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(), + sourceCollectionID: 'theme', + 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, + sourceCollectionID: 'theme', + 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(), + sourceCollectionID: 'theme', + 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(), + sourceCollectionID: 'theme', + 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'); + }, + ); +});