Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions packages/cli/src/commands/figma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/commands/figma/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
93 changes: 93 additions & 0 deletions packages/cli/src/commands/figma/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* `hyperframes figma tokens <fileKey>` — 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<TokensImportResult> {
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));
}
},
});
17 changes: 17 additions & 0 deletions packages/core/src/figma/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
175 changes: 175 additions & 0 deletions packages/core/src/figma/tokensToVariables.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading