diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx index c10dc4fdfd..6d180861ea 100644 --- a/docs/guides/figma.mdx +++ b/docs/guides/figma.mdx @@ -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) | @@ -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. In Figma: **Settings → Security → Personal access tokens → Generate new token.** - - The integration never writes to Figma — read-only is all it ever needs: + + 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. ```bash @@ -46,7 +56,11 @@ The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environmen -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 @@ -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) | diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts index c3e25a340b..6e0e1d44de 100644 --- a/packages/cli/src/commands/figma/asset.test.ts +++ b/packages/cli/src/commands/figma/asset.test.ts @@ -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[] = []; @@ -17,8 +22,9 @@ afterEach(() => { }); function fakeClient(overrides: Partial = {}): 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([]), @@ -26,6 +32,19 @@ function fakeClient(overrides: Partial = {}): FigmaClient { 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]); @@ -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) diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 261dda4598..d1a307ea37 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -9,6 +9,7 @@ import { appendRecord, buildAssetSnippet, createFigmaClient, + FigmaClientError, findAllByFigmaNode, freezeBytes, nextId, @@ -49,56 +50,82 @@ export interface AssetImportResult { reused: boolean; } -export async function runAssetImport( - refInput: string, - opts: AssetImportOptions, - deps: AssetImportDeps, -): Promise { +/** + * Flatten CLI positionals into asset refs. Comma-splits bare + * `fileKey:nodeId` tokens (so `asset A,B` batches) but leaves URL tokens + * whole — a figma URL can carry commas in its query (multi-select + * `node-id=1:2,3:4`), and splitting those would tear the URL apart. To batch + * URLs, pass them as separate positional args. + */ +export function gatherAssetRefs(positionals: string[]): string[] { + return positionals + .flatMap((r) => (/^https?:/i.test(r.trim()) ? [r] : r.split(","))) + .map((r) => r.trim()) + .filter((r) => r.length > 0); +} + +function requireNodeRef(refInput: string): { fileKey: string; nodeId: string } { const ref = parseFigmaRef(refInput); if (!ref.nodeId) throw new Error( `ref "${refInput}" has no node id — share a link with ?node-id=… or use fileKey:nodeId`, ); + return { fileKey: ref.fileKey, nodeId: ref.nodeId }; +} - const { version } = await deps.client.fileVersion(ref.fileKey); - const description = normalizeMeta(opts.description); - const entity = normalizeMeta(opts.entity); - - // Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse. - // Check EVERY row for the node (a node can legitimately have several - // format/scale/version tuples — the oldest-row shortcut minted duplicates - // forever once a second tuple existed). Unspecified scale is canonically 1 - // on both sides (figma's default). Reuse also requires the frozen file to - // still exist — a deleted file falls through to re-import. - const existing = findAllByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId).find( +/** Cache hit per spec §5 (fileKey:nodeId:format:scale:version). Check EVERY + * row for the node — a node can carry several format/scale/version tuples, + * and the oldest-row shortcut minted duplicates forever. Reuse requires the + * frozen file to still exist; a deleted file falls through to re-import. + * Metadata supplied on a re-import upserts rather than being discarded. */ +function reuseExisting( + fileKey: string, + nodeId: string, + opts: AssetImportOptions, + version: string, + deps: AssetImportDeps, + description: string | undefined, + entity: string | undefined, +): AssetImportResult | null { + const existing = findAllByFigmaNode(deps.projectDir, fileKey, nodeId).find( (r) => r.provenance.format === opts.format && (r.provenance.scale ?? 1) === (opts.scale ?? 1) && r.provenance.version === version && existsSync(join(deps.projectDir, r.path)), ); - if (existing) { - // Metadata supplied on a re-import still lands: upsert the row instead - // of silently discarding the flags. - let record = existing; - if ( - (description !== undefined && description !== existing.description) || - (entity !== undefined && entity !== existing.entity) - ) { - record = { - ...existing, - ...(description !== undefined && { description }), - ...(entity !== undefined && { entity }), - }; - updateRecord(deps.projectDir, record); - } - safeRegenerateIndex(deps.projectDir); - return { record, snippet: buildAssetSnippet(record), reused: true }; + if (!existing) return null; + let record = existing; + if ( + (description !== undefined && description !== existing.description) || + (entity !== undefined && entity !== existing.entity) + ) { + record = { + ...existing, + ...(description !== undefined && { description }), + ...(entity !== undefined && { entity }), + }; + updateRecord(deps.projectDir, record); } + return { record, snippet: buildAssetSnippet(record), reused: true }; +} - const rendered = await deps.client.renderNode(ref, opts); - let bytes = await deps.download(rendered.url); - if (rendered.ext === "svg") { +/** Freeze a rendered node's bytes and record it. Does NOT regenerate index.md + * — the caller does that once (batch imports would otherwise rewrite it N + * times). */ +async function freezeAndRecord( + fileKey: string, + nodeId: string, + url: string, + ext: FigmaAssetFormat, + opts: AssetImportOptions, + version: string, + deps: AssetImportDeps, + description: string | undefined, + entity: string | undefined, +): Promise { + let bytes = await deps.download(url); + if (ext === "svg") { // Sniff before decoding: an SVG starts with '<' or an XML decl/BOM. A // non-text payload would decode to U+FFFD soup and still write to disk. const b0 = bytes[0]; @@ -106,32 +133,108 @@ export async function runAssetImport( throw new Error("figma render returned non-SVG bytes for an svg export — retry the import"); bytes = new TextEncoder().encode(sanitizeSvg(new TextDecoder().decode(bytes))); } - const id = nextId(deps.projectDir, "image"); - const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${rendered.ext}`); + const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${ext}`); freezeBytes(bytes, destAbs); - const record: FigmaManifestRecord = { id, type: "image", path: relative(deps.projectDir, destAbs), - source: `figma:${ref.fileKey}/${ref.nodeId}`, + source: `figma:${fileKey}/${nodeId}`, ...(description !== undefined && { description }), ...(entity !== undefined && { entity }), provenance: { source: "figma", - fileKey: ref.fileKey, - nodeId: ref.nodeId, + fileKey, + nodeId, version, format: opts.format, scale: opts.scale, }, }; appendRecord(deps.projectDir, record); - safeRegenerateIndex(deps.projectDir); return { record, snippet: buildAssetSnippet(record), reused: false }; } +export async function runAssetImport( + refInput: string, + opts: AssetImportOptions, + deps: AssetImportDeps, +): Promise { + const [result] = await runAssetImportMany([refInput], opts, deps); + if (!result) throw new Error(`figma asset import produced no result for "${refInput}"`); + return result; +} + +/** + * Import many nodes of ONE figma file. Cache-checks each, renders the misses + * in a SINGLE /v1/images batch call (figma's documented rate-limit + * workaround — N nodes, one REST request), freezes each, and regenerates + * index.md once. Results come back in input order. + */ +export async function runAssetImportMany( + refInputs: string[], + opts: AssetImportOptions, + deps: AssetImportDeps, +): Promise { + if (refInputs.length === 0) return []; + const refs = refInputs.map(requireNodeRef); + const fileKey = refs[0]!.fileKey; + const mixed = refs.find((r) => r.fileKey !== fileKey); + if (mixed) + throw new Error( + `all refs in one import must share a fileKey (batch is per-file) — got ${fileKey} and ${mixed.fileKey}; run separate commands per file`, + ); + + const { version } = await deps.client.fileVersion(fileKey); + const description = normalizeMeta(opts.description); + const entity = normalizeMeta(opts.entity); + + // Resolve cache hits first; batch-render only the misses. + const slots: (AssetImportResult | null)[] = refs.map((r) => + reuseExisting(fileKey, r.nodeId, opts, version, deps, description, entity), + ); + const missIndexes = slots.flatMap((s, i) => (s === null ? [i] : [])); + try { + if (missIndexes.length > 0) { + const missNodeIds = missIndexes.map((i) => refs[i]!.nodeId); + const rendered = await deps.client.renderNodes(fileKey, missNodeIds, opts); + const byNode = new Map(rendered.map((r) => [r.nodeId, r] as const)); + for (const i of missIndexes) { + const nodeId = refs[i]!.nodeId; + const r = byNode.get(nodeId); + // Keep the typed code: component import's rasterize fallback skips on + // RENDER_FAILED, so a plain Error here would abort the whole import. + if (!r || r.url === null) + throw new FigmaClientError( + "RENDER_FAILED", + `figma could not render node ${nodeId} as ${opts.format}`, + ); + slots[i] = await freezeAndRecord( + fileKey, + nodeId, + r.url, + r.ext, + opts, + version, + deps, + description, + entity, + ); + } + } + } finally { + // Regenerate once — in `finally` so a mid-batch RENDER_FAILED still leaves + // index.md consistent with the nodes that DID freeze, not stale until the + // next import. + safeRegenerateIndex(deps.projectDir); + } + return slots.map((s, i) => { + if (!s) throw new Error(`figma asset import produced no result for "${refInputs[i]}"`); + return s; + }); +} + /** index.md is a single table row per record — newlines/tabs in a * description would corrupt the whole table. */ function normalizeMeta(value: string | undefined): string | undefined { @@ -159,11 +262,12 @@ function parseFormat(raw: string): FigmaAssetFormat { } export default defineCommand({ - meta: { name: "asset", description: "Import a figma node as a frozen local asset" }, + meta: { name: "asset", description: "Import one or more figma nodes as frozen local assets" }, args: { ref: { type: "positional", - description: "figma URL, fileKey:nodeId, or fileKey", + description: + "figma URL, fileKey:nodeId, or fileKey (pass several, or comma-separate ids, to batch)", required: true, }, format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" }, @@ -183,8 +287,16 @@ export default defineCommand({ const t0 = Date.now(); const token = process.env.FIGMA_TOKEN ?? ""; const client = createFigmaClient({ token }); - const result = await runAssetImport( - args.ref, + // citty puts ALL positionals in `args._` (including the one bound to the + // named `ref`), so use `_` as the source of truth — reading both would + // double-count the first. Split any comma-joined ids, so `asset A B`, + // `asset A,B`, and `asset URL1 URL2` all batch into ONE /v1/images call. + const positionals = ( + Array.isArray(args._) && args._.length > 0 ? (args._ as string[]) : [args.ref] + ).map(String); + const refs = gatherAssetRefs(positionals); + const results = await runAssetImportMany( + refs, { format: parseFormat(args.format), scale: args.scale !== undefined ? Number(args.scale) : undefined, @@ -193,11 +305,25 @@ export default defineCommand({ }, { 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); + for (const result of results) { + const verb = result.reused ? "reused" : "imported"; + console.log(`${verb} ${result.record.id} → ${result.record.path}`); + console.log(result.snippet.html); + } + if (results.length > 1) { + const rendered = results.filter((r) => !r.reused).length; + console.log( + rendered > 0 + ? `(${results.length} nodes, ${rendered} rendered in 1 figma request)` + : `(${results.length} nodes, all reused from cache — no figma request)`, + ); + } const { trackFigmaImport } = await import("../../telemetry/index.js"); - trackFigmaImport({ phase: "asset", reused: result.reused, durationMs: Date.now() - t0 }); + trackFigmaImport({ + phase: "asset", + reused: results.every((r) => r.reused), + durationMs: Date.now() - t0, + }); }); }, }); diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts index 0f8a7ad5a5..260f02aa1d 100644 --- a/packages/cli/src/commands/figma/component.test.ts +++ b/packages/cli/src/commands/figma/component.test.ts @@ -41,6 +41,20 @@ const SVG = new TextEncoder().encode(""); function client(): FigmaClient { return { renderNode: () => Promise.resolve({ url: "https://cdn/x", ext: "svg" }), + // Delegates to whatever renderNode is on the final object (via `this`), so + // inline clients that spread `...client()` and override renderNode still + // drive the batch path; rejections propagate (matching production). + renderNodes(fileKey, nodeIds, opts) { + return Promise.all( + nodeIds.map((nodeId) => + this.renderNode({ fileKey, nodeId }, opts).then((r) => ({ + nodeId, + url: r.url, + ext: r.ext, + })), + ), + ); + }, imageFills: () => Promise.resolve(new Map()), variables: () => Promise.resolve({ variables: {}, variableCollections: {} }), styles: () => Promise.resolve([]), diff --git a/packages/cli/src/commands/figma/tokens.test.ts b/packages/cli/src/commands/figma/tokens.test.ts index 760d4d89cc..8f7f1c6e9e 100644 --- a/packages/cli/src/commands/figma/tokens.test.ts +++ b/packages/cli/src/commands/figma/tokens.test.ts @@ -15,6 +15,7 @@ afterEach(() => rmSync(dir, { recursive: true, force: true })); function client(overrides: Partial): FigmaClient { return { renderNode: () => Promise.reject(new Error("unused")), + renderNodes: () => Promise.reject(new Error("unused")), imageFills: () => Promise.resolve(new Map()), variables: () => Promise.resolve({ @@ -56,12 +57,24 @@ describe("runTokensImport", () => { const out = await runTokensImport("FILE", { projectDir: dir, client: gated }); expect(out.mode).toBe("styles"); expect(out.entries).toEqual([]); + expect(out.styleCount).toBe(1); 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("reports styleCount 0 when the file has no published styles — never a false success", async () => { + const gatedNoStyles = client({ + variables: () => + Promise.reject(new FigmaClientError("REQUIRES_ENTERPRISE", "enterprise only", 403)), + styles: () => Promise.resolve([]), + }); + const out = await runTokensImport("FILE", { projectDir: dir, client: gatedNoStyles }); + expect(out.mode).toBe("styles"); + expect(out.styleCount).toBe(0); + }); + it("propagates non-enterprise failures", async () => { const broken = client({ variables: () => Promise.reject(new FigmaClientError("RATE_LIMITED", "429", 429)), diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts index a1cc6a69e6..87ac8a8d99 100644 --- a/packages/cli/src/commands/figma/tokens.ts +++ b/packages/cli/src/commands/figma/tokens.ts @@ -30,6 +30,10 @@ export interface TokensImportResult { mode: "variables" | "styles"; entries: CompositionVariableEntry[]; sidecarPath: string; + /** styles mode only: how many published styles were actually found — + * entries is always [] in this mode (style values resolve later, at + * component-import time), so this is what tells success from empty. */ + styleCount?: number; } export async function runTokensImport( @@ -68,7 +72,7 @@ export async function runTokensImport( })), }; writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + "\n"); - return { mode: "styles", entries: [], sidecarPath }; + return { mode: "styles", entries: [], sidecarPath, styleCount: styles.length }; } export default defineCommand({ @@ -84,7 +88,9 @@ export default defineCommand({ 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)", + (result.styleCount ?? 0) > 0 + ? `variables are Enterprise-gated on this plan — recorded ${result.styleCount} published style(s) instead (style values resolve at component-import time)` + : "variables are Enterprise-gated on this plan, and this file has no published library styles to fall back to — nothing recorded. Publish the file's styles to a team library, or read variables via the Figma MCP connector's get_variable_defs instead (works on any plan, rate-limited).", ); } console.log(`wrote ${result.sidecarPath} (${result.mode})`); diff --git a/packages/cli/src/ui/format.ts b/packages/cli/src/ui/format.ts index 0ba33827ca..e76792ac33 100644 --- a/packages/cli/src/ui/format.ts +++ b/packages/cli/src/ui/format.ts @@ -46,7 +46,16 @@ export function label(name: string, value: string): string { export function errorBox(title: string, hint?: string, suggestion?: string): void { console.error(`\n${c.error("\u2717")} ${c.bold(title)}`); - if (hint) console.error(`\n ${c.dim(hint)}`); + if (hint) { + // Indent EVERY hint line, not just the first \u2014 a multi-line hint (e.g. the + // NO_TOKEN numbered setup list) otherwise had line 1 indented and the rest + // flush-left, mangling the list. Single-line hints are unchanged. + const indented = hint + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + console.error(`\n${c.dim(indented)}`); + } if (suggestion) console.error(` ${c.accent(suggestion)}`); console.error(); } diff --git a/packages/core/src/figma/client.test.ts b/packages/core/src/figma/client.test.ts index 04c3b14005..3af01728a6 100644 --- a/packages/core/src/figma/client.test.ts +++ b/packages/core/src/figma/client.test.ts @@ -106,14 +106,18 @@ describe("variables", () => { }); describe("error mapping", () => { - it("maps 429 to RATE_LIMITED and 401 to BAD_TOKEN", async () => { + it("maps 429 to RATE_LIMITED (after retries) and 401 to BAD_TOKEN", async () => { + const stub = fetchStub(() => jsonResponse(429, {})); const c429 = createFigmaClient({ token: "t", - fetch: fetchStub(() => jsonResponse(429, {})).fetch, + fetch: stub.fetch, + sleep: () => Promise.resolve(), }); await expect(c429.styles("F")).rejects.toThrowError( expect.objectContaining({ code: "RATE_LIMITED" }), ); + // 1 initial + 3 retries = 4 attempts + expect(stub.calls).toHaveLength(4); const c401 = createFigmaClient({ token: "t", fetch: fetchStub(() => jsonResponse(401, {})).fetch, @@ -123,6 +127,179 @@ describe("error mapping", () => { ); }); + it("retries 429 and succeeds when the limit clears", async () => { + let n = 0; + const waits: number[] = []; + const client = createFigmaClient({ + token: "t", + fetch: (() => { + n += 1; + return Promise.resolve( + n < 3 + ? jsonResponse(429, {}) + : jsonResponse(200, { + meta: { styles: [{ key: "k", name: "P", style_type: "FILL" }] }, + }), + ); + }) as FigmaFetch, + sleep: (ms) => { + waits.push(ms); + return Promise.resolve(); + }, + }); + const styles = await client.styles("F"); + expect(styles[0]?.key).toBe("k"); + expect(n).toBe(3); // two 429s then success + expect(waits).toEqual([1000, 2000]); // exponential backoff + }); + + it("caps an oversized Retry-After at 60s so the CLI can't block for an hour", async () => { + let n = 0; + const waits: number[] = []; + const client = createFigmaClient({ + token: "t", + fetch: (() => { + n += 1; + return Promise.resolve( + n === 1 + ? new Response("{}", { status: 429, headers: { "retry-after": "3600" } }) + : jsonResponse(200, { meta: { styles: [] } }), + ); + }) as FigmaFetch, + sleep: (ms) => { + waits.push(ms); + return Promise.resolve(); + }, + }); + await client.styles("F"); + expect(waits).toEqual([60_000]); // 3600s clamped, not 3_600_000 + }); + + it("retries 429 on non-styles endpoints too (retry lives in the shared get)", async () => { + let n = 0; + const client = createFigmaClient({ + token: "t", + fetch: (() => { + n += 1; + return Promise.resolve( + n < 2 + ? jsonResponse(429, {}) + : jsonResponse(200, { images: { "1:2": "https://cdn/a.png" } }), + ); + }) as FigmaFetch, + sleep: () => Promise.resolve(), + }); + const out = await client.renderNodes("F", ["1:2"], { format: "png" }); + expect(out[0]?.url).toBe("https://cdn/a.png"); + expect(n).toBe(2); // one 429 then success + }); + + it("honors Retry-After (seconds) over the backoff default", async () => { + let n = 0; + const waits: number[] = []; + const client = createFigmaClient({ + token: "t", + fetch: (() => { + n += 1; + return Promise.resolve( + n === 1 + ? new Response("{}", { status: 429, headers: { "retry-after": "5" } }) + : jsonResponse(200, { meta: { styles: [] } }), + ); + }) as FigmaFetch, + sleep: (ms) => { + waits.push(ms); + return Promise.resolve(); + }, + }); + await client.styles("F"); + expect(waits).toEqual([5000]); + }); + + it("names the endpoint scope in the styles 403 when the body is silent", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch, + }); + await expect(client.styles("F")).rejects.toThrowError( + expect.objectContaining({ + code: "FORBIDDEN", + message: expect.stringContaining("library_content:read"), + }), + ); + }); + + it("surfaces figma's own scope diagnosis verbatim from the 403 body (err field)", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => + jsonResponse(403, { + err: "Invalid scope(s): file_content:read, file_metadata:read. This endpoint requires the library_content:read scope", + }), + ).fetch, + }); + await expect(client.styles("F")).rejects.toThrowError( + expect.objectContaining({ + code: "FORBIDDEN", + message: expect.stringContaining("requires the library_content:read scope"), + }), + ); + }); + + it("reclassifies a 403 'Invalid token' body as BAD_TOKEN, not a scope problem", async () => { + // figma returns 403 (not 401) for bad PATs on file endpoints — verified live + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(403, { err: "Invalid token" })).fetch, + }); + const err = await client.styles("F").catch((e: unknown) => e); + expect(err).toBeInstanceOf(FigmaClientError); + if (err instanceof FigmaClientError) { + expect(err.code).toBe("BAD_TOKEN"); + expect(err.message).toContain("Re-mint"); + } + }); + + it("keeps REQUIRES_ENTERPRISE for a scopeless variables 403", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch, + }); + await expect(client.variables("F")).rejects.toThrowError( + expect.objectContaining({ code: "REQUIRES_ENTERPRISE" }), + ); + }); +}); + +describe("renderNodes (batch)", () => { + it("fetches many nodes in ONE /v1/images call and maps each url", async () => { + const stub = fetchStub(() => + jsonResponse(200, { + images: { "1:2": "https://cdn/a.png", "3:4": "https://cdn/b.png" }, + }), + ); + const client = createFigmaClient({ token: "t", fetch: stub.fetch }); + const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "png" }); + expect(stub.calls).toHaveLength(1); + expect(stub.calls[0]).toContain("ids=1%3A2%2C3%3A4"); // "1:2,3:4" url-encoded + expect(out).toEqual([ + { nodeId: "1:2", url: "https://cdn/a.png", ext: "png" }, + { nodeId: "3:4", url: "https://cdn/b.png", ext: "png" }, + ]); + }); + + it("returns url:null for a node figma couldn't render, without failing the batch", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => + jsonResponse(200, { images: { "1:2": "https://cdn/a.png", "3:4": null } }), + ).fetch, + }); + const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "svg" }); + expect(out[0]?.url).toBe("https://cdn/a.png"); + expect(out[1]?.url).toBeNull(); + }); + it("wraps other failures as HTTP_ERROR with status", async () => { const client = createFigmaClient({ token: "t", diff --git a/packages/core/src/figma/client.ts b/packages/core/src/figma/client.ts index efe8f51903..29efadd6dc 100644 --- a/packages/core/src/figma/client.ts +++ b/packages/core/src/figma/client.ts @@ -76,8 +76,24 @@ export interface FigmaFileVersion { lastModified: string; } +/** One batch render result — url is null when figma couldn't render that + * node (a bad node id in the batch shouldn't fail the whole call). */ +export interface BatchRenderedNode { + nodeId: string; + url: string | null; + ext: FigmaAssetFormat; +} + export interface FigmaClient { renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise; + /** Batch render many nodes of ONE file in a single /v1/images call — the + * documented rate-limit workaround (comma-separated ids). Per-node + * failures come back as url:null rather than throwing the batch. */ + renderNodes( + fileKey: string, + nodeIds: string[], + opts: RenderNodeOptions, + ): Promise; imageFills(fileKey: string): Promise>; variables(fileKey: string): Promise; styles(fileKey: string): Promise; @@ -89,6 +105,63 @@ export interface FigmaClientOptions { token: string; fetch?: FigmaFetch; baseUrl?: string; + /** Injectable delay for 429 backoff — tests pass a no-op so retries don't + * actually wait. Defaults to a real timer. */ + sleep?: (ms: number) => Promise; + /** Max 429 retries before giving up. Default 3. */ + maxRetries?: number; +} + +/** Read scope each endpoint needs, named exactly as figma's PAT settings UI + * lists them — so a 403 tells the user which checkbox to tick, not just + * "some read scope". The styles endpoint's `library_content:read` is the one + * the setup docs used to omit (it 403s even with file content + metadata). */ +const SCOPE_HINTS = { + fileContent: "File content: Read-only", + fileMetadata: "File metadata: Read-only", + libraryContent: "Library content: Read-only (library_content:read)", +} as const; + +/** Longest we'll auto-wait on a single Retry-After before giving up — past a + * minute the user is better off cancelling and reducing batch size (the + * RATE_LIMITED message says so) than watching the CLI block silently. */ +const MAX_RETRY_WAIT_MS = 60_000; + +/** Parse a Retry-After header (figma sends integer seconds; the HTTP spec + * also allows a date) into ms, capped at MAX_RETRY_WAIT_MS, or null when + * absent/unparseable. The cap keeps a spec-legal `Retry-After: 3600` (tier + * quota exhaustion) from silently blocking the CLI for an hour. */ +function retryAfterMs(res: Response): number | null { + const raw = res.headers.get("retry-after"); + if (raw === null) return null; + const secs = Number(raw); + if (Number.isFinite(secs)) return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, secs * 1000)); + const date = Date.parse(raw); + if (Number.isNaN(date)) return null; + return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, date - Date.now())); +} + +/** Figma's error bodies are precise — "Invalid token", or "Invalid scope(s): + * … requires the X scope" — and worth surfacing verbatim instead of a + * generic guess. The message lives under `err` on most endpoints but + * `message` on /variables; read both. Returns null when unparseable. */ +async function readFigmaErrorMessage(res: Response): Promise { + let text: string; + try { + text = await res.text(); + } catch { + return null; + } + try { + const body: unknown = JSON.parse(text); + if (isRecord(body)) { + if (typeof body.err === "string") return body.err; + if (typeof body.message === "string") return body.message; + } + } catch { + // non-JSON body — fall through + } + return text.trim() === "" ? null : text.trim(); } function requireNodeId(ref: FigmaRef): string { @@ -125,6 +198,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { " 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", + " Library content: Read-only (needed for the `tokens` published-styles fallback)", " 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', @@ -135,62 +209,125 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { } const doFetch: FigmaFetch = options.fetch ?? ((url, init) => fetch(url, init)); const base = options.baseUrl ?? "https://api.figma.com"; + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const maxRetries = options.maxRetries ?? 3; - async function get(path: string, enterpriseGated = false): Promise { - const res = await doFetch(`${base}${path}`, { - headers: { "X-Figma-Token": token }, - }); - if (res.status === 401) - throw new FigmaClientError( + interface GetOptions { + /** 403 → REQUIRES_ENTERPRISE (variables) rather than FORBIDDEN. */ + enterpriseGated?: boolean; + /** scope named in a FORBIDDEN message so the user knows which to add. */ + scopeHint?: string; + } + + /** Map a 403 to the right typed error using figma's own response body: + * "Invalid token" is a bad PAT (figma returns 403, NOT 401, for these on + * file endpoints), "Invalid scope(s) … requires X" is a missing scope + * surfaced verbatim. Falls back to the endpoint's scopeHint when the body + * is silent. */ + function forbiddenError(body: string | null, opts: GetOptions): FigmaClientError { + // Every branch RETURNS the error (the caller throws once) — no mixed + // throw/return, so a future caller that wraps the result gets consistent + // behavior across all three cases. + if (body && /invalid token/i.test(body)) + return 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, + "figma rejected the token (403 Invalid token) — it is invalid, expired, or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.", + 403, ); - if (res.status === 403 && enterpriseGated) - throw new FigmaClientError( + if (opts.enterpriseGated) + return new FigmaClientError( "REQUIRES_ENTERPRISE", "figma variables require an Enterprise plan (403) — fall back to styles", 403, ); - if (res.status === 403) - throw new FigmaClientError( + if (body && /scope/i.test(body)) + return 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.", + `figma denied access (403): ${body} — add the named scope at figma.com/settings → Security → Personal access tokens.`, 403, ); + const scopeLine = opts.scopeHint + ? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.` + : "The token is missing a read scope, or your account can't view this file. Check File content: Read-only + File metadata: Read-only at figma.com/settings → Security."; + return new FigmaClientError( + "FORBIDDEN", + `figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`, + 403, + ); + } + + /** Throw the typed error for a non-ok response (no-op when res.ok). */ + async function throwForStatus(res: Response, path: string, opts: GetOptions): Promise { + if (res.ok) return; + if (res.status === 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) throw forbiddenError(await readFigmaErrorMessage(res), opts); if (res.status === 429) throw new FigmaClientError( "RATE_LIMITED", - "figma rate limit hit (429) — back off and retry", + `figma rate limit hit (429) and still limited after ${maxRetries} retries — wait a minute and re-run, or import fewer nodes per call.`, 429, ); - if (!res.ok) - throw new FigmaClientError( - "HTTP_ERROR", - `figma request failed: HTTP ${res.status} ${path}`, - res.status, - ); + throw new FigmaClientError( + "HTTP_ERROR", + `figma request failed: HTTP ${res.status} ${path}`, + res.status, + ); + } + + async function get(path: string, opts: GetOptions = {}): Promise { + // Retry 429 with backoff before surfacing RATE_LIMITED — figma's limit is + // per-minute, so a couple of imports in quick succession hit it and a + // short wait clears it. Honor Retry-After when present, else exponential. + let res: Response; + for (let attempt = 0; ; attempt += 1) { + res = await doFetch(`${base}${path}`, { headers: { "X-Figma-Token": token } }); + if (res.status !== 429 || attempt >= maxRetries) break; + const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt; + await sleep(wait); + } + await throwForStatus(res, path, opts); return res.json(); } return { async renderNode(ref, opts) { const nodeId = requireNodeId(ref); - const params = new URLSearchParams({ ids: nodeId, format: opts.format }); - if (opts.scale !== undefined) params.set("scale", String(opts.scale)); - const body = await get(`/v1/images/${ref.fileKey}?${params}`); - const images = isRecord(body) && isRecord(body.images) ? body.images : {}; - const url = images[nodeId]; - if (typeof url !== "string" || url === "") + const [result] = await this.renderNodes(ref.fileKey, [nodeId], opts); + if (!result || result.url === null) throw new FigmaClientError( "RENDER_FAILED", `figma could not render node ${nodeId} as ${opts.format}`, ); - return { url, ext: opts.format }; + return { url: result.url, ext: opts.format }; + }, + + async renderNodes(fileKey, nodeIds, opts) { + if (nodeIds.length === 0) return []; + // /v1/images accepts comma-separated ids — one call for the whole batch, + // which is figma's own answer to the per-minute rate limit. + const params = new URLSearchParams({ ids: nodeIds.join(","), format: opts.format }); + if (opts.scale !== undefined) params.set("scale", String(opts.scale)); + const body = await get(`/v1/images/${fileKey}?${params}`, { + scopeHint: SCOPE_HINTS.fileContent, + }); + const images = isRecord(body) && isRecord(body.images) ? body.images : {}; + return nodeIds.map((nodeId) => { + const url = images[nodeId]; + return { + nodeId, + url: typeof url === "string" && url !== "" ? url : null, + ext: opts.format, + }; + }); }, async imageFills(fileKey) { - const body = await get(`/v1/files/${fileKey}/images`); + const body = await get(`/v1/files/${fileKey}/images`, { scopeHint: SCOPE_HINTS.fileContent }); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const images = isRecord(meta.images) ? meta.images : {}; const out = new Map(); @@ -201,7 +338,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { }, async variables(fileKey) { - const body = await get(`/v1/files/${fileKey}/variables/local`, true); + const body = await get(`/v1/files/${fileKey}/variables/local`, { enterpriseGated: true }); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const variables = isRecord(meta.variables) ? meta.variables : {}; const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {}; @@ -214,7 +351,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { }, async styles(fileKey) { - const body = await get(`/v1/files/${fileKey}/styles`); + const body = await get(`/v1/files/${fileKey}/styles`, { + scopeHint: SCOPE_HINTS.libraryContent, + }); const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {}; const styles = Array.isArray(meta.styles) ? meta.styles : []; return styles.filter( @@ -229,7 +368,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { async nodeTree(ref) { const nodeId = requireNodeId(ref); const params = new URLSearchParams({ ids: nodeId, geometry: "paths" }); - const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`); + const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`, { + scopeHint: SCOPE_HINTS.fileContent, + }); const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {}; const entry = nodes[nodeId]; const doc = isRecord(entry) ? entry.document : undefined; @@ -244,7 +385,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { }, async fileVersion(fileKey) { - const body = await get(`/v1/files/${fileKey}?depth=1`); + const body = await get(`/v1/files/${fileKey}?depth=1`, { + scopeHint: SCOPE_HINTS.fileMetadata, + }); const version = isRecord(body) && typeof body.version === "string" ? body.version : ""; const lastModified = isRecord(body) && typeof body.lastModified === "string" ? body.lastModified : ""; diff --git a/packages/core/src/figma/nodeToHtml.test.ts b/packages/core/src/figma/nodeToHtml.test.ts index 65d332e9f1..ef18989447 100644 --- a/packages/core/src/figma/nodeToHtml.test.ts +++ b/packages/core/src/figma/nodeToHtml.test.ts @@ -234,6 +234,47 @@ describe("nodeToHtml", () => { expect(out.html).toContain(" { + const out = nodeToHtml( + frame([ + { + id: "1:8", + name: "Sneaker Photo", + type: "RECTANGLE", + absoluteBoundingBox: BOX(120, 220, 200, 200), + fills: [{ type: "IMAGE", imageRef: "abc123" }], + }, + ]), + { resolved: [], unresolved: [] }, + ); + expect(out.rasterize).toEqual([ + { nodeId: "1:8", name: "Sneaker Photo", slug: "sneaker-photo" }, + ]); + expect(out.html).toContain('data-figma-rasterize="1:8"'); + expect(out.html).toContain(" { + const out = nodeToHtml( + frame([ + { + id: "1:9", + name: "Blob", + type: "VECTOR", + absoluteBoundingBox: BOX(120, 220, 64, 64), + fills: [SOLID_BLUE], + cornerRadius: 12, + opacity: 0.5, + }, + ]), + { resolved: [], unresolved: [] }, + ); + expect(out.html).not.toContain("background-color: #0066FF"); + expect(out.html).not.toContain("border-radius: 12px"); + // opacity is compositing, not shape — still applies on top of the export + expect(out.html).toContain("opacity: 0.5"); + }); + it("skips invisible nodes and invisible fills (respects visible:false)", () => { const out = nodeToHtml( frame([ diff --git a/packages/core/src/figma/nodeToHtml.ts b/packages/core/src/figma/nodeToHtml.ts index e1779d77c2..1610e8800d 100644 --- a/packages/core/src/figma/nodeToHtml.ts +++ b/packages/core/src/figma/nodeToHtml.ts @@ -7,8 +7,11 @@ * - CSS where CSS is faithful: solid/linear-gradient fills, corner radius, * opacity, drop shadow, blur, text styles. * - Everything CSS can't match faithfully (vectors, boolean ops, exotic - * paint) routes to the rasterize list — the caller exports those nodes as - * images (Phase 1) and fills in the placeholder src. + * paint, IMAGE fills) routes to the rasterize list — the caller exports + * those nodes as images (Phase 1) and fills in the placeholder src. A + * rasterized node's own fill/corner-radius CSS is never emitted — the + * exported image already contains it; adding both double-paints (a flat + * color block behind/around the real art). * - Bindings (§7.1): resolved sites emit var(--slug, literal) so a brand * refresh propagates; unresolved sites bake the literal and carry a * data-figma-unresolved flag. Never a dangling var(). @@ -113,6 +116,13 @@ function fillCss(node: FigmaNodeDocument): string | null { return null; } +/** IMAGE fills (photos, icons pasted as bitmaps) have no CSS equivalent — + * route to rasterize like vectors, regardless of node.type (a plain + * RECTANGLE/FRAME carries the fill just as often as a dedicated image node). */ +function hasImageFill(node: FigmaNodeDocument): boolean { + return firstVisibleFill(node)?.type === "IMAGE"; +} + function dropShadowCss(effect: Record): string | null { if (!isRecord(effect.offset)) return null; const color = figmaColorToCss(effect.color); @@ -234,33 +244,47 @@ function geometryCss(node: FigmaNodeDocument, parentBox: Box, isRoot: boolean): return styles; } -function shapeCss(node: FigmaNodeDocument, styles: string[]): void { +/** Corner-radius + clip describe the node's OWN shape — meaningless once + * that shape has already been baked into a rasterized image (see + * decorationCss). Opacity stays separate: it's compositing, still correct + * to apply on top of a raster/vector export. */ +function cornerAndClipCss(node: FigmaNodeDocument, styles: string[]): void { if (node.type === "ELLIPSE") { styles.push("border-radius: 50%"); } else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) { styles.push(`border-radius: ${round(node.cornerRadius)}px`); } if (node.clipsContent === true) styles.push("overflow: hidden"); +} + +function opacityCss(node: FigmaNodeDocument, styles: string[]): void { if (typeof node.opacity === "number" && node.opacity < 1) styles.push(`opacity: ${round(node.opacity)}`); } -function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] { +function decorationCss(node: FigmaNodeDocument, ctx: RenderContext, rasterized: boolean): string[] { const styles: string[] = []; - // backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT - // color goes through it too, so a token-bound text fill keeps its link. - const bg = backgroundValue(node, ctx); - if (node.type === "TEXT") { - if (bg !== null) styles.push(`color: ${bg}`); - textCss(node, styles); - } else if (bg !== null) { - // background-color (longhand) for solid fills, never the shorthand: GSAP - // backgroundColor tweens can't read a var() through the shorthand (its - // pending-substitution longhands serialize empty), so .from/.to on an - // imported node would settle on transparent instead of the token color. - styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`); + // A rasterized node's fill/shape is already baked into the exported image + // — background-color/border-radius on top of it would double-paint (a + // flat color block behind or around the real art). Opacity and effects + // (shadow/blur) aren't baked by the export, so those still apply. + if (!rasterized) { + // backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT + // color goes through it too, so a token-bound text fill keeps its link. + const bg = backgroundValue(node, ctx); + if (node.type === "TEXT") { + if (bg !== null) styles.push(`color: ${bg}`); + textCss(node, styles); + } else if (bg !== null) { + // background-color (longhand) for solid fills, never the shorthand: GSAP + // backgroundColor tweens can't read a var() through the shorthand (its + // pending-substitution longhands serialize empty), so .from/.to on an + // imported node would settle on transparent instead of the token color. + styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`); + } + cornerAndClipCss(node, styles); } - shapeCss(node, styles); + opacityCss(node, styles); effectsCss(node, styles); return styles; } @@ -292,15 +316,16 @@ function renderNodeHtml( ): string { if (node.visible === false || depth > MAX_DEPTH) return ""; const slug = uniqueSlug(ctx, node.name); + const rasterized = RASTERIZE_TYPES.has(node.type) || hasImageFill(node); const style = escapeHtml( - [...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx)].join("; "), + [...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx, rasterized)].join("; "), ); // data-hf-snippet marks the file as a mountable fragment, not a standalone // composition — the project linter skips composition-root rules for it. const snippetAttr = isRoot ? ' data-hf-snippet=""' : ""; const idAttrs = `id="${slug}"${snippetAttr} data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`; - if (RASTERIZE_TYPES.has(node.type)) { + if (rasterized) { ctx.rasterize.push({ nodeId: node.id, name: node.name, slug }); return `${escapeHtml(node.name)}`; } diff --git a/skills-manifest.json b/skills-manifest.json index 420885a7b1..3cf1b90554 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,15 +10,15 @@ "files": 18 }, "figma": { - "hash": "c2929c6cc7ca35b3", + "hash": "99538ee56a4ca553", "files": 2 }, "general-video": { - "hash": "e26710c3537b3a07", + "hash": "67f3dae100541eed", "files": 1 }, "hyperframes": { - "hash": "7ddbb928a3674e5e", + "hash": "132596767485f923", "files": 1 }, "hyperframes-animation": { @@ -50,7 +50,7 @@ "files": 113 }, "motion-graphics": { - "hash": "96ed2f7d8051b009", + "hash": "0f1ac928e387a74c", "files": 23 }, "music-to-video": { @@ -62,7 +62,7 @@ "files": 22 }, "product-launch-video": { - "hash": "4f858cc4d59324da", + "hash": "937dcd6c581fb054", "files": 20 }, "remotion-to-hyperframes": { @@ -70,7 +70,7 @@ "files": 70 }, "slideshow": { - "hash": "19a0332616bc397b", + "hash": "114b57cf22b39068", "files": 2 }, "talking-head-recut": { @@ -78,7 +78,7 @@ "files": 27 }, "website-to-video": { - "hash": "32bdb559f4d18f99", + "hash": "79af52a847abaa43", "files": 32 } } diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index c0ceb4cb80..b08f771743 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -24,14 +24,14 @@ REST is used wherever it can be (usable at volume, headless); MCP only where Fig **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). +2. Scopes — read-only is all this integration ever needs (it never writes to Figma): **File content: Read-only** + **File metadata: Read-only**. Add **Library content: Read-only** if you'll run `tokens` on a non-Enterprise plan — the published-styles fallback hits `/v1/files/:key/styles`, which 403s without it (a scope the older setup text omitted). Optionally **Variables: Read-only** for brand variables — Enterprise-only; without it `tokens` degrades to published styles automatically (expected, not an error — say so). A 403 now names the exact missing scope; 429s retry automatically (per-minute limit, honors `Retry-After`). 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. +- `BAD_TOKEN` (401) mid-flow → the token is expired/revoked; re-mint. `FORBIDDEN` (403) → the message names the exact missing scope (e.g. `library_content:read` for the styles fallback) — add it, or the file isn't visible to the account. `REQUIRES_ENTERPRISE` (403 on variables) → not a failure: styles fallback already ran. `RATE_LIMITED` (429) → the client already retried with backoff (this applies to EVERY read — assets, tokens, styles, node trees, versions — the retry lives in the shared request path; `Retry-After` is honored, capped at 60s); if it still surfaces, wait a minute or import fewer nodes per call. **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. @@ -51,11 +51,13 @@ Parse the user's figma link with `parseFigmaRef` (URL, `fileKey:nodeId`, bare `f ## Assets (Phase 1 — CLI) ```bash -hyperframes figma asset '' [--format svg|png|jpg|pdf] [--scale 2] [--description "..."] [--entity "..."] +hyperframes figma asset '' [more refs…] [--format svg|png|jpg|pdf] [--scale 2] [--description "..."] [--entity "..."] ``` Renders over REST, sanitizes SVG, freezes under `.media/images/`, appends the manifest with provenance, regenerates `.media/index.md` (the shared media-use inventory), prints an `` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity. **Always pass `--description ""`** (it becomes the index row + ``); add `--entity ""` for named brand marks so media-use `resolve --entity` finds them later (entity hits match across image/icon). +**Batch many nodes in ONE request** — pass several refs (space-separated or comma-joined) of the SAME file: `hyperframes figma asset 'KEY:1-2' 'KEY:3-4' 'KEY:5-6'`. All render in a single `/v1/images` call, which is figma's own answer to the per-minute rate limit — prefer it over N separate commands when pulling a whole frame's worth of assets. `--description`/`--entity` apply to every node in the batch, so batch nodes that share a purpose. 429s also auto-retry with backoff regardless. + ## Tokens (Phase 2 — CLI) ```bash diff --git a/skills/general-video/SKILL.md b/skills/general-video/SKILL.md index 88a004d553..9b4abe8165 100644 --- a/skills/general-video/SKILL.md +++ b/skills/general-video/SKILL.md @@ -12,6 +12,8 @@ metadata: { "tags": "orchestrator, general-video, fallback, freeform, compositio > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. +> **figma source**: If any input is a figma.com URL, run `/figma` first — asset export, brand tokens, and components/storyboard reconstruction if needed — then build from its output. Don't drive Figma via raw MCP tools directly: that skips SVG sanitization, `.media/manifest.jsonl` provenance, and brand-token `var()` binding, so a later brand change can't propagate without a full re-import. + # general-video — general video workflow > **Confirm the route before you build.** This is the **fallback** for custom composition authoring. If the input clearly fits a specialized workflow, prefer it: marketed product → `/product-launch-video`; general site → `/website-to-video`; topic explainer → `/faceless-explainer`; GitHub PR → `/pr-to-video`; existing footage → `/embedded-captions` · `/talking-head-recut`; short unnarrated motion graphic → `/motion-graphics`; Remotion port → `/remotion-to-hyperframes`. **Out of scope**: live / at-render-time data, NLE-style editing of a finished video, or producing footage HyperFrames can't capture. Unsure? **Read `/hyperframes` first.** diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index b80d7e1cc0..680c3a848f 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -47,6 +47,7 @@ This section knows only the top-level workflows; it does not load their internal Routing needs to know **what the video is about** — its input and subject. If that's unspecified ("make a video about our thing" with no URL, product, topic, or asset), ask before entering any workflow — committing to a workflow IS the routing decision. At most two questions: - **Input** — a product (URL / brief), a general website, a GitHub PR, a topic to explain, or an existing talking-head video? +- **Figma source** — if the input is a figma.com URL, `/figma` extracts assets/tokens/(components/storyboard) first, regardless of which workflow below is chosen for the video's shape; that workflow then builds from `/figma`'s output — never by driving Figma via raw MCP tools directly (skips SVG sanitization, provenance, and brand-token binding). **Mode** — if the request carries an ongoing autonomous signal ("surprise me", "decide for me", "just build it"), note it and pass it into the workflow: the whole run goes autonomous and no later step re-asks. With no signal, the workflow asks the mode as its first brief question. Default is collaborative. (`/motion-graphics` is autonomous by design.) Semantics: `hyperframes-core` → `references/brief-contract.md`. diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 9c5180e6a4..104d493afa 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -17,6 +17,8 @@ metadata: > **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update motion-graphics`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. +> **figma source**: If the logo/asset/animation to build from comes from a figma.com URL, run `/figma` first — asset export, brand tokens, and Motion→GSAP translation if the graphic is a Figma Motion import — then build from its output. Don't drive Figma via raw MCP tools directly: that skips SVG sanitization, `.media/manifest.jsonl` provenance, and brand-token `var()` binding, so a later brand change can't propagate without a full re-import. + # motion-graphics — dispatch entry > **Confirm the route before Step 0.** This skill makes a **short, design-led, unnarrated motion graphic** (motion is the message; ~under 10s, no voice-over). A **longer, multi-scene, or narrated** treatment → `/general-video`; a **narrated video of a website** → `/website-to-video`; a **topic explainer** → `/faceless-explainer`; a **product promo** → `/product-launch-video`; **captions on existing footage** → `/embedded-captions`. **Out of scope**: live / at-render-time data, or footage it can't capture. Unsure motion-first-vs-narrated? **Read `/hyperframes` first.** diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 8ff1b63bb8..50ef6c44cd 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -7,6 +7,8 @@ description: "Turn a product or marketing URL, pasted script, or brief into a pr > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. +> **figma source**: If the source is a figma.com URL, run `/figma` first — asset export, brand tokens, and components/storyboard reconstruction if needed — then build this workflow from its output. Don't drive Figma via raw MCP tools directly: that skips SVG sanitization, `.media/manifest.jsonl` provenance, and brand-token `var()` binding, so a later brand change can't propagate without a full re-import. + # Product Launch to HyperFrames Use this skill to capture a product, understand its brand, plan a launch video, and build it frame by frame in HyperFrames. diff --git a/skills/slideshow/SKILL.md b/skills/slideshow/SKILL.md index 2a16a39aaa..9c57dad080 100644 --- a/skills/slideshow/SKILL.md +++ b/skills/slideshow/SKILL.md @@ -11,6 +11,8 @@ description: > > **First, keep this skill fresh — run silently, don't ask:** `npx hyperframes skills update slideshow`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them. +> **figma source**: If the deck's content or storyboard comes from a figma.com URL, run `/figma` first — asset export, brand tokens, and storyboard reconstruction if the source is a strip of scene frames — then build from its output. Don't drive Figma via raw MCP tools directly: that skips SVG sanitization, `.media/manifest.jsonl` provenance, and brand-token `var()` binding, so a later brand change can't propagate without a full re-import. + # Slideshow authoring contract A HyperFrames slideshow is a normal HyperFrames composition — scenes, clips, GSAP timelines — with one extra ingredient: a **JSON island** that declares which scenes are slides and how they connect. The player's `SlideshowController` reads the island and turns the continuous GSAP timeline into a discrete, navigable deck. diff --git a/skills/website-to-video/SKILL.md b/skills/website-to-video/SKILL.md index f693351836..afa7921368 100644 --- a/skills/website-to-video/SKILL.md +++ b/skills/website-to-video/SKILL.md @@ -7,6 +7,8 @@ description: "Capture a general website/URL and turn it into a video OF the site > **media-use**: Before sourcing audio/images/logos, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog and brand logos from their official sources. Run `--adopt` first to register existing assets. See `/media-use` skill. +> **figma source**: If the URL is a figma.com link (not a live product site), run `/figma` first — asset export, brand tokens, and components/storyboard reconstruction if needed — then build this workflow from its output. Don't drive Figma via raw MCP tools directly: that skips SVG sanitization, `.media/manifest.jsonl` provenance, and brand-token `var()` binding, so a later brand change can't propagate without a full re-import. + # Website to HyperFrames Capture a website, then produce a professional video from it.