diff --git a/CLAUDE.md b/CLAUDE.md
index 74f78933d..9816b6b90 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index b9061da56..be7156432 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/docs/docs.json b/docs/docs.json
index 9e6829eb4..4b67d959a 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -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",
diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx
new file mode 100644
index 000000000..62d1f5132
--- /dev/null
+++ b/docs/guides/figma.mdx
@@ -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.
+
+
+
+ In Figma: **Settings → Security → Personal access tokens → Generate new token.**
+
+
+ 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.
+
+
+ ```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.
+
+
+
+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 `
` snippet:
+
+```text
+imported image_007 → .media/images/image_007.svg
+
+```
+
+- `--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.
+
+
+Import tokens **before** components. That's what lets an imported component's colors link to your brand variables instead of baking duplicate literals.
+
+
+## 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//`. 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) |
diff --git a/docs/guides/skills.mdx b/docs/guides/skills.mdx
index 57c8ea8c3..b04d090bd 100644
--- a/docs/guides/skills.mdx
+++ b/docs/guides/skills.mdx
@@ -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
diff --git a/packages/cli/src/commands/figma.ts b/packages/cli/src/commands/figma.ts
index 7ec1e69ca..e7f3da0ea 100644
--- a/packages/cli/src/commands/figma.ts
+++ b/packages/cli/src/commands/figma.ts
@@ -26,11 +26,20 @@ ${c.bold("hyperframes figma")} ${c.dim(" [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.")}
@@ -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);
diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts
index 1e9517670..450868249 100644
--- a/packages/cli/src/commands/figma/asset.ts
+++ b/packages/cli/src/commands/figma/asset.ts
@@ -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;
@@ -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);
+ });
},
});
diff --git a/packages/cli/src/commands/figma/cliError.ts b/packages/cli/src/commands/figma/cliError.ts
new file mode 100644
index 000000000..610181bd3
--- /dev/null
+++ b/packages/cli/src/commands/figma/cliError.ts
@@ -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): Promise {
+ 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;
+ }
+}
diff --git a/packages/cli/src/commands/figma/component.ts b/packages/cli/src/commands/figma/component.ts
index beb1ec091..5c6f6bf4e 100644
--- a/packages/cli/src/commands/figma/component.ts
+++ b/packages/cli/src/commands/figma/component.ts
@@ -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;
@@ -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}" `,
@@ -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.`,
- );
- }
},
});
diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts
index cad4c0343..f00b840e3 100644
--- a/packages/cli/src/commands/figma/tokens.ts
+++ b/packages/cli/src/commands/figma/tokens.ts
@@ -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;
@@ -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));
+ }
+ });
},
});
diff --git a/packages/core/src/figma/client.ts b/packages/core/src/figma/client.ts
index a433ae09a..efe8f5190 100644
--- a/packages/core/src/figma/client.ts
+++ b/packages/core/src/figma/client.ts
@@ -4,6 +4,7 @@ import type { FigmaAssetFormat, FigmaRef } from "./types";
export type FigmaClientErrorCode =
| "NO_TOKEN"
| "BAD_TOKEN"
+ | "FORBIDDEN"
| "REQUIRES_ENTERPRISE"
| "RATE_LIMITED"
| "RENDER_FAILED"
@@ -119,7 +120,17 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
if (token === "") {
throw new FigmaClientError(
"NO_TOKEN",
- "FIGMA_TOKEN is missing — mint a personal access token at figma.com/settings and export FIGMA_TOKEN",
+ [
+ "FIGMA_TOKEN is missing. One-time setup:",
+ " 1. figma.com/settings → Security → Personal access tokens → Generate new token",
+ " 2. Scopes (read-only is all this integration ever needs — it never writes to figma):",
+ " File content: Read-only · File metadata: Read-only",
+ " Variables: Read-only (optional — brand variables, requires figma Enterprise;",
+ " without it `tokens` falls back to published styles)",
+ ' 3. export FIGMA_TOKEN="figd_…" — add it to your shell profile or the project .env',
+ " so future sessions skip this step",
+ "Then re-run this command.",
+ ].join("\n"),
);
}
const doFetch: FigmaFetch = options.fetch ?? ((url, init) => fetch(url, init));
@@ -130,13 +141,23 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
headers: { "X-Figma-Token": token },
});
if (res.status === 401)
- throw new FigmaClientError("BAD_TOKEN", "figma rejected the token (401)", 401);
+ throw new FigmaClientError(
+ "BAD_TOKEN",
+ "figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
+ 401,
+ );
if (res.status === 403 && enterpriseGated)
throw new FigmaClientError(
"REQUIRES_ENTERPRISE",
"figma variables require an Enterprise plan (403) — fall back to styles",
403,
);
+ if (res.status === 403)
+ throw new FigmaClientError(
+ "FORBIDDEN",
+ "figma denied access (403) — the token is missing a read scope, or your account can't view this file. Check the token has File content: Read-only + File metadata: Read-only (figma.com/settings → Security) and that the file is visible to your account.",
+ 403,
+ );
if (res.status === 429)
throw new FigmaClientError(
"RATE_LIMITED",
diff --git a/skills-manifest.json b/skills-manifest.json
index ba6d0efb0..7001b1b64 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -10,7 +10,7 @@
"files": 17
},
"figma": {
- "hash": "5b39cd7270fe10db",
+ "hash": "c462519102acc265",
"files": 1
},
"general-video": {
@@ -18,7 +18,7 @@
"files": 1
},
"hyperframes": {
- "hash": "9582315c3a80e700",
+ "hash": "2bd5cdf9f851b4d2",
"files": 1
},
"hyperframes-animation": {
diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md
index 40d521e50..6a2f15356 100644
--- a/skills/figma/SKILL.md
+++ b/skills/figma/SKILL.md
@@ -1,40 +1,119 @@
---
name: figma
-description: Import Figma content into a HyperFrames composition — rendered assets, brand tokens, components, and Figma Motion animations — via the Figma MCP connector. Use when the user pastes a figma.com link or asks to bring a Figma design, frame, logo, brand, or animation into a video/composition.
+description: Import Figma content into a HyperFrames composition — rendered assets, brand tokens, components, storyboard sections → animatics (REST/CLI), and Figma Motion animations + shaders (MCP). Use when the user pastes a figma.com link or asks to bring a Figma design, frame, logo, brand, or animation into a video/composition.
---
# Figma → HyperFrames
-Bring the user's Figma work into a composition. **MCP-first:** you (the agent) call the Figma MCP tools, then hand their output to the pure helpers in `@hyperframes/core/figma`, freezing every asset locally so renders stay deterministic.
+Bring the user's Figma work into a composition. **Split by capability** (design spec §2):
-## Auth
+| Phase | What | Transport | Surface |
+| ----- | ------------------- | ---------------------------- | ----------------------------- |
+| 1 | Static assets | REST | `hyperframes figma asset` |
+| 2 | Brand tokens/styles | REST | `hyperframes figma tokens` |
+| 3 | Components → HTML | REST | `hyperframes figma component` |
+| 4 | Motion → GSAP | **MCP only** | you, via `get_motion_context` |
+| 5 | Shaders | **MCP only** / manual export | you |
-Requires the Figma MCP connector (one-click OAuth). If tools error unauthenticated, tell the user to connect Figma and stop.
+REST is used wherever it can be (usable at volume, headless); MCP only where Figma exposes no REST equivalent (motion, shaders). Every path freezes assets locally so renders stay deterministic. Storyboard animatics compose Phase-1 asset exports (REST) with agent-driven timeline assembly — no MCP needed. Existing frozen assets, manifest records, and bindings are unaffected by routing changes — the split only changes which credential the next import uses.
+
+## Auth — two credentials, scoped
+
+**Preflight — before the first CLI call, check a token exists**: shell env (`[ -n "$FIGMA_TOKEN" ]`) **or** the project `.env` (the CLI auto-loads it — a `.env` entry counts as configured). If neither, do NOT run the command to harvest the error — walk the user through the one-time setup first, then stop and wait:
+
+1. figma.com/settings → **Security** → **Personal access tokens** → Generate new token.
+2. Scopes — read-only is all this integration ever needs (it never writes to Figma): **File content: Read-only** + **File metadata: Read-only**. Optionally **Variables: Read-only** for brand variables — that scope only works on Figma Enterprise; without it `tokens` degrades to published styles automatically (expected behavior, not an error — say so).
+3. `export FIGMA_TOKEN="figd_…"` — and suggest persisting it (shell profile or project `.env`) so no future session repeats this.
+
+While onboarding, also set expectations in one breath: every import lands as a **local frozen file with recorded provenance** — renders never call Figma, re-running a command re-imports only what changed in Figma, and one token works for assets, brand tokens, and components across every file their Figma account can view.
+
+- **Phases 4–5 (motion/shaders):** the Figma MCP connector (one-click OAuth), a separate credential from the token. If MCP tools error unauthenticated, tell the user to connect the Figma connector and stop.
+- Say exactly which credential a failing phase needs — never present the split as broken.
+- `BAD_TOKEN` (401) mid-flow → the token is expired/revoked; re-mint. `FORBIDDEN` (403) → missing read scope or no access to that file — check scopes + file visibility. `REQUIRES_ENTERPRISE` (403 on variables) → not a failure: styles fallback already ran.
+
+**Rate-limit awareness (spec §2.1):** MCP on a Starter plan is 6 tool calls/**month** (figma plan matrix as of 2026-07 — re-verify if quotas look off) — batch with `recursive:true` on the parent node, skip verification screenshots unless asked, and cache raw MCP responses so re-derivation never spends a second call. REST is per-minute (10+/min, per-endpoint buckets) — fine at volume, back off on 429.
## Routing
-Parse the user's figma link with `parseFigmaRef`. Then by intent:
+Parse the user's figma link with `parseFigmaRef` (URL, `fileKey:nodeId`, bare `fileKey`). Then by intent:
-- "use this layer / logo / image" → **Asset import**
-- "import this animation / motion" → **Motion import**
-- brand tokens / components → not in this skill version (see the design spec roadmap).
+- "use this layer / logo / image" → **Asset** (CLI)
+- "pull my brand / colors / tokens" → **Tokens** (CLI)
+- "build a scene from this frame" → **Component** (CLI)
+- "import this animation / motion" → **Motion** (MCP, below)
+- a storyboard section / filmstrip of scene frames → **Storyboard** (below)
+- shader fill/effect → **Shaders** (below)
-## Asset import
+**Narrate every step for the user** — before each command say what you're about to pull from Figma; after it, say where the artifact landed (the frozen path / sidecar / component dir), what changed in the composition, and the immediate next action (preview, add printed variables, re-import to link bindings). The user should never have to ask "did it work?" or "now what?".
-1. `get_metadata(fileKey, nodeId)` → confirm the node, capture width/height.
-2. Export it: `download_assets` (or node image export) for PNG; prefer SVG for vectors.
-3. Freeze: write the bytes with `freezeBytes(bytes, join(typeDirPath(projectDir,"image"), id + ext))` where `id = nextId(projectDir,"image")`.
-4. Ledger: `appendRecord(projectDir, { id, type:"image", path, source:"figma", width, height, provenance:{ source:"figma", fileKey, nodeId, format } })`.
-5. Emit `buildAssetSnippet(record).html` into the composition. Re-import guard: `findByFigmaNode` before re-fetching.
+## Assets (Phase 1 — CLI)
-## Motion import
+```bash
+hyperframes figma asset '' [--format svg|png|jpg|pdf] [--scale 2]
+```
-1. `get_motion_context(fileKey, nodeId)` → read the `codeSnippets.motionDev` (and `.css`).
-2. Normalize it into a `MotionDoc`: for each animated property build a `MotionTrack` { property (motion.dev name), values, times (0..1), ease[] (named strings or `[x1,y1,x2,y2]` bezier arrays), duration, repeat }. Selector = the target element's stable id (`#` from the component's `data-figma-id`/`id`).
-3. `const spec = motionToGsap(doc);`
-4. `const script = emitTimelineScript(spec);` → inject as a `