Skip to content
Merged
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
44 changes: 29 additions & 15 deletions docs/guides/figma.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The work your designer already did in Figma — layout, color, type, motion —
| Capability | What you get | Surface |
| --- | --- | --- |
| **Static assets** | A frame/layer rendered to SVG/PNG/JPG/PDF, frozen under `.media/` | `hyperframes figma asset` |
| **Brand tokens** | Figma variables/styles as composition brand variables | `hyperframes figma tokens` |
| **Brand tokens** | Figma variables/styles as composition brand variables | `hyperframes figma tokens` (Enterprise) or the `/figma` skill via MCP (any plan) |
| **Components** | A frame as editable HTML with brand-linked colors | `hyperframes figma component` |
| **Motion** | A Figma Motion timeline as an editable, paused GSAP timeline | `/figma` skill (agent, MCP) |
| **Shaders** | A shader fill/effect as a frozen still or clip | `/figma` skill (agent, MCP) |
Expand All @@ -20,22 +20,32 @@ Two transports, split by what Figma exposes: assets, tokens, and components run

## One-time setup

The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environment variable.
There are two credentials, and most people only need to set up one to start:

| You want to import… | Set up |
| --- | --- |
| A logo, image, or a whole frame as HTML (assets, components) | A **token** — Step A below |
| Brand colors (tokens) | Either works, but on a non-Enterprise plan the **MCP connector** (Step B) gets you there in one click — the token path needs an Enterprise plan for this specific pull |
| Motion, shaders, or a storyboard | The **MCP connector** only — no token, no setup beyond connecting it |

Do both if your project needs everything; each is independent, so it doesn't matter which you set up first.

### Step A — Figma token (assets, tokens, components)

Needed for anything you run from the `hyperframes figma` CLI.

<Steps>
<Step title="Mint a token">
In Figma: **Settings → Security → Personal access tokens → Generate new token.**
</Step>
<Step title="Pick read-only scopes">
The integration never writes to Figma — read-only is all it ever needs:
<Step title="Check these scopes">
Read-only is all it ever needs — the integration never writes to Figma. **On most accounts (not Figma Enterprise), check exactly these three:**

| Scope | Setting | Needed for |
| --- | --- | --- |
| File content | Read-only | assets, components |
| File metadata | Read-only | version tracking, refresh |
| Variables | Read-only | brand variables — **Figma Enterprise only** |
- **File content** — Read-only
- **File metadata** — Read-only
- **Library content** — Read-only — easy to miss, and without it `tokens` 403s the moment it tries the published-styles fallback

No Enterprise plan? Skip the Variables scope — `tokens` automatically falls back to your published styles. That's expected behavior, not an error.
On a **Figma Enterprise** plan, also check **Variables — Read-only** to pull brand colors directly via `tokens`. Not on Enterprise? Skip it — `tokens` falls back to published styles automatically, or use the MCP connector (Step B) instead, which reaches variables on any plan.
</Step>
<Step title="Export it">
```bash
Expand All @@ -46,7 +56,11 @@ The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environmen
</Step>
</Steps>

Motion and shader import use the **Figma MCP connector** instead — a one-click OAuth from your agent, separate from the token. Connect it when your agent asks; no scopes to configure.
### Step B — Figma MCP connector (motion, shaders, storyboards — and an easier token-free path to brand colors)

No token, no scopes to pick — connect it once when your agent asks (a one-click OAuth) and it stays connected.

This is also the easiest way to pull brand colors on **any** Figma plan, including free: the connector's variable-reading tool isn't Enterprise-gated, only rate-limited by plan — Starter/free caps at **6 calls/month**, a paid Full/Dev seat gets 200–600/day. Fine for an occasional brand pull; not for iterating call-by-call.

## Import an asset

Expand Down Expand Up @@ -109,9 +123,9 @@ Every import records where it came from (`fileKey`, `nodeId`, `version`) in `.me
| Error | Meaning | Fix |
| --- | --- | --- |
| `NO_TOKEN` | `FIGMA_TOKEN` unset | Follow [One-time setup](#one-time-setup) |
| `BAD_TOKEN` (401) | Token expired or revoked | Re-mint the token |
| `FORBIDDEN` (403) | Token missing a read scope, or no access to the file | Check the read-only scopes above and file visibility |
| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — the styles fallback already ran |
| `RATE_LIMITED` (429) | REST per-minute budget hit | Wait a minute and retry; chunk batch renders |
| `BAD_TOKEN` | Token invalid, expired, or revoked (Figma returns **403 `Invalid token`** for bad PATs, not 401) | Re-mint the token |
| `FORBIDDEN` (403) | Missing a read scope, or no access to the file | The message names the exact scope Figma wants (e.g. `library_content:read` for the styles fallback) — add it, or check file visibility |
| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — `tokens` falls back to published styles (which needs the Library content scope above) |
| `RATE_LIMITED` (429) | Figma's per-minute limit | The client retries with backoff automatically (honoring `Retry-After`); if it still surfaces, wait a minute or batch fewer nodes |
| "Render timeout" on batch export | Too many large frames in one `/v1/images` call | Chunk to ~4 ids per call |
| `ref has no node id` | Link points at a file, not a node | Copy the link with `?node-id=…` (right-click layer → Copy link) |
68 changes: 66 additions & 2 deletions packages/cli/src/commands/figma/asset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { describe, expect, it, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runAssetImport, type AssetImportDeps } from "./asset.js";
import {
gatherAssetRefs,
runAssetImport,
runAssetImportMany,
type AssetImportDeps,
} from "./asset.js";
import type { FigmaClient } from "@hyperframes/core/figma";

const dirs: string[] = [];
Expand All @@ -17,15 +22,29 @@ afterEach(() => {
});

function fakeClient(overrides: Partial<FigmaClient> = {}): FigmaClient {
return {
const client: FigmaClient = {
renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }),
renderNodes: () => Promise.resolve([]),
imageFills: () => Promise.resolve(new Map()),
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
styles: () => Promise.resolve([]),
nodeTree: () => Promise.resolve({ id: "1:2", name: "n", type: "FRAME" }),
fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }),
...overrides,
};
// Default renderNodes delegates to renderNode (honoring any override) so
// existing single-node tests keep controlling behavior via renderNode.
if (!overrides.renderNodes) {
client.renderNodes = (fileKey, nodeIds, opts) =>
Promise.all(
nodeIds.map((nodeId) =>
client
.renderNode({ fileKey, nodeId }, opts)
.then((r) => ({ nodeId, url: r.url, ext: r.ext })),
),
);
}
return client;
}

const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
Expand Down Expand Up @@ -134,6 +153,51 @@ describe("runAssetImport", () => {
expect(index).not.toContain("image_002");
});

it("batches many nodes into ONE renderNodes call and freezes each", async () => {
const dir = scratch();
let renderNodesCalls = 0;
let batchSize = 0;
const batchClient = fakeClient({
renderNodes: (fileKey, nodeIds, opts) => {
renderNodesCalls += 1;
batchSize = nodeIds.length;
return Promise.resolve(
nodeIds.map((nodeId) => ({ nodeId, url: `https://cdn/${nodeId}`, ext: opts.format })),
);
},
});
const results = await runAssetImportMany(
["KEY:1-2", "KEY:3-4", "KEY:5-6"],
{ format: "png" },
deps(dir, { client: batchClient }),
);
expect(results).toHaveLength(3);
expect(results.every((r) => !r.reused)).toBe(true);
expect(renderNodesCalls).toBe(1); // one REST call for all three
expect(batchSize).toBe(3);
// distinct frozen files, all recorded
expect(new Set(results.map((r) => r.record.id)).size).toBe(3);
});

it("gatherAssetRefs splits bare comma-joined ids but keeps URLs whole", () => {
// bare tokens comma-split
expect(gatherAssetRefs(["KEY:1-2,KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
// space-separated positionals preserved
expect(gatherAssetRefs(["KEY:1-2", "KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
// a URL with a comma in its query is NOT torn apart
const url = "https://www.figma.com/design/KEY/F?node-id=1:2,3:4";
expect(gatherAssetRefs([url])).toEqual([url]);
// mixed: URL stays whole, bare token splits
expect(gatherAssetRefs([url, "KEY:5-6,KEY:7-8"])).toEqual([url, "KEY:5-6", "KEY:7-8"]);
});

it("splits comma-joined refs and rejects a cross-file batch", async () => {
const dir = scratch();
await expect(
runAssetImportMany(["KEY:1-2", "OTHER:3-4"], { format: "png" }, deps(dir)),
).rejects.toThrow(/share a fileKey/);
});

it("reuses against ANY matching tuple, not just the oldest row", async () => {
const dir = scratch();
await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg)
Expand Down
Loading
Loading