From d5503b2051c939777ad30e86c89da39a7e34eae3 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 2 Jul 2026 02:31:27 -0700 Subject: [PATCH] feat(core,cli): figma tokens import with alias-aware binding records (M2) 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 --- packages/cli/src/commands/figma.ts | 1 + .../cli/src/commands/figma/tokens.test.ts | 74 +++++++ packages/cli/src/commands/figma/tokens.ts | 93 +++++++++ packages/core/src/figma/index.ts | 17 ++ .../core/src/figma/tokensToVariables.test.ts | 175 ++++++++++++++++ packages/core/src/figma/tokensToVariables.ts | 187 ++++++++++++++++++ 6 files changed, 547 insertions(+) create mode 100644 packages/cli/src/commands/figma/tokens.test.ts create mode 100644 packages/cli/src/commands/figma/tokens.ts create mode 100644 packages/core/src/figma/tokensToVariables.test.ts create mode 100644 packages/core/src/figma/tokensToVariables.ts diff --git a/packages/cli/src/commands/figma.ts b/packages/cli/src/commands/figma.ts index 7152c2d8a..7ec1e69ca 100644 --- a/packages/cli/src/commands/figma.ts +++ b/packages/cli/src/commands/figma.ts @@ -40,6 +40,7 @@ export default defineCommand({ meta: { name: "figma", description: "Import figma assets, tokens, and components (REST)" }, subCommands: { asset: () => import("./figma/asset.js").then((m) => m.default), + tokens: () => import("./figma/tokens.js").then((m) => m.default), }, async run({ args }) { if (!args._?.[0]) console.log(HELP); diff --git a/packages/cli/src/commands/figma/tokens.test.ts b/packages/cli/src/commands/figma/tokens.test.ts new file mode 100644 index 000000000..760d4d89c --- /dev/null +++ b/packages/cli/src/commands/figma/tokens.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment node +import { describe, expect, it, afterEach, beforeEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runTokensImport } from "./tokens.js"; +import { FigmaClientError, type FigmaClient } from "@hyperframes/core/figma"; + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-figma-tokens-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +function client(overrides: Partial): FigmaClient { + return { + renderNode: () => Promise.reject(new Error("unused")), + imageFills: () => Promise.resolve(new Map()), + variables: () => + Promise.resolve({ + variables: { + "VariableID:1:1": { + name: "Blue/500", + key: "kblue", + resolvedType: "COLOR", + valuesByMode: { m1: { r: 0, g: 0.4, b: 1, a: 1 } }, + }, + }, + variableCollections: {}, + }), + styles: () => Promise.resolve([{ key: "s1", name: "Primary", style_type: "FILL" }]), + nodeTree: () => Promise.reject(new Error("unused")), + fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }), + ...overrides, + }; +} + +describe("runTokensImport", () => { + it("imports variables: entries + sidecar + binding index", async () => { + const out = await runTokensImport("FILE", { projectDir: dir, client: client({}) }); + expect(out.entries).toHaveLength(1); + expect(out.mode).toBe("variables"); + const sidecar = JSON.parse(readFileSync(join(dir, "figma-tokens.json"), "utf8")) as { + tokens: unknown[]; + }; + expect(sidecar.tokens).toHaveLength(1); + const bindings = readFileSync(join(dir, ".media", "figma-bindings.jsonl"), "utf8"); + expect(bindings).toContain('"figmaId":"VariableID:1:1"'); + }); + + it("falls back to styles metadata when variables are enterprise-gated", async () => { + const gated = client({ + variables: () => + Promise.reject(new FigmaClientError("REQUIRES_ENTERPRISE", "enterprise only", 403)), + }); + const out = await runTokensImport("FILE", { projectDir: dir, client: gated }); + expect(out.mode).toBe("styles"); + expect(out.entries).toEqual([]); + const sidecar = JSON.parse(readFileSync(join(dir, "figma-tokens.json"), "utf8")) as { + tokens: Array<{ name: string; type: string }>; + }; + expect(sidecar.tokens[0]).toMatchObject({ name: "Primary", type: "style:FILL" }); + }); + + it("propagates non-enterprise failures", async () => { + const broken = client({ + variables: () => Promise.reject(new FigmaClientError("RATE_LIMITED", "429", 429)), + }); + await expect(runTokensImport("FILE", { projectDir: dir, client: broken })).rejects.toThrow( + /429/, + ); + expect(existsSync(join(dir, "figma-tokens.json"))).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts new file mode 100644 index 000000000..cad4c0343 --- /dev/null +++ b/packages/cli/src/commands/figma/tokens.ts @@ -0,0 +1,93 @@ +/** + * `hyperframes figma tokens ` — Phase 2: import figma variables as + * composition brand-variable entries + figma-tokens.json sidecar + binding + * index records. Variables are Enterprise-gated upstream; degrades to a + * styles-metadata listing on REQUIRES_ENTERPRISE (style *values* resolve at + * component-import time, Phase 3). + */ + +import { defineCommand } from "citty"; +import { + createFigmaClient, + FigmaClientError, + parseFigmaRef, + tokensToVariables, + upsertBindings, + type CompositionVariableEntry, + type FigmaClient, + type FigmaTokensSidecar, +} from "@hyperframes/core/figma"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface TokensImportDeps { + projectDir: string; + client: FigmaClient; +} + +export interface TokensImportResult { + mode: "variables" | "styles"; + entries: CompositionVariableEntry[]; + sidecarPath: string; +} + +export async function runTokensImport( + refInput: string, + deps: TokensImportDeps, +): Promise { + const { fileKey } = parseFigmaRef(refInput); + const { version } = await deps.client.fileVersion(fileKey); + const sidecarPath = join(deps.projectDir, "figma-tokens.json"); + + // Only the variables() call may trigger the styles fallback — a translator + // or write failure must propagate, not silently rerun as a styles import. + let vars = null; + try { + vars = await deps.client.variables(fileKey); + } catch (err) { + if (!(err instanceof FigmaClientError) || err.code !== "REQUIRES_ENTERPRISE") throw err; + } + if (vars !== null) { + const out = tokensToVariables(vars, { fileKey, version }); + upsertBindings(deps.projectDir, out.bindings); + writeFileSync(sidecarPath, JSON.stringify(out.sidecar, null, 2) + "\n"); + return { mode: "variables", entries: out.entries, sidecarPath }; + } + + // Styles fallback: metadata only — values resolve at component-import time. + const styles = await deps.client.styles(fileKey); + const sidecar: FigmaTokensSidecar = { + source: { fileKey, version }, + tokens: styles.map((s) => ({ + name: s.name, + type: `style:${s.style_type}`, + figmaId: s.node_id ?? s.key, + key: s.key, + value: null, + })), + }; + writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + "\n"); + return { mode: "styles", entries: [], sidecarPath }; +} + +export default defineCommand({ + meta: { name: "tokens", description: "Import figma variables/styles as brand tokens" }, + args: { + ref: { type: "positional", description: "figma fileKey or URL", required: true }, + dir: { type: "string", description: "project directory", default: "." }, + }, + async run({ args }) { + const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" }); + const result = await runTokensImport(args.ref, { projectDir: args.dir, client }); + if (result.mode === "styles") { + console.log( + "variables are Enterprise-gated on this plan — recorded published style metadata instead", + ); + } + console.log(`wrote ${result.sidecarPath} (${result.mode})`); + if (result.entries.length > 0) { + console.log("add to data-composition-variables:"); + console.log(JSON.stringify(result.entries, null, 2)); + } + }, +}); diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts index 2eaf7e394..0c90a3245 100644 --- a/packages/core/src/figma/index.ts +++ b/packages/core/src/figma/index.ts @@ -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"; diff --git a/packages/core/src/figma/tokensToVariables.test.ts b/packages/core/src/figma/tokensToVariables.test.ts new file mode 100644 index 000000000..1bff70ad8 --- /dev/null +++ b/packages/core/src/figma/tokensToVariables.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/figma/tokensToVariables.ts b/packages/core/src/figma/tokensToVariables.ts new file mode 100644 index 000000000..13f88a58d --- /dev/null +++ b/packages/core/src/figma/tokensToVariables.ts @@ -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 = { + COLOR: "color", + FLOAT: "number", + STRING: "string", + BOOLEAN: "boolean", +}; + +function isRecord(value: unknown): value is Record { + 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 | 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, +): 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 | 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(); + 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 } }; +}