mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Claude Fable 5
parent
22942280b6
commit
4d60792adc
@@ -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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user