Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/figma-composed-colors.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions packages/cli/src/import/figma/composed-color.ts
Original file line number Diff line number Diff line change
@@ -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<string, LocalVariable>;
collections: Record<string, LocalVariableCollection>;
remoteIDs: Set<string>;
},
): RGBA | undefined {
const modeName = Object.values(collections)
.flatMap((collection) => collection.modes)
.find((mode) => mode.modeId === modeID)?.name;
const active = new Set<string>();

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);
Comment thread
Copilot marked this conversation as resolved.
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<string, unknown>)[channel] === 'number' &&
Number.isFinite((color as Record<string, number>)[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;
}
48 changes: 28 additions & 20 deletions packages/cli/src/import/figma/variables.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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 ||
Expand Down
Loading
Loading