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 +image_007 +``` + +- `--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 `