feat(core,cli): figma tokens import with alias-aware binding records (M2) (#1871)

tokensToVariables: variables -> composition brand-variable entries
(COLOR->hex/rgba, FLOAT/STRING/BOOLEAN), alias chains walked cycle-safe
to the leaf value while the binding keeps the semantic id. Sidecar
figma-tokens.json + .media/figma-bindings.jsonl records per spec 7.1.

hyperframes figma tokens: variables path, REQUIRES_ENTERPRISE degrades
to published-styles metadata (values resolve at component time).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 18:16:48 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 22942280b6
commit 4d60792adc
6 changed files with 547 additions and 0 deletions
+17
View File
@@ -33,6 +33,23 @@ export {
} from "./manifest";
export { buildAssetSnippet } from "./assetSnippet";
export { sanitizeSvg } from "./sanitizeSvg";
export {
appendBinding,
upsertBindings,
findBindingByFigmaId,
readBindings,
readLibraryMap,
recordLibraryFile,
} from "./bindings";
export type { FigmaBindingRecord } from "./bindings";
export { tokensToVariables } from "./tokensToVariables";
export type {
CompositionVariableEntry,
FigmaTokenSidecarEntry,
FigmaTokensSidecar,
TokenSource,
TokensToVariablesResult,
} from "./tokensToVariables";
export { mapEase } from "./motionEase";
export { motionToGsap } from "./motionToGsap";
export { emitTimelineScript } from "./emitTimelineScript";
@@ -0,0 +1,175 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { tokensToVariables } from "./tokensToVariables";
import type { FigmaVariablesResult } from "./client";
const VARS: FigmaVariablesResult = {
variables: {
"VariableID:1:1": {
name: "Blue/500",
key: "kblue",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0.4, b: 1, a: 1 } },
variableCollectionId: "c1",
},
"VariableID:1:2": {
name: "button/bg",
key: "kbtn",
resolvedType: "COLOR",
valuesByMode: { m1: { type: "VARIABLE_ALIAS", id: "VariableID:1:1" } },
variableCollectionId: "c1",
},
"VariableID:1:3": {
name: "radius/md",
key: "krad",
resolvedType: "FLOAT",
valuesByMode: { m1: 8 },
variableCollectionId: "c1",
},
},
variableCollections: {
c1: { defaultModeId: "m1", modes: [{ modeId: "m1", name: "Light" }] },
},
};
const SOURCE = { fileKey: "FILE", version: "7" };
describe("tokensToVariables", () => {
it("maps color variables to composition color entries with hex defaults", () => {
const out = tokensToVariables(VARS, SOURCE);
const blue = out.entries.find((e) => e.id === "figma:Blue/500");
expect(blue).toMatchObject({ type: "color", label: "Blue/500", default: "#0066FF" });
});
it("resolves alias chains to the leaf value and records the chain on the binding", () => {
const out = tokensToVariables(VARS, SOURCE);
const btn = out.entries.find((e) => e.id === "figma:button/bg");
expect(btn?.default).toBe("#0066FF");
const binding = out.bindings.find((b) => b.figmaId === "VariableID:1:2");
expect(binding?.aliasChain).toEqual(["VariableID:1:2", "VariableID:1:1"]);
expect(binding?.compositionVariableId).toBe("figma:button/bg");
});
it("maps FLOAT to number entries and stamps provenance on every binding", () => {
const out = tokensToVariables(VARS, SOURCE);
const rad = out.entries.find((e) => e.id === "figma:radius/md");
expect(rad).toMatchObject({ type: "number", default: 8 });
for (const b of out.bindings) {
expect(b.sourceFileKey).toBe("FILE");
expect(b.version).toBe("7");
expect(b.kind).toBe("binding");
}
});
it("emits an alpha color as rgba()", () => {
const out = tokensToVariables(
{
variables: {
"VariableID:2:1": {
name: "overlay",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0, b: 0, a: 0.5 } },
},
},
variableCollections: {},
},
SOURCE,
);
expect(out.entries[0]?.default).toBe("rgba(0, 0, 0, 0.5)");
});
it("survives alias cycles without hanging and skips the unresolvable variable", () => {
const out = tokensToVariables(
{
variables: {
"VariableID:3:1": {
name: "a",
resolvedType: "COLOR",
valuesByMode: { m1: { type: "VARIABLE_ALIAS", id: "VariableID:3:2" } },
},
"VariableID:3:2": {
name: "b",
resolvedType: "COLOR",
valuesByMode: { m1: { type: "VARIABLE_ALIAS", id: "VariableID:3:1" } },
},
},
variableCollections: {},
},
SOURCE,
);
expect(out.entries).toEqual([]);
});
it("writes a sidecar with every token including modes", () => {
const out = tokensToVariables(VARS, SOURCE);
expect(out.sidecar.source).toEqual(SOURCE);
const blue = out.sidecar.tokens.find((t) => t.name === "Blue/500");
expect(blue).toMatchObject({ figmaId: "VariableID:1:1", key: "kblue", type: "color" });
});
});
describe("tokensToVariables collection semantics", () => {
const TWO_COLLECTIONS: FigmaVariablesResult = {
variables: {
"VariableID:9:1": {
name: "Blue/500",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0, b: 1, a: 1 } },
variableCollectionId: "sem",
},
"VariableID:9:2": {
name: "Blue/500",
resolvedType: "COLOR",
valuesByMode: { m1: { r: 0, g: 0.5, b: 1, a: 1 } },
variableCollectionId: "prim",
},
},
variableCollections: {
sem: { name: "Semantic", defaultModeId: "m1" },
prim: { name: "Primitive", defaultModeId: "m1" },
},
};
it("namespaces composition ids by collection so same-name variables never collide", () => {
const out = tokensToVariables(TWO_COLLECTIONS, { fileKey: "F", version: "1" });
const ids = out.entries.map((e) => e.id);
expect(new Set(ids).size).toBe(2);
expect(ids).toContain("figma:Semantic/Blue/500");
expect(ids).toContain("figma:Primitive/Blue/500");
});
it("prefers the collection defaultModeId over insertion order", () => {
const multiMode: FigmaVariablesResult = {
variables: {
"VariableID:9:3": {
name: "Ink",
resolvedType: "COLOR",
valuesByMode: {
dark: { r: 1, g: 1, b: 1, a: 1 },
light: { r: 0, g: 0, b: 0, a: 1 },
},
variableCollectionId: "c",
},
},
variableCollections: { c: { name: "Modes", defaultModeId: "light" } },
};
const out = tokensToVariables(multiMode, { fileKey: "F", version: "1" });
expect(out.entries[0]?.default).toBe("#000000");
});
it("rejects type-mismatched values (stringified FLOAT) instead of passing them through", () => {
const bad: FigmaVariablesResult = {
variables: {
"VariableID:9:4": {
name: "Gap",
resolvedType: "FLOAT",
valuesByMode: { m1: "8" },
variableCollectionId: "c",
},
},
variableCollections: { c: { name: "N", defaultModeId: "m1" } },
};
const out = tokensToVariables(bad, { fileKey: "F", version: "1" });
expect(out.entries).toHaveLength(0);
});
});
@@ -0,0 +1,187 @@
/**
* Phase 2 translator: figma variables → composition brand-variable entries,
* a human-readable sidecar, and binding-index records (design spec §6, §7.1).
*
* Pure — REST payload in, artifacts out. Alias chains are walked to the leaf
* value (cycle-safe) but the binding records the semantic id the designer
* bound, so a swapped primitive doesn't orphan the link.
*/
import type { FigmaVariablePayload, FigmaVariablesResult } from "./client";
import type { FigmaBindingRecord } from "./bindings";
/** data-composition-variables entry (runtime getVariables contract). */
export interface CompositionVariableEntry {
id: string;
type: "string" | "number" | "color" | "boolean";
label: string;
default: string | number | boolean;
brandRole?: string;
}
export interface FigmaTokenSidecarEntry {
name: string;
type: string;
figmaId: string;
key?: string;
value: string | number | boolean | null;
}
export interface FigmaTokensSidecar {
source: TokenSource;
tokens: FigmaTokenSidecarEntry[];
}
export interface TokenSource {
fileKey: string;
version: string;
}
export interface TokensToVariablesResult {
entries: CompositionVariableEntry[];
bindings: FigmaBindingRecord[];
sidecar: FigmaTokensSidecar;
}
const TYPE_MAP: Record<string, CompositionVariableEntry["type"]> = {
COLOR: "color",
FLOAT: "number",
STRING: "string",
BOOLEAN: "boolean",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function toHexByte(channel: number): string {
return Math.round(channel * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
}
function colorToCss(value: Record<string, unknown>): string | null {
const { r, g, b, a } = value;
if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") return null;
const alpha = typeof a === "number" ? a : 1;
if (alpha >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
const c = (n: number) => Math.round(n * 255);
return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${alpha})`;
}
/**
* The collection's defaultModeId is the authority for "which mode is the
* base value" (Light vs Dark). Falls back to first-inserted mode when the
* collection or its default mode isn't in the payload.
*/
function baseModeValue(
payload: FigmaVariablePayload,
collections: Record<string, unknown>,
): unknown {
const modes = payload.valuesByMode ?? {};
const collection = collections[payload.variableCollectionId ?? ""];
if (isRecord(collection) && typeof collection.defaultModeId === "string") {
const preferred = modes[collection.defaultModeId];
if (preferred !== undefined) return preferred;
}
for (const value of Object.values(modes)) return value;
return undefined;
}
function collectionName(
payload: FigmaVariablePayload,
collections: Record<string, unknown>,
): string | null {
const collection = collections[payload.variableCollectionId ?? ""];
return isRecord(collection) && typeof collection.name === "string" ? collection.name : null;
}
/** Follow VARIABLE_ALIAS links to a leaf value; null on cycle/missing. */
function resolveValue(
start: string,
vars: FigmaVariablesResult,
): { value: unknown; chain: string[] } | null {
const chain: string[] = [];
const seen = new Set<string>();
let currentId = start;
while (!seen.has(currentId)) {
chain.push(currentId);
seen.add(currentId);
const payload = vars.variables[currentId];
if (!payload) return null;
const value = baseModeValue(payload, vars.variableCollections);
if (isRecord(value) && value.type === "VARIABLE_ALIAS" && typeof value.id === "string") {
currentId = value.id;
continue;
}
return { value, chain };
}
return null; // cycle
}
/** Value must match the declared resolvedType — a stringified "8" for a
* FLOAT would silently break the CompositionVariableEntry contract. */
function toEntryValue(
resolvedType: string | undefined,
raw: unknown,
): string | number | boolean | null {
if (resolvedType === "COLOR") return isRecord(raw) ? colorToCss(raw) : null;
if (resolvedType === "FLOAT") return typeof raw === "number" ? raw : null;
if (resolvedType === "BOOLEAN") return typeof raw === "boolean" ? raw : null;
if (resolvedType === "STRING") return typeof raw === "string" ? raw : null;
return null;
}
export function tokensToVariables(
vars: FigmaVariablesResult,
source: TokenSource,
): TokensToVariablesResult {
const entries: CompositionVariableEntry[] = [];
const bindings: FigmaBindingRecord[] = [];
const sidecarTokens: FigmaTokenSidecarEntry[] = [];
for (const [figmaId, payload] of Object.entries(vars.variables)) {
const entryType = TYPE_MAP[payload.resolvedType ?? ""];
const resolved = resolveValue(figmaId, vars);
const value = resolved ? toEntryValue(payload.resolvedType, resolved.value) : null;
// Namespace by collection: figma allows the same variable name in
// different collections (Semantic Blue/500 vs Primitive Blue/500), and a
// collision here would silently merge two distinct bindings.
const collection = collectionName(payload, vars.variableCollections);
const compositionVariableId = collection
? `figma:${collection}/${payload.name}`
: `figma:${payload.name}`;
// Sidecar keeps EVERY variable — including unresolvable ones (value:
// null) — for designer visibility into what didn't map; entries/bindings
// below only get the mappable subset.
sidecarTokens.push({
name: payload.name,
type: entryType ?? payload.resolvedType ?? "unknown",
figmaId,
key: payload.key,
value,
});
if (!entryType || value === null || !resolved) continue;
entries.push({
id: compositionVariableId,
type: entryType,
label: payload.name,
default: value,
});
bindings.push({
kind: "binding",
figmaId,
key: payload.key,
sourceFileKey: source.fileKey,
aliasChain: resolved.chain.length > 1 ? resolved.chain : undefined,
compositionVariableId,
version: source.version,
});
}
return { entries, bindings, sidecar: { source, tokens: sidecarTokens } };
}