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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Atomic capabilities the creation workflows compose against — pull one when you
- `/media-use` — resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking; keeps search noise on disk.
- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering).
- `/hyperframes-registry` — install and wire registry blocks and components into compositions via `hyperframes add`. Covers authoring a new block or component to contribute upstream.
- `/figma` — import Figma assets, tokens, components, and Motion animations into a composition (MCP-first).
- `/figma` — import Figma assets, tokens, components, and storyboard sections → animatics (REST/CLI) plus Motion animations and shaders (MCP) into a composition.

## Skill catalog maintenance

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Atomic capabilities the creation workflows compose against — pull one when you
| `/media-use` | Resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking. |
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). |
| `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. |
| `/figma` | Import Figma assets, tokens, components, and Motion animations into a composition (MCP-first). |
| `/figma` | Import Figma assets, tokens, components, and storyboard sections → animatics (REST/CLI) plus Motion animations and shaders (MCP) into a composition. |

For visual design handoff workflows, see the [Claude Design guide](https://hyperframes.heygen.com/guides/claude-design) and [Open Design guide](https://hyperframes.heygen.com/guides/open-design).

Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"guides/video-components",
"guides/html-in-canvas",
"guides/website-to-video",
"guides/figma",
"guides/antigravity",
"guides/copilot-cli",
"guides/claude-design",
Expand Down
117 changes: 117 additions & 0 deletions docs/guides/figma.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
title: Figma Import
description: "Bring Figma designs into HyperFrames — frozen assets, brand tokens, editable components, storyboard animatics, and Figma Motion timelines translated to GSAP."
---

The work your designer already did in Figma — layout, color, type, motion — becomes the starting point of a composition instead of a thing to rebuild by hand. Point at a Figma URL; the artifact lands as a native HyperFrames piece: a frozen local file, a composition variable, editable HTML, or a paused GSAP timeline.

## What you can import

| 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` |
| **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) |
| **Storyboards** | A section of scene frames reconstructed as an animatic | `/figma` skill (agent) |

Two transports, split by what Figma exposes: assets, tokens, and components run over the **REST API** (headless, works in CI, generous per-minute rate limits). Motion and shaders exist only on Figma's **MCP server**, so an agent drives those. Every path freezes files locally — **renders never call Figma**.

## One-time setup

The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environment variable.

<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:

| 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** |

No Enterprise plan? Skip the Variables scope — `tokens` automatically falls back to your published styles. That's expected behavior, not an error.
</Step>
<Step title="Export it">
```bash
export FIGMA_TOKEN="figd_…"
```

Add the line to your shell profile or the project's `.env` so future sessions skip this step. The same token covers every Figma file your account can view.
</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.

## Import an asset

```bash
hyperframes figma asset 'https://www.figma.com/design/KEY/Title?node-id=1-2'
```

The node renders over REST, lands frozen under `.media/images/`, and the command prints a ready-to-paste `<img>` snippet:

```text
imported image_007 → .media/images/image_007.svg
<img src=".media/images/image_007.svg" alt="image_007" data-figma-id="1:2" />
```

- `--format svg|png|jpg|pdf` (default `svg`). SVG for logos and vectors — scalable and animatable. `--format png --scale 2` for raster fidelity.
- Accepted refs: a full Figma URL with `?node-id=…` (right-click a layer → Copy link) or `fileKey:nodeId` shorthand. Asset and component imports always target a specific node; only `tokens` takes a bare `fileKey`.
- Idempotent: the manifest records `fileKey:nodeId:format:scale:version`, so re-running reuses the file unless the design actually changed in Figma.

## Pull your brand

```bash
hyperframes figma tokens KEY
```

Reads the file's variables (or published styles), writes a `figma-tokens.json` sidecar plus a binding index, and prints entries for the composition's `data-composition-variables`. Every scene that references a brand role — instead of a hard-coded hex — is on-brand automatically, and stays on-brand when the file changes.

<Tip>
Import tokens **before** components. That's what lets an imported component's colors link to your brand variables instead of baking duplicate literals.
</Tip>

## Import a component

```bash
hyperframes figma component 'https://www.figma.com/design/KEY/Title?node-id=10-20'
```

The frame's node tree becomes editable HTML at exact Figma geometry, packaged under `compositions/components/<name>/`. Vector and boolean-op nodes that don't map to clean HTML auto-rasterize through the asset path.

Colors bound to a Figma variable resolve against your imported tokens:

- Bound to an **imported** token → emitted as `var(--brand-slug, #0066FF)` — a later brand refresh propagates into the component.
- Bound to a token you **haven't imported** → the literal color is used and the element is flagged `data-figma-unresolved`. The command tells you; run `tokens` on the source (or library) file and re-import to link them.

Matching is by exact Figma ID only — never by hex value — so a coincidentally-shared color can't create a false brand link.

## Motion, shaders, and storyboards

These run through the `/figma` agent skill:

- **Motion** — a Figma Motion timeline (keyframes, easing, repeats) translates structurally into a paused, finite GSAP timeline registered on `window.__timelines`, seekable frame-by-frame like any hand-authored animation, and editable afterward. Tracks that can't translate faithfully fall back to a baked video clip — the agent tells you which path it took and why.
- **Shaders** — Figma's export path doesn't execute shaders, so the default is a native Figma export (PNG or Motion MP4) imported as an asset/clip.
- **Storyboards** — a section of scene frames is decoded, not slideshowed: frames sharing an element are treated as that element's keyframes over time, diffed into element chains and tweened, with text under the strip read as director notes. See the `/figma` skill for the full grammar.

## Provenance and refresh

Every import records where it came from (`fileKey`, `nodeId`, `version`) in `.media/manifest.jsonl`. Nothing in a rendered composition points at Figma — assets are files, tokens are variables, motion is a timeline. When the Figma file moves on, re-running the same import commands re-pulls only what changed.

## Troubleshooting

| 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 |
| "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) |
2 changes: 1 addition & 1 deletion docs/guides/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Atomic capabilities the creation workflows compose against — pull one when you
| `/media-use` | Resolve any media need (BGM, SFX, image, icon) into a frozen local file + ledger record. One verb (`resolve`) over the HeyGen catalog with manifest tracking. |
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). |
| `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. |
| `/figma` | Import Figma assets, tokens, components, and Motion animations into a composition (MCP-first). |
| `/figma` | Import Figma assets, tokens, components, and storyboard sections → animatics (REST/CLI) plus Motion animations and shaders (MCP) into a composition. |

## Source of truth

Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/commands/figma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,20 @@ ${c.bold("hyperframes figma")} ${c.dim("<subcommand> [args]")}
Import figma content over the REST API. Requires ${c.accent("FIGMA_TOKEN")}.

${c.bold("SUBCOMMANDS:")}
${c.accent("asset")} ${c.dim("Render a node (png/svg/jpg/pdf), freeze under .media/, print a snippet.")}
${c.accent("tokens")} ${c.dim("Import variables/styles as composition brand variables.")}
${c.accent("asset")} ${c.dim("Render a node (png/svg/jpg/pdf), freeze under .media/, print a snippet.")}
${c.accent("tokens")} ${c.dim("Import variables/styles as composition brand variables.")}
${c.accent("component")} ${c.dim("Import a frame as an editable HTML component (brand-linked colors).")}

${c.bold("ENV VARS:")}
${c.accent("FIGMA_TOKEN")} Personal access token (figma.com/settings → security).
${c.bold("FIRST-TIME SETUP:")}
1. ${c.dim("Mint a token:")} figma.com/settings → Security → Personal access tokens
2. ${c.dim("Scopes (read-only only — this integration never writes to figma):")}
File content: Read-only · File metadata: Read-only
Variables: Read-only ${c.dim("(optional — Enterprise-only brand variables)")}
3. ${c.accent('export FIGMA_TOKEN="figd_…"')} ${c.dim("— persist in your shell profile or project .env")}

${c.bold("WHAT TO EXPECT:")}
${c.dim("Every import freezes files locally under .media/ and records figma provenance —")}
${c.dim("renders never touch figma. Re-running a command re-imports only what changed.")}

${c.dim("Motion and shader import are agent-only (figma exposes no REST endpoint for")}
${c.dim("either) — use the /figma skill in a Claude session for those.")}
Expand All @@ -41,6 +50,7 @@ export default defineCommand({
subCommands: {
asset: () => import("./figma/asset.js").then((m) => m.default),
tokens: () => import("./figma/tokens.js").then((m) => m.default),
component: () => import("./figma/component.js").then((m) => m.default),
},
async run({ args }) {
if (!args._?.[0]) console.log(HELP);
Expand Down
29 changes: 16 additions & 13 deletions packages/cli/src/commands/figma/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { existsSync } from "node:fs";
import { join, relative } from "node:path";
import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js";

export interface AssetImportOptions {
format: FigmaAssetFormat;
Expand Down Expand Up @@ -124,18 +125,20 @@ export default defineCommand({
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
const token = process.env.FIGMA_TOKEN ?? "";
const client = createFigmaClient({ token });
const result = await runAssetImport(
args.ref,
{
format: parseFormat(args.format),
scale: args.scale !== undefined ? Number(args.scale) : undefined,
},
{ projectDir: args.dir, client, download: downloadRender },
);
const verb = result.reused ? "reused" : "imported";
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
console.log(result.snippet.html);
await withFigmaErrors(async () => {
const token = process.env.FIGMA_TOKEN ?? "";
const client = createFigmaClient({ token });
const result = await runAssetImport(
args.ref,
{
format: parseFormat(args.format),
scale: args.scale !== undefined ? Number(args.scale) : undefined,
},
{ projectDir: args.dir, client, download: downloadRender },
);
const verb = result.reused ? "reused" : "imported";
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
console.log(result.snippet.html);
});
},
});
22 changes: 22 additions & 0 deletions packages/cli/src/commands/figma/cliError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Shared CLI error boundary for `hyperframes figma` subcommands: typed
* client errors (NO_TOKEN, BAD_TOKEN, …) and input errors (bad ref, bad
* format) all carry actionable, user-facing messages — present them via
* the CLI's standard errorBox, not a stack trace. Non-Error throws still
* surface raw.
*/

import { errorBox } from "../../ui/format.js";

export async function withFigmaErrors(fn: () => Promise<void>): Promise<void> {
try {
await fn();
} catch (err) {
if (err instanceof Error) {
const [title = "figma command failed", ...rest] = err.message.split("\n");
errorBox(title, rest.length > 0 ? rest.join("\n") : undefined);
process.exit(1);
}
throw err;
}
}
36 changes: 22 additions & 14 deletions packages/cli/src/commands/figma/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function escapeAttr(value: string): string {
}
import { runAssetImport } from "./asset.js";
import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js";

export interface ComponentImportDeps {
projectDir: string;
Expand Down Expand Up @@ -72,7 +73,12 @@ export async function runComponentImport(
{ format: "svg" },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path));
// src is a URL — always forward slashes, even when relative() yields
// windows separators.
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
"\\",
"/",
);
const emittedId = escapeAttr(req.nodeId);
html = html.replaceAll(
`data-figma-rasterize="${emittedId}" `,
Expand Down Expand Up @@ -120,19 +126,21 @@ export default defineCommand({
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runComponentImport(args.ref, {
projectDir: args.dir,
client,
download: downloadRender,
await withFigmaErrors(async () => {
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
const result = await runComponentImport(args.ref, {
projectDir: args.dir,
client,
download: downloadRender,
});
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0)
console.log(`rasterized ${result.rasterized.length} node(s) via asset export`);
if (result.unresolved.length > 0) {
console.log(
`${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`,
);
}
});
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0)
console.log(`rasterized ${result.rasterized.length} node(s) via asset export`);
if (result.unresolved.length > 0) {
console.log(
`${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`,
);
}
},
});
27 changes: 15 additions & 12 deletions packages/cli/src/commands/figma/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "@hyperframes/core/figma";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { withFigmaErrors } from "./cliError.js";

export interface TokensImportDeps {
projectDir: string;
Expand Down Expand Up @@ -77,17 +78,19 @@ export default defineCommand({
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));
}
await withFigmaErrors(async () => {
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 (style values resolve at component-import time)",
);
}
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));
}
});
},
});
Loading
Loading