From 1bb7688347126fe9e0213d93b36f50dade252b8a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 13:31:13 -0700 Subject: [PATCH 1/9] fix(core): name missing figma scope in 403, retry 429 with backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from live figma-integration use: 1. `tokens` styles fallback 403s on non-Enterprise. /v1/files/:key/styles needs library_content:read — a scope the setup docs and the generic FORBIDDEN message both omitted, so the user saw "missing a read scope" with no way to know which. Each endpoint now carries a scope hint; the 403 names the exact scope (styles → library_content:read). Setup text and skill scope list updated to include Library content: Read-only. 2. `asset` (and every per-node component render) had no 429 handling — the message said "back off and retry" but the client didn't. Two imports in a row tripped the per-minute limit and hard-failed. get() now retries 429 with exponential backoff, honoring Retry-After when present, before surfacing RATE_LIMITED after maxRetries (default 3). sleep is injectable so tests don't wait. Batch multi-node asset syntax (the documented /v1/images comma-ids rate workaround) is a separate enhancement — retry makes the reported failure self-heal, including the many-node component path. Co-Authored-By: Claude Fable 5 --- packages/core/src/figma/client.test.ts | 69 ++++++++++++++++- packages/core/src/figma/client.ts | 100 ++++++++++++++++++++----- skills-manifest.json | 2 +- skills/figma/SKILL.md | 4 +- 4 files changed, 150 insertions(+), 25 deletions(-) diff --git a/packages/core/src/figma/client.test.ts b/packages/core/src/figma/client.test.ts index 04c3b14005..3db4edb461 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,67 @@ 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("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 library_content scope in the styles 403 message", 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("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..c2761f9338 100644 --- a/packages/core/src/figma/client.ts +++ b/packages/core/src/figma/client.ts @@ -89,6 +89,32 @@ 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; + +/** Parse a Retry-After header (figma sends integer seconds; the HTTP spec + * also allows a date) into ms, or null when absent/unparseable. */ +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.max(0, secs * 1000); + const date = Date.parse(raw); + return Number.isNaN(date) ? null : Math.max(0, date - Date.now()); } function requireNodeId(ref: FigmaRef): string { @@ -125,6 +151,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,41 +162,66 @@ 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 }, - }); + 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; + } + + /** Throw the typed error for a non-ok response (no-op when res.ok). */ + function throwForStatus(res: Response, path: string, opts: GetOptions): void { + 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 && enterpriseGated) + if (res.status === 403 && opts.enterpriseGated) throw new FigmaClientError( "REQUIRES_ENTERPRISE", "figma variables require an Enterprise plan (403) — fall back to styles", 403, ); - if (res.status === 403) + if (res.status === 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."; 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.", + `figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`, 403, ); + } 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); + } + throwForStatus(res, path, opts); return res.json(); } @@ -178,7 +230,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { 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 body = await get(`/v1/images/${ref.fileKey}?${params}`, { + scopeHint: SCOPE_HINTS.fileContent, + }); const images = isRecord(body) && isRecord(body.images) ? body.images : {}; const url = images[nodeId]; if (typeof url !== "string" || url === "") @@ -190,7 +244,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { }, 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 +255,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 +268,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 +285,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 +302,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/skills-manifest.json b/skills-manifest.json index 420885a7b1..78035f0cf3 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 18 }, "figma": { - "hash": "c2929c6cc7ca35b3", + "hash": "903f643bffefa5ce", "files": 2 }, "general-video": { diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index c0ceb4cb80..081a8bb292 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; 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. From 4fc699fee6aa124717d899b7675a591f01325d4e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 15:14:57 -0700 Subject: [PATCH 2/9] fix(core,cli): parse figma 403 body, batch asset fetch, fix NO_TOKEN box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the scope+retry work from the figma bug-bash (valid report: 9-bugs-with-repros; the skill-not-used report was discarded). - 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN with re-mint advice; a scope body surfaces figma's own diagnosis verbatim; else falls back to the endpoint's scope hint. Reads both err and message (variables endpoint uses message). One fix, honest messages for bugs 1/4/9. - Batch asset fetch (requested): figma asset accepts multiple refs (space-separated or comma-joined) of one file and renders them in a SINGLE /v1/images call via new client.renderNodes — figma's documented per-minute rate-limit workaround. runAssetImport delegates to runAssetImportMany; cache-checks per node, batches only the misses, one index.md regen. - NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling the numbered setup list. Indent every line; single-line hints unchanged. Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not scope advice. Client suite 22, cli figma 33. Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/figma/asset.test.ts | 51 ++++- packages/cli/src/commands/figma/asset.ts | 203 +++++++++++++----- .../cli/src/commands/figma/component.test.ts | 14 ++ packages/cli/src/ui/format.ts | 11 +- packages/core/src/figma/client.test.ts | 73 ++++++- packages/core/src/figma/client.ts | 122 ++++++++--- skills-manifest.json | 2 +- skills/figma/SKILL.md | 4 +- 8 files changed, 398 insertions(+), 82 deletions(-) diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts index c3e25a340b..13947b2b00 100644 --- a/packages/cli/src/commands/figma/asset.test.ts +++ b/packages/cli/src/commands/figma/asset.test.ts @@ -3,7 +3,7 @@ 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 { runAssetImport, runAssetImportMany, type AssetImportDeps } from "./asset.js"; import type { FigmaClient } from "@hyperframes/core/figma"; const dirs: string[] = []; @@ -17,8 +17,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 +27,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 +148,39 @@ 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("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..9da7bc33b3 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,68 @@ export interface AssetImportResult { reused: boolean; } -export async function runAssetImport( - refInput: string, - opts: AssetImportOptions, - deps: AssetImportDeps, -): Promise { +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 +119,102 @@ 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] : [])); + 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, + ); + } + } + 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 +242,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 +267,18 @@ 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]; + const refs = positionals + .flatMap((r) => String(r).split(",")) + .map((r) => r.trim()) + .filter((r) => r.length > 0); + const results = await runAssetImportMany( + refs, { format: parseFormat(args.format), scale: args.scale !== undefined ? Number(args.scale) : undefined, @@ -193,11 +287,18 @@ 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) console.log(`(${results.length} nodes in 1 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/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 3db4edb461..6bf5c7dd1c 100644 --- a/packages/core/src/figma/client.test.ts +++ b/packages/core/src/figma/client.test.ts @@ -175,7 +175,7 @@ describe("error mapping", () => { expect(waits).toEqual([5000]); }); - it("names the library_content scope in the styles 403 message", async () => { + 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, @@ -188,6 +188,77 @@ describe("error mapping", () => { ); }); + 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 c2761f9338..35731ea63f 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; @@ -117,6 +133,29 @@ function retryAfterMs(res: Response): number | null { return Number.isNaN(date) ? null : 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 { if (!ref.nodeId) throw new Error(`figma ref ${ref.fileKey} has no nodeId`); return ref.nodeId; @@ -172,31 +211,50 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { scopeHint?: string; } - /** Throw the typed error for a non-ok response (no-op when res.ok). */ - function throwForStatus(res: Response, path: string, opts: GetOptions): void { - if (res.ok) return; - if (res.status === 401) + /** 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 { + if (body && /invalid token/i.test(body)) 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, + "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 && opts.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) { - 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."; - throw new FigmaClientError( + if (body && /scope/i.test(body)) + return new FigmaClientError( "FORBIDDEN", - `figma denied access (403). ${scopeLine} Also confirm 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", @@ -221,26 +279,40 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt; await sleep(wait); } - throwForStatus(res, path, opts); + 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}`, { - scopeHint: SCOPE_HINTS.fileContent, - }); - 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) { diff --git a/skills-manifest.json b/skills-manifest.json index 78035f0cf3..427739bcd3 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 18 }, "figma": { - "hash": "903f643bffefa5ce", + "hash": "5581f824cc5deecc", "files": 2 }, "general-video": { diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index 081a8bb292..e5c7e14626 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -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 From 1d18b30625ffd9ee4a0fbd5875ebb21fa2878a4f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 15:22:56 -0700 Subject: [PATCH 3/9] test(cli): add renderNodes to tokens.test figma client mock CI typecheck caught the tokens.test mock missing the new renderNodes member on FigmaClient (asset/component mocks were updated, this one was missed). Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/figma/tokens.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/commands/figma/tokens.test.ts b/packages/cli/src/commands/figma/tokens.test.ts index 760d4d89cc..f43943432b 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({ From 87e2a70f9a9fe4bba2b3bee8cd8a27b4c303f00a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 15:31:11 -0700 Subject: [PATCH 4/9] =?UTF-8?q?fix(core,cli):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20consistent=20403=20error=20shape,=20cap=20Retry-Aft?= =?UTF-8?q?er,=20URL-safe=20ref=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rames's inline findings on #2112: - forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws inside) so the caller's single throw covers all cases — no mixed throw/return contract for a future wrapping caller. - retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer silently blocks the CLI for an hour before RATE_LIMITED. - asset ref gathering extracted to gatherAssetRefs() and made URL-safe: bare fileKey:nodeId tokens comma-split, but a figma URL with commas in its query (multi-select node-id=1:2,3:4) is kept whole. - Documented in SKILL that 429 retry lives in the shared request path, so EVERY read endpoint retries (not just asset) — blast-radius note the reviewer asked for. variables intentionally still retries: its fallback is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate. Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs URL-vs-bare split. client 24, cli asset 11. Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/figma/asset.test.ts | 19 ++++++++- packages/cli/src/commands/figma/asset.ts | 24 ++++++++--- packages/core/src/figma/client.test.ts | 41 +++++++++++++++++++ packages/core/src/figma/client.ts | 19 +++++++-- skills-manifest.json | 2 +- skills/figma/SKILL.md | 2 +- 6 files changed, 94 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts index 13947b2b00..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, runAssetImportMany, type AssetImportDeps } from "./asset.js"; +import { + gatherAssetRefs, + runAssetImport, + runAssetImportMany, + type AssetImportDeps, +} from "./asset.js"; import type { FigmaClient } from "@hyperframes/core/figma"; const dirs: string[] = []; @@ -174,6 +179,18 @@ describe("runAssetImport", () => { 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( diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 9da7bc33b3..8e3465bea1 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -50,6 +50,20 @@ export interface AssetImportResult { reused: boolean; } +/** + * 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) @@ -271,12 +285,10 @@ export default defineCommand({ // 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]; - const refs = positionals - .flatMap((r) => String(r).split(",")) - .map((r) => r.trim()) - .filter((r) => r.length > 0); + const positionals = ( + Array.isArray(args._) && args._.length > 0 ? (args._ as string[]) : [args.ref] + ).map(String); + const refs = gatherAssetRefs(positionals); const results = await runAssetImportMany( refs, { diff --git a/packages/core/src/figma/client.test.ts b/packages/core/src/figma/client.test.ts index 6bf5c7dd1c..3af01728a6 100644 --- a/packages/core/src/figma/client.test.ts +++ b/packages/core/src/figma/client.test.ts @@ -153,6 +153,47 @@ describe("error mapping", () => { 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[] = []; diff --git a/packages/core/src/figma/client.ts b/packages/core/src/figma/client.ts index 35731ea63f..29efadd6dc 100644 --- a/packages/core/src/figma/client.ts +++ b/packages/core/src/figma/client.ts @@ -122,15 +122,23 @@ const SCOPE_HINTS = { 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, or null when absent/unparseable. */ + * 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.max(0, secs * 1000); + if (Number.isFinite(secs)) return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, secs * 1000)); const date = Date.parse(raw); - return Number.isNaN(date) ? null : Math.max(0, date - Date.now()); + 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): @@ -217,8 +225,11 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { * 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)) - throw new FigmaClientError( + return new FigmaClientError( "BAD_TOKEN", "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, diff --git a/skills-manifest.json b/skills-manifest.json index 427739bcd3..b843af82f8 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 18 }, "figma": { - "hash": "5581f824cc5deecc", + "hash": "99538ee56a4ca553", "files": 2 }, "general-video": { diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index e5c7e14626..b08f771743 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -31,7 +31,7 @@ While onboarding, also set expectations in one breath: every import lands as a * - **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) → 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; if it still surfaces, wait a minute or import fewer nodes per call. +- `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. From 43af5825342a43653ad39c5fadaf51914c0323d6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 16:17:39 -0700 Subject: [PATCH 5/9] docs(figma): sync guide scopes + error table; gate batch line, regen index in finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @miguel-heygen's review on #2112: - BLOCKER: docs/guides/figma.mdx now matches the shipped code/skill — adds the Library content: Read-only scope row (+ corrects the 'falls back, expected' line that was false without it), and the troubleshooting table now says bad PATs surface as 403 Invalid token (not 401), names the scope in FORBIDDEN, and documents RATE_LIMITED retry. - nit: the batch summary line no longer claims '1 figma request' when every node was a cache hit — says 'all reused from cache — no figma request'. - nit: index.md regen moved to a finally, so a mid-batch RENDER_FAILED leaves index.md consistent with the nodes that did freeze. Co-Authored-By: Claude Fable 5 --- docs/guides/figma.mdx | 10 ++-- packages/cli/src/commands/figma/asset.ts | 65 ++++++++++++++---------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx index c10dc4fdfd..7f888484fa 100644 --- a/docs/guides/figma.mdx +++ b/docs/guides/figma.mdx @@ -33,9 +33,10 @@ The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environmen | --- | --- | --- | | File content | Read-only | assets, components | | File metadata | Read-only | version tracking, refresh | + | Library content | Read-only | the `tokens` published-styles fallback | | 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. + No Enterprise plan? `tokens` falls back to your published styles automatically — but that fallback reads `/v1/files/:key/styles`, which needs the **Library content: Read-only** scope. Without it the command 403s with Figma's own diagnosis (*"requires the library_content:read scope"*); add the scope and re-run. The **Variables** scope stays optional (Enterprise-only). ```bash @@ -109,9 +110,10 @@ 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 | +| `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 | | `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/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 8e3465bea1..d1a307ea37 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -195,34 +195,40 @@ export async function runAssetImportMany( reuseExisting(fileKey, r.nodeId, opts, version, deps, description, entity), ); const missIndexes = slots.flatMap((s, i) => (s === null ? [i] : [])); - 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}`, + 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, ); - 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); } - safeRegenerateIndex(deps.projectDir); return slots.map((s, i) => { if (!s) throw new Error(`figma asset import produced no result for "${refInputs[i]}"`); return s; @@ -304,7 +310,14 @@ export default defineCommand({ console.log(`${verb} ${result.record.id} → ${result.record.path}`); console.log(result.snippet.html); } - if (results.length > 1) console.log(`(${results.length} nodes in 1 figma request)`); + 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", From c8eff1a4ba5b25b2c821354cae51bbf12d180244 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 19:23:19 -0700 Subject: [PATCH 6/9] fix(core,cli): figma IMAGE fills dropped, rasterize double-paint, tokens false-success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodeToHtml routed rasterize eligibility off node.type alone, so a RECTANGLE/FRAME with an IMAGE fill fell through to the generic
path — fillCss() has no IMAGE case, so it rendered an empty box. IMAGE-filled nodes now route to rasterize like vectors, regardless of node.type. Rasterized nodes (vectors, now image fills too) were also getting their own fill/corner-radius CSS applied on top of the already- rendered — a flat color block behind/around the real art, flattening non-rectangular shapes into rounded rects. decorationCss now skips background and corner-radius/clip for rasterized nodes; opacity and effects still apply since those aren't baked into the export. tokens.ts's styles-fallback path hardcoded entries: [] regardless of how many published styles were actually found, so the CLI printed "recorded published style metadata instead" even when styles() returned zero results. Added styleCount to the result so the message reflects what happened, and points at the MCP get_variable_defs fallback when there's nothing to fall back to. Co-Authored-By: Claude Opus --- .../cli/src/commands/figma/tokens.test.ts | 12 ++++ packages/cli/src/commands/figma/tokens.ts | 10 ++- packages/core/src/figma/nodeToHtml.test.ts | 41 ++++++++++++ packages/core/src/figma/nodeToHtml.ts | 63 +++++++++++++------ 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/figma/tokens.test.ts b/packages/cli/src/commands/figma/tokens.test.ts index f43943432b..8f7f1c6e9e 100644 --- a/packages/cli/src/commands/figma/tokens.test.ts +++ b/packages/cli/src/commands/figma/tokens.test.ts @@ -57,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/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)}`; } From d13c96d4704dc125d577c9612549f1e290b928f9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 19:35:22 -0700 Subject: [PATCH 7/9] fix(skills): force figma.com sources through /figma, not raw MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real incidents this week had agents skip /figma entirely and drive Figma via raw MCP tools (get_metadata/get_screenshot/get_design_context) when a figma.com URL landed inside a creation-workflow skill. Root cause: none of the creation workflows mention Figma at all, and the only routing table that does (/hyperframes) is skipped whenever a workflow is invoked directly rather than through the entry router — which is the common path. Going raw loses real infrastructure the CLI/skill guarantees: sanitizeSvg() before freezing (raw-fetched SVGs are unsanitized), .media/manifest.jsonl provenance (no cache-hit, no version tracking), and brand-token var() binding (colors bake as literals, so a later Figma brand change can't propagate without a full re-import). Added a "figma source" callout to every creation workflow that could plausibly receive a figma.com link (product-launch-video, website-to-video, general-video, motion-graphics, slideshow), plus a defense-in-depth line in /hyperframes's own routing checklist. The fix lives in the workflows themselves so it doesn't depend on the entry router being consulted. Co-Authored-By: Claude Opus --- skills-manifest.json | 12 ++++++------ skills/general-video/SKILL.md | 2 ++ skills/hyperframes/SKILL.md | 1 + skills/motion-graphics/SKILL.md | 2 ++ skills/product-launch-video/SKILL.md | 2 ++ skills/slideshow/SKILL.md | 2 ++ skills/website-to-video/SKILL.md | 2 ++ 7 files changed, 17 insertions(+), 6 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index b843af82f8..3cf1b90554 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -14,11 +14,11 @@ "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/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. From 49a78a30e49e83504dba6e11e2f9d15c50bb8e84 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 19:39:10 -0700 Subject: [PATCH 8/9] docs(figma): remove stale duplicate RATE_LIMITED troubleshooting row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Miga's re-review caught it: the new RATE_LIMITED row (client auto- retries with backoff) landed alongside the old pre-retry row ("wait a minute and retry; chunk batch renders"), leaving two rows for the same code — one accurate, one stale. Keep only the current one. Co-Authored-By: Claude Opus --- docs/guides/figma.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx index 7f888484fa..e658988505 100644 --- a/docs/guides/figma.mdx +++ b/docs/guides/figma.mdx @@ -114,6 +114,5 @@ Every import records where it came from (`fileKey`, `nodeId`, `version`) in `.me | `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 | -| `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) | From ffac2ecbfe542054648338098f1a2f9df5c4f8fb Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 19:41:43 -0700 Subject: [PATCH 9/9] docs(figma): lead setup with "which credential do I need", not a token wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old setup section was one long token-minting flow with the MCP connector as an afterthought — but motion/shaders/storyboards need only the connector (no token at all), and even brand tokens are easier via MCP on any non-Enterprise plan. A reader wanting only motion had to wade past REST scope tables meant for a different path. Restructured into a decision table (what you want → which credential) followed by two equal, independent steps. Step A's scope list now states the 3 boxes to check on a normal (non-Enterprise) account up front, instead of a generic 4-scope table the reader has to interpret themselves. Step B now states the MCP connector's actual capability (variable reads work on any plan, rate-limited by tier — 6 calls/month on Starter) instead of "no scopes to configure," which undersold what it can do. Co-Authored-By: Claude Opus --- docs/guides/figma.mdx | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx index e658988505..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,23 +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 | - | Library content | Read-only | the `tokens` published-styles fallback | - | 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? `tokens` falls back to your published styles automatically — but that fallback reads `/v1/files/:key/styles`, which needs the **Library content: Read-only** scope. Without it the command 403s with Figma's own diagnosis (*"requires the library_content:read scope"*); add the scope and re-run. The **Variables** scope stays optional (Enterprise-only). + 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 @@ -47,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