diff --git a/CLAUDE.md b/CLAUDE.md
index 6a54fd36..5fb05328 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,6 +16,10 @@ CLI that controls Figma Desktop directly. No API key needed.
| "create a rectangle/frame" | `figma-cli render ' ...'` |
| "convert to component" | `figma-cli node to-component "ID"` |
| "use the existing X component" / "don't rebuild, instance it" | `figma-cli instantiate "X"` |
+| "put a Button / any existing component into this frame" | ` ` inside `render` |
+| "build it on page X" | `figma-cli render --page "X" ' …'` (creates the page when missing) |
+| "make a component set with these variants" | `figma-cli render-batch '[" ", …]' --variant-set Button` |
+| "use text style / effect style X" | ``, ` ` |
| "what component already exists for X" | `figma-cli spec X` (shows the reuse handle) |
| "list variables" | `figma-cli var list` |
| "find nodes named X" | `figma-cli find "X"` |
@@ -75,6 +79,24 @@ CLI that controls Figma Desktop directly. No API key needed.
---
+## Agent efficiency
+
+Every tool call is a full agent turn (~21k tokens of context re-read), so the
+CLI is built to finish a task in ONE call and to answer without a readback:
+
+- `eval` has helpers that absorb Figma pitfalls: `$page`, `$var`, `$bind`,
+ `$style`, `$component`, `$instance`, `$fontSafe`, `$describe`
+ (src/lib/figma-helpers.js, shared with render).
+- Fonts that are not installed are swapped to Inter and
+ reported as `note:` lines. Without that, Figma refuses to relabel an instance
+ or even appendChild it.
+- `render` prints the structure it built (sizes, layout, padding, bindings,
+ which component each instance uses) so the agent does not read it back.
+- Piped `eval` output is compact JSON, capped at 20k characters.
+- The user-facing agent rules live in src/lib/agent-rules.js (hashed marker
+ block). `init-agent` writes them, `connect` refreshes outdated copies.
+- Before a release: `FIGMA_FILE="" node tests/live/regression.mjs`.
+
## Key Rules
1. **Always use `render` for frames** - has smart positioning
diff --git a/REFERENCE.md b/REFERENCE.md
index 822832f7..92688424 100644
--- a/REFERENCE.md
+++ b/REFERENCE.md
@@ -327,8 +327,48 @@ node src/index.js eval --file /tmp/script.js
node src/index.js run /tmp/script.js
```
+## Eval Helpers
+
+Available inside every `figma-cli eval` (and used by `render`). They install on
+first use and handle lazy pages, missing fonts and name lookups:
+
+```js
+const p = await $page('Scratch'); // switch to page, create if missing
+const v = await $var('bgColor/muted'); // local variable by full name
+await $bind(frame, 'bgColor/muted'); // bind to fills ('strokes' or a field as 3rd arg)
+const s = await $style('Body/Medium'); // any local style by name
+const set = await $component('Button'); // component or set by name/id, whole file
+const i = await $instance('Button', 'variant=danger, size=large', { text: 'Delete', parent: frame });
+await $fontSafe(node); // load fonts, swap missing ones to Inter
+return await $describe(node); // compact one-line-per-node summary
+```
+
+Piped output (an agent calling the CLI) is compact JSON and capped at 20,000
+characters; a terminal still gets indented JSON.
+
## Render JSX Syntax
+**Pages and design-system hooks**
+
+```bash
+figma-cli render --page "Scratch" ' ' # render-batch takes --page too
+```
+```jsx
+ // any page, font-safe
+ ...
+... // style's font missing: size/spacing kept on the fallback font
+ // per-side padding, root frames included
+```
+
+**Component sets in one call**
+
+```bash
+figma-cli render-batch '[" ...", ...]' --variant-set Button
+```
+Frame names must be `prop=value, prop=value` with the same properties on every
+frame; the command validates names before touching the file.
+
+
**Elements:** ` `, ``, ``, ``, ``, ``, ``, ``
**Size:** `w={320} h={200}`, `w="fill"`, `minW={100} maxW={500}`
diff --git a/src/commands/export-eval.js b/src/commands/export-eval.js
index 3fc281ea..e00e904c 100644
--- a/src/commands/export-eval.js
+++ b/src/commands/export-eval.js
@@ -2,6 +2,7 @@
import chalk from 'chalk';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
+import { HELPERS_SOURCE, RESET_NOTES, READ_NOTES, usesHelpers } from '../lib/figma-helpers.js';
import {
program,
checkConnection,
@@ -299,6 +300,33 @@ program
// ============ EVAL ============
+// Agents read eval output through a pipe and pay for every indent as tokens, so
+// print compact JSON there and keep the indented form for a human terminal.
+// A result that dumps a whole file (thousands of nodes) costs an agent more
+// tokens than the task itself, so piped output is capped with a hint.
+const PIPED_OUTPUT_CAP = 20000;
+function formatResult(result) {
+ const text = typeof result !== 'object' ? String(result)
+ : process.stdout.isTTY ? JSON.stringify(result, null, 2) : JSON.stringify(result);
+ if (process.stdout.isTTY || text.length <= PIPED_OUTPUT_CAP) return text;
+ return text.slice(0, PIPED_OUTPUT_CAP) +
+ `\n… output cut at ${PIPED_OUTPUT_CAP} of ${text.length} characters. Return counts, a filtered list or .slice(0, N) instead.`;
+}
+
+// $page / $var / $bind / $instance / ... live on globalThis in Figma. Install
+// them first when the code uses one; the install is a no-op when present.
+async function ensureHelpers(code, run) {
+ if (!usesHelpers(code)) return;
+ await run(HELPERS_SOURCE + '\n' + RESET_NOTES + '\nreturn true;');
+}
+async function printHelperNotes(code, run) {
+ if (!usesHelpers(code)) return;
+ try {
+ const notes = await run(READ_NOTES);
+ for (const n of notes || []) console.error(chalk.yellow('note: ' + n));
+ } catch {}
+}
+
program
.command('eval [code]')
.description('Execute JavaScript in Figma plugin context')
@@ -324,10 +352,12 @@ program
// Always prefer async daemon (more reliable, no shell timeout issues)
if (isDaemonRunning()) {
try {
+ await ensureHelpers(jsCode, c => daemonExec('eval', { code: c }));
const result = await daemonExec('eval', { code: jsCode });
if (result !== undefined && result !== null) {
- console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result);
+ console.log(formatResult(result));
}
+ await printHelperNotes(jsCode, c => daemonExec('eval', { code: c }));
return;
} catch (e) {
// Check if this is a connection/daemon error vs user code error
@@ -349,9 +379,10 @@ program
// Sync fallback (when daemon not running)
try {
+ await ensureHelpers(jsCode, async c => figmaEvalSync(c));
const result = figmaEvalSync(jsCode);
if (result !== undefined && result !== null) {
- console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result);
+ console.log(formatResult(result));
}
} catch (error) {
console.log(chalk.red('✗ ' + error.message));
@@ -374,7 +405,7 @@ program
if (isDaemonRunning()) {
const result = await daemonExec('eval', { code });
if (result !== undefined) {
- console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result);
+ console.log(formatResult(result));
}
} else {
// Fallback to sync path
diff --git a/src/commands/init.js b/src/commands/init.js
index b4869fb6..12a6b6c1 100644
--- a/src/commands/init.js
+++ b/src/commands/init.js
@@ -1,79 +1,14 @@
-// Command: init — scaffold agent guidance into a designer's project so figma-cli
-// "just works" with whichever AI coding tool they use (Claude Code or Cursor).
-// Writes the SAME condensed usage ruleset to:
+// Command: init-agent — scaffold agent guidance into a designer's project so
+// figma-cli "just works" with whichever AI coding tool they use.
+// Writes the SAME ruleset to:
// - .cursor/rules/figma-cli.mdc (Cursor)
// - AGENTS.md (Claude Code, Cursor, Codex all read it)
-// The CLI binary needs nothing else — it controls Figma Desktop directly, so it
-// already runs in any terminal. This just teaches the agent HOW to drive it.
+// The rules and the update logic live in lib/agent-rules.js, because `connect`
+// also refreshes outdated copies.
import chalk from 'chalk';
-import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
-import { join, dirname } from 'path';
+import { join } from 'path';
import { program } from '../lib/cli-core.js';
-
-// The shared, designer-facing usage rules. Kept tight on purpose — an agent
-// needs the operating rules, not the CLI's internals.
-const RULES_BODY = `# Using figma-cli
-
-figma-cli controls **Figma Desktop** directly (no API key). It runs in any
-terminal. Open Figma Desktop, then \`figma-cli connect\` once per session.
-
-## Golden rules
-1. **Create frames with \`render\` / \`render-batch\`** — they have smart positioning.
- NEVER use \`eval\` to create visual nodes (no positioning, bypasses guards).
-2. **"N buttons/cards" = N separate top-level nodes**, not one wrapper frame
- containing N children. Use \`render-batch '[...]'\` or \`shadcn add --count N\`.
-3. **Never delete the user's existing nodes.**
-4. After creating, **verify**: \`figma-cli verify "" --measure\` (returns a
- screenshot + real w/h so you catch size bugs by numbers, not by eye).
-
-## Design tokens / variables
-- Bind colors at creation with \`var:name\`, never raw hex when a system is loaded:
- \`Go \`
-- Pin a named collection when the user names one: \`render-batch ... --collection figma\`.
-- Import a system: \`figma-cli import tailwind.config.js | globals.css | tokens.json\`.
-- Export the open file's system: \`figma-cli extract\` → DESIGN.md.
-
-## JSX cheatsheet (render)
-- Layout: \`flex="row|col" gap={16} p={24} px py pt pr pb pl justify="center|between" items="center"\`
-- Size: \`w={320} h={200} w="fill" w="hug" w="60%"\` (percent resolves vs parent)
-- Look: \`bg="#fff" stroke="#000" strokeWidth={2} rounded={12} shadow="..." opacity={0.8}\`
-- Text: \`\`
-- Icons (real SVG, never emojis): \` \`
-- Dividers: a thin child (\` \`) auto-fills the cross axis.
-
-## Text wrapping (most common bug)
-For text to wrap, the parent AND every \`\` need \`w="fill"\`, and the parent
-needs \`flex="col"\` or \`flex="row"\`.
-
-## Recreating a component from an extracted DESIGN.md (hard rule)
-Don't read the structure markdown by hand. Use:
-- \`figma-cli spec \` → authoritative variant axes + sample size (compact).
- Build EXACTLY to those axes (e.g. Variant × Size = a Component Set, not one node).
-- \`figma-cli spec --check \` → enforces it (exit 1 on mismatch:
- wrong structure, missing axes, wrong height). Treat non-zero as "not done".
-
-## Handy commands
-\`\`\`
-figma-cli connect # connect to Figma Desktop (yolo)
-figma-cli render ' ...' # one frame
-figma-cli render-batch '[ " ", ... ]' --direction row
-figma-cli shadcn add button --count 3 # N distinct shadcn primitives
-figma-cli node to-component "" # promote to a component
-figma-cli verify "" --measure # screenshot + dimensions
-figma-cli a11y audit # contrast / touch / text checks
-\`\`\`
-`;
-
-function writeFile(path, content, force) {
- if (existsSync(path) && !force) {
- const existing = readFileSync(path, 'utf8');
- if (existing.includes('# Using figma-cli')) return { path, status: 'up-to-date' };
- return { path, status: 'exists' }; // don't clobber unrelated content
- }
- mkdirSync(dirname(path), { recursive: true });
- writeFileSync(path, content);
- return { path, status: 'written' };
-}
+import { RULE_FILES, syncRulesFile } from '../lib/agent-rules.js';
program
.command('init-agent')
@@ -85,18 +20,15 @@ program
const cwd = process.cwd();
const results = [];
- if (tool === 'cursor' || tool === 'both') {
- const mdc = `---\ndescription: How to drive figma-cli (controls Figma Desktop) from this project\nalwaysApply: true\n---\n\n${RULES_BODY}`;
- results.push(writeFile(join(cwd, '.cursor', 'rules', 'figma-cli.mdc'), mdc, options.force));
- }
- if (tool === 'claude' || tool === 'both') {
- // AGENTS.md is read by Claude Code, Cursor and Codex — one file, all tools.
- results.push(writeFile(join(cwd, 'AGENTS.md'), RULES_BODY, options.force));
+ for (const f of RULE_FILES) {
+ if (tool !== 'both' && tool !== f.tool) continue;
+ results.push(syncRulesFile(join(cwd, f.rel), f.fresh, { force: options.force }));
}
for (const r of results) {
const rel = r.path.replace(cwd + '/', '');
if (r.status === 'written') console.log(chalk.green('✓ wrote'), rel);
+ else if (r.status === 'updated') console.log(chalk.green('✓ updated'), rel);
else if (r.status === 'up-to-date') console.log(chalk.gray('• up-to-date'), rel);
else console.log(chalk.yellow('• exists (use --force to overwrite)'), rel);
}
diff --git a/src/commands/instantiate.js b/src/commands/instantiate.js
index 4b80c2d6..5356d7e0 100644
--- a/src/commands/instantiate.js
+++ b/src/commands/instantiate.js
@@ -10,15 +10,30 @@ import { resolveInstancePlan } from '../lib/instance-plan.js';
// Build the async, dynamic-page-safe eval that tries each plan step in order.
// Exported for unit testing. A COMPONENT_SET resolves to its default variant
// (a set has no createInstance). First success wins; failures are collected.
-export function instantiateCode(plan) {
+//
+// Every step is time-bounded. `importComponentByKeyAsync` on a key that was
+// never published NEVER SETTLES — it neither resolves nor rejects (measured:
+// still pending after 2 minutes). Unbounded, the whole command hangs and the
+// daemon wedges behind it, when the correct behaviour is to fall through to the
+// node-id step, which is exactly the case for a component in the OPEN file that
+// is not published to a library.
+export const STEP_TIMEOUT_MS = 5000;
+
+export function instantiateCode(plan, { timeoutMs = STEP_TIMEOUT_MS } = {}) {
return `(async () => {
const plan = ${JSON.stringify(plan)};
+ const TIMEOUT = ${Number(timeoutMs)};
+ const bounded = (p, what) => Promise.race([
+ p,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error(what + ' did not answer within ' + TIMEOUT + 'ms')), TIMEOUT)),
+ ]);
const tried = [];
for (const step of plan) {
try {
let comp;
- if (step.via === 'key') comp = await figma.importComponentByKeyAsync(step.key);
- else comp = await figma.getNodeByIdAsync(step.id);
+ if (step.via === 'key') comp = await bounded(figma.importComponentByKeyAsync(step.key), 'library import');
+ else comp = await bounded(figma.getNodeByIdAsync(step.id), 'node lookup');
if (!comp) { tried.push(step.via + ': not found'); continue; }
if (comp.type === 'COMPONENT_SET') comp = comp.defaultVariant || comp.children[0];
if (!comp || comp.type !== 'COMPONENT') { tried.push(step.via + ': not a component'); continue; }
diff --git a/src/commands/render.js b/src/commands/render.js
index 92d567d2..59987791 100644
--- a/src/commands/render.js
+++ b/src/commands/render.js
@@ -55,6 +55,27 @@ function printUnresolvedVars(unresolved) {
* with no error anywhere. Saying it out loud is the difference between a
* two-second fix and re-deriving the same auto-layout bug from scratch.
*/
+// Notes from the in-Figma helpers: a missing font replaced, a style or
+// variant that was not found. One line each, so an agent does not have to
+// rediscover them by inspecting the result.
+// The structure render actually built (layout, sizes, bindings, instances), so
+// an agent can confirm the result without a second call to read it back.
+function printSummary(summary, fallbackName) {
+ if (summary) { for (const line of String(summary).split('\n')) console.log(' ' + line); }
+ else if (fallbackName) console.log(chalk.gray(' name: ' + fallbackName));
+}
+
+function printNotes(notes) {
+ for (const n of notes || []) console.log(chalk.yellow('note: ' + n));
+}
+
+// --page: switch to the page (creating it when missing) before rendering.
+async function switchToPage(name) {
+ if (!name) return;
+ const { HELPERS_SOURCE } = await import('../lib/figma-helpers.js');
+ await daemonExec('eval', { code: `(async () => { ${HELPERS_SOURCE}\n const p = await globalThis.__figHelpers.page(${JSON.stringify(String(name))}); return p.id; })()` });
+}
+
function printLayoutWarnings(warnings) {
if (!warnings || warnings.length === 0) return;
console.log(chalk.yellow(`\n⚠ ${warnings.length} auto-layout problem(s):`));
@@ -62,6 +83,53 @@ function printLayoutWarnings(warnings) {
console.log(chalk.gray(' Fix: give the parent a fixed size on that axis, or drop the fill from the child.'));
}
+// --variant-set: frames -> components -> one component set, in the same call.
+// Without it an agent needed render-batch --as-component plus
+// `variants from --multi`, and usually a --help round to find the second one.
+// Names are validated first so Figma never invents "Property 1" axes.
+async function combineIntoVariantSet(ids, setName) {
+ const { HELPERS_SOURCE } = await import('../lib/figma-helpers.js');
+ const r = await daemonExec('eval', { code: `(async () => {
+ ${HELPERS_SOURCE}
+ const nodes = [];
+ for (const id of ${JSON.stringify(ids)}) { const n = await figma.getNodeByIdAsync(id); if (n) nodes.push(n); }
+ const parse = name => { const o = {}; for (const part of name.split(',')) { const i = part.indexOf('='); if (i <= 0) return null; o[part.slice(0, i).trim()] = part.slice(i + 1).trim(); } return o; };
+ const props = nodes.map(n => parse(n.name));
+ const bad = nodes.filter((n, i) => !props[i]).map(n => n.name);
+ if (bad.length) throw new Error('Name every frame by its variant, e.g. name="size=small, state=hover". Not variant names: ' + bad.join(' | '));
+ const axes = Object.keys(props[0]).sort().join(',');
+ const off = nodes.filter((n, i) => Object.keys(props[i]).sort().join(',') !== axes).map(n => n.name);
+ if (off.length) throw new Error('All variants need the same properties (' + axes + '). Different: ' + off.join(' | '));
+ const keys = props.map(p => Object.keys(p).sort().map(k => k + '=' + p[k]).join(', '));
+ const dup = keys.filter((k, i) => keys.indexOf(k) !== i);
+ if (dup.length) throw new Error('Duplicate variants: ' + [...new Set(dup)].join(' | '));
+ const parent = nodes[0].parent, x = Math.min(...nodes.map(n => n.x)), y = Math.min(...nodes.map(n => n.y));
+ const comps = nodes.map(n => n.type === 'COMPONENT' ? n : figma.createComponentFromNode(n));
+ const set = figma.combineAsVariants(comps, parent);
+ set.name = ${JSON.stringify(String(setName))};
+ // grid: one row per value of the first axis, 24px gaps, 24px padding
+ const first = Object.keys(props[0])[0];
+ const rows = [...new Set(comps.map(c => c.variantProperties[first]))];
+ let yy = 24;
+ for (const val of rows) {
+ const row = comps.filter(c => c.variantProperties[first] === val);
+ let xx = 24, h = 0;
+ for (const c of row) { c.x = xx; c.y = yy; xx += c.width + 24; h = Math.max(h, c.height); }
+ yy += h + 24;
+ }
+ const w = Math.max(...comps.map(c => c.x + c.width)) + 24, hh = Math.max(...comps.map(c => c.y + c.height)) + 24;
+ set.resizeWithoutConstraints(w, hh);
+ set.x = x; set.y = y;
+ return { id: set.id, name: set.name, variants: comps.length, axes: Object.fromEntries(Object.entries(set.variantGroupProperties).map(([k, v]) => [k, v.values])),
+ summary: await globalThis.__figHelpers.describe(set, 2, 60).catch(() => null) };
+ })()` });
+ console.log(chalk.green(`✓ Component set: ${r.id} (${r.name}), ${r.variants} variants`));
+ for (const [k, v] of Object.entries(r.axes || {})) console.log(chalk.gray(` ${k}: ${v.join(', ')}`));
+ printSummary(r.summary);
+ recordCreated([{ id: r.id, name: r.name }]);
+ return r;
+}
+
// Remember what the last render created so `figma-cli undo` can remove
// exactly those nodes. CLI-side state file: covers every render path
// (eval-based, daemon render, render-batch) and survives daemon restarts.
@@ -139,6 +207,7 @@ program
.command('render ')
.description('Render JSX to Figma (use --as-component to also convert result to a Figma component)')
.option('--parent ', 'Parent node ID')
+ .option('--page ', 'Render on this page (switches to it, creates it when missing)')
.option('-x ', 'X position')
.option('-y ', 'Y position')
.option('--no-smart-position', 'Disable auto-positioning')
@@ -151,6 +220,7 @@ program
const jsx = unescapeShell(rawJsx);
warnUnknownProps([jsx]);
await checkConnection();
+ await switchToPage(options.page);
// Auto-split: if the caller passed a layout-only outer Frame with N child
// Frames, treat it as render-batch. This is the canonical "N buttons / N
@@ -226,9 +296,10 @@ program
}
console.log(chalk.green('✓ Rendered: ' + result.id));
- if (result.name) console.log(chalk.gray(' name: ' + result.name));
+ printSummary(result.summary, result.name);
printUnresolvedVars(result.unresolved);
printLayoutWarnings(result.layoutWarnings);
+ printNotes(result.notes);
recordCreated([result]);
await maybeAsComponent(result.id);
@@ -257,12 +328,15 @@ program
.command('render-batch')
.description('Render multiple JSX frames in a single call (fast). Pass --as-component to promote each rendered frame to a Figma Component.')
.argument('', 'JSON array of JSX strings, e.g. \'[" ..."," ..."]\'')
+ .option('--page ', 'Render on this page (switches to it, creates it when missing)')
.option('-g, --gap ', 'Gap between frames', '40')
.option('-d, --direction ', 'Layout direction: row (horizontal) or col (vertical)', 'row')
.option('--as-component', 'After rendering, convert each resulting frame to a Figma component')
+ .option('--variant-set ', 'Combine the rendered frames into ONE component set. Name each frame by its variant, e.g. name="size=small, state=hover"')
.option('-c, --collection ', 'Pin var: resolution to this variable collection (case-insensitive, fuzzy match). Per-attr `var:collection:name` overrides this.')
.option('--verify', 'After rendering, return a screenshot of each result (saves PNGs, prints JSON)')
.action(async (jsxArrayStr, options) => {
+ await switchToPage(options.page);
await checkConnection();
try {
const jsxArray = JSON.parse(jsxArrayStr);
@@ -284,22 +358,29 @@ program
// Unwrap the wrapped form returned when there are warnings to report.
let unresolvedVars = null;
let layoutWarnings = null;
+ let batchNotes = null;
if (results && !Array.isArray(results) && Array.isArray(results.frames)) {
unresolvedVars = results.unresolved;
layoutWarnings = results.layoutWarnings;
+ batchNotes = results.notes;
results = results.frames;
}
if (Array.isArray(results)) {
results.forEach(r => {
console.log(chalk.green('✓ Rendered: ' + r.id + (r.name ? ' (' + r.name + ')' : '')));
+ // with --variant-set the set is summarized once, after combining
+ if (!options.variantSet) printSummary(r.summary);
});
console.log(chalk.cyan(`\n${results.length} frames created`));
recordCreated(results);
printUnresolvedVars(unresolvedVars);
printLayoutWarnings(layoutWarnings);
+ printNotes(batchNotes);
- if (options.asComponent) {
+ if (options.variantSet) {
+ await combineIntoVariantSet(results.map(r => r.id).filter(Boolean), options.variantSet);
+ } else if (options.asComponent) {
const ids = results.map(r => r.id).filter(Boolean);
if (ids.length > 0) {
try {
diff --git a/src/commands/setup.js b/src/commands/setup.js
index 4a4757d1..bf82a298 100644
--- a/src/commands/setup.js
+++ b/src/commands/setup.js
@@ -620,6 +620,13 @@ program
const config = loadConfig();
+ // A figma-cli update also updates the agent rules: refresh AGENTS.md /
+ // .cursor rules in this project when they carry an older figma-cli block.
+ try {
+ const { refreshRules } = await import('../lib/agent-rules.js');
+ for (const r of refreshRules(process.cwd())) console.log(chalk.gray(' ✓ updated agent rules: ' + r.path.replace(process.cwd() + '/', '')));
+ } catch {}
+
// Browser Mode: CDP to a normal browser — the Figma app is never modified.
if (options.browser) {
await connectBrowser(config);
diff --git a/src/figma-client.js b/src/figma-client.js
index b91cabf0..74bbca5f 100644
--- a/src/figma-client.js
+++ b/src/figma-client.js
@@ -7,6 +7,7 @@
import WebSocket from 'ws';
import { getCdpPort } from './figma-patch.js';
+import { HELPERS_SOURCE, RESET_NOTES } from './lib/figma-helpers.js';
/**
* Visible fallback colors for shadcn semantic token names (Zinc light theme).
@@ -627,6 +628,10 @@ export class FigmaClient {
const p = props.p || props.padding || 0;
const px = props.px || p;
const py = props.py || p;
+ // pt/pr/pb/pl override one side, same as on nested frames. They used to
+ // be dropped on the root, so agents had to patch padding with eval.
+ const side = (v, d) => v !== undefined ? Number(v) : d;
+ const pt = side(props.pt, py), pb = side(props.pb, py), pl = side(props.pl, px), pr = side(props.pr, px);
const wrap = props.wrap === true || props.wrap === 'true';
const wrapGap = Number(props.wrapGap || props.rowGap || props.counterAxisSpacing || 0);
const hug = props.hug || '';
@@ -664,8 +669,10 @@ export class FigmaClient {
f${frameIdx}.layoutMode = '${flex === 'none' || flex === 'stack' || flex === 'free' ? 'NONE' : (flex === 'row' ? 'HORIZONTAL' : 'VERTICAL')}';
${flex === 'none' || flex === 'stack' || flex === 'free' ? '' : `${wrap && flex === 'row' ? `f${frameIdx}.layoutWrap = 'WRAP';` : ''}
f${frameIdx}.itemSpacing = ${itemGap};
- f${frameIdx}.paddingTop = f${frameIdx}.paddingBottom = ${py};
- f${frameIdx}.paddingLeft = f${frameIdx}.paddingRight = ${px};
+ f${frameIdx}.paddingTop = ${pt};
+ f${frameIdx}.paddingBottom = ${pb};
+ f${frameIdx}.paddingLeft = ${pl};
+ f${frameIdx}.paddingRight = ${pr};
f${frameIdx}.primaryAxisAlignItems = '${justifyVal}';
f${frameIdx}.counterAxisAlignItems = '${alignVal}';
f${frameIdx}.primaryAxisSizingMode = '${flex === 'col' ? (hugHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED') : (hugWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED')}';
@@ -684,6 +691,8 @@ export class FigmaClient {
return `
(async function() {
+ ${HELPERS_SOURCE}
+ ${RESET_NOTES}
${fontLoads}
${LAYOUT_WARN_PRELUDE}
${varLoadCode}
@@ -713,8 +722,12 @@ export class FigmaClient {
if (globalThis.__figHugFlush) globalThis.__figHugFlush();
const layoutWarnings = globalThis.__layoutWarnings || [];
globalThis.__layoutWarnings = [];
- return (unresolved.length > 0 || layoutWarnings.length > 0)
- ? { frames: results, unresolved, layoutWarnings }
+ const notes = globalThis.__figNotes ? [...globalThis.__figNotes] : [];
+ for (const r of results) {
+ try { const n = await figma.getNodeByIdAsync(r.id); if (n) r.summary = await globalThis.__figHelpers.describe(n, 2, 12); } catch (e) {}
+ }
+ return (unresolved.length > 0 || layoutWarnings.length > 0 || notes.length > 0)
+ ? { frames: results, unresolved, layoutWarnings, notes }
: results;
})()
`;
@@ -854,7 +867,7 @@ export class FigmaClient {
'justify', 'items', 'align', 'grow', 'stretch', 'hug',
'w', 'h', 'width', 'height', 'minW', 'maxW', 'minH', 'maxH',
'position', 'x', 'y', 'top', 'right', 'bottom', 'left', 'centerOffsetX', 'centerOffsetY'];
- const paint = ['bg', 'fill', 'stroke', 'strokeWidth', 'strokeAlign', 'opacity', 'blendMode',
+ const paint = ['effectStyle', 'bg', 'fill', 'stroke', 'strokeWidth', 'strokeAlign', 'opacity', 'blendMode',
'image', 'imageScale', 'visible', 'locked', 'clip', 'overflow', 'rotate'];
const corners = ['rounded', 'radius', 'roundedTL', 'roundedTR', 'roundedBL', 'roundedBR', 'cornerSmoothing'];
const effects = ['shadow', 'innerShadow', 'blur', 'bgBlur',
@@ -865,7 +878,7 @@ export class FigmaClient {
const known = {
Frame: [...layout, ...paint, ...corners, ...effects],
- Text: ['name', 'size', 'weight', 'color', 'font', 'italic', 'align', 'w', 'h', 'width', 'height',
+ Text: ['name', 'textStyle', 'size', 'weight', 'color', 'font', 'italic', 'align', 'w', 'h', 'width', 'height',
'grow', 'opacity', 'x', 'y', 'position', 'lineHeight', 'letterSpacing', 'truncate', 'maxLines'],
Icon: ['name', 'size', 's', 'color', 'c', 'x', 'y', 'position'],
Rect: ['name', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
@@ -875,7 +888,7 @@ export class FigmaClient {
Circle: null, // alias of Ellipse, filled below
Image: ['name', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
Slot: ['name', 'flex', 'gap', 'p', 'px', 'py', 'padding', 'w', 'h', 'width', 'height', 'bg', 'fill'],
- Instance: ['name', 'component', 'id', 'w', 'h', 'width', 'height'],
+ Instance: ['name', 'component', 'id', 'variant', 'text', 'w', 'h', 'width', 'height'],
};
known.Rectangle = known.Rect;
known.Circle = known.Ellipse;
@@ -1114,7 +1127,8 @@ export class FigmaClient {
}
// Parse Instance elements (self-closing) - creates component instance
- const instanceRegex = / /g;
+ // quoted values may contain '/' ("ActionList.Item/Default"), so skip over them
+ const instanceRegex = / /g;
while ((match = instanceRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
@@ -1378,6 +1392,17 @@ export class FigmaClient {
${tLetterSpacing ? `try { el${idx}.letterSpacing = ${tLetterSpacing}; } catch(e) {}` : ''}
${tAlign ? `el${idx}.textAlignHorizontal = '${tAlign}';` : ''}
el${idx}.characters = ${JSON.stringify(item.content)};
+ ${item.textStyle ? `// Named text style. When its font is not installed Figma refuses the
+ // style outright, so keep the style's metrics on the fallback font and say so.
+ try {
+ const __ts = await globalThis.__figHelpers.style(${JSON.stringify(String(item.textStyle))});
+ try { await figma.loadFontAsync(__ts.fontName); await el${idx}.setTextStyleIdAsync(__ts.id); }
+ catch (e) {
+ el${idx}.fontSize = __ts.fontSize;
+ try { el${idx}.lineHeight = __ts.lineHeight; el${idx}.letterSpacing = __ts.letterSpacing; } catch (e2) {}
+ globalThis.__figHelpers.note('text style "' + __ts.name + '" needs font "' + __ts.fontName.family + ' ' + __ts.fontName.style + '" which is not installed; applied its size and spacing with the fallback font instead');
+ }
+ } catch (e) { globalThis.__figHelpers.note(e.message); }` : ''}
${textFillCode.code}
${runStyleCode ? runStyleCode : ''}
${parentVar}.appendChild(el${idx});
@@ -1752,28 +1777,21 @@ export class FigmaClient {
${parentVar}.appendChild(el${idx});`;
}
} else if (item._type === 'instance') {
- // Component instance
- const compId = item.component || item.id;
- const compName = item.name;
-
- if (compId) {
- // Create instance by component ID
- return `
- const comp${idx} = figma.getNodeById(${JSON.stringify(compId)});
- if (comp${idx} && comp${idx}.type === 'COMPONENT') {
- const el${idx} = comp${idx}.createInstance();
- ${parentVar}.appendChild(el${idx});
- }`;
- } else if (compName) {
- // Find component by name and create instance
- return `
- const comp${idx} = figma.currentPage.findOne(n => n.type === 'COMPONENT' && n.name === ${JSON.stringify(compName)});
- if (comp${idx}) {
- const el${idx} = comp${idx}.createInstance();
- ${parentVar}.appendChild(el${idx});
- }`;
- }
- return '';
+ // Component instance by set/component name (whole document) or id.
+ // variant="size=large, variant=danger" picks the variant, text="..."
+ // relabels the first text layer. Missing fonts are swapped to Inter
+ // (otherwise Figma refuses both the relabel and the appendChild).
+ const ref = item.component || item.id || item.name;
+ if (!ref) return '';
+ const fillW = item.w === 'fill';
+ const numW = item.w !== undefined && !isNaN(Number(item.w)) ? Number(item.w) : null;
+ return `
+ __currentNode = ${JSON.stringify('Instance: ' + ref)};
+ const el${idx} = await globalThis.__figHelpers.instance(${JSON.stringify(String(ref))}, ${JSON.stringify(item.variant || '')}, {
+ ${item.text !== undefined ? `text: ${JSON.stringify(String(item.text))},` : ''} parent: ${parentVar} });
+ ${item.component && item.name ? `el${idx}.name = ${JSON.stringify(String(item.name))};` : ''}
+ ${fillW ? `try { el${idx}.layoutSizingHorizontal = 'FILL'; } catch (e) {}` : ''}
+ ${numW !== null ? `try { el${idx}.resize(${numW}, el${idx}.height); } catch (e) {}` : ''}`;
} else if (item._type === 'slot') {
// Slot element - creates slot inside component
// NOTE: createSlot only works when parent is a component
@@ -1849,6 +1867,8 @@ export class FigmaClient {
const p = props.p || props.padding || 0;
const px = props.px || p;
const py = props.py || p;
+ const side = (v, d) => v !== undefined ? Number(v) : d;
+ const pt = side(props.pt, py), pb = side(props.pb, py), pl = side(props.pl, px), pr = side(props.pr, px);
// CLI-supplied -x/-y win over JSX props; `--no-smart-position` reaches us
// as an explicit x so the frame lands exactly where the caller asked.
const cliX = opts.x !== undefined && opts.x !== null ? Number(opts.x) : undefined;
@@ -1998,6 +2018,8 @@ export class FigmaClient {
return `
(async function() {
+ ${HELPERS_SOURCE}
+ ${RESET_NOTES}
${fontLoadCode}
${LAYOUT_WARN_PRELUDE}
${varLoadCode}
@@ -2019,10 +2041,10 @@ export class FigmaClient {
frame.layoutMode = '${flex === 'none' || flex === 'stack' || flex === 'free' ? 'NONE' : (flex === 'row' ? 'HORIZONTAL' : 'VERTICAL')}';
${flex === 'none' || flex === 'stack' || flex === 'free' ? '' : `${wrap && flex === 'row' ? `frame.layoutWrap = 'WRAP';` : ''}
frame.itemSpacing = ${gap};
- frame.paddingTop = ${py};
- frame.paddingBottom = ${py};
- frame.paddingLeft = ${px};
- frame.paddingRight = ${px};
+ frame.paddingTop = ${pt};
+ frame.paddingBottom = ${pb};
+ frame.paddingLeft = ${pl};
+ frame.paddingRight = ${pr};
frame.primaryAxisAlignItems = '${justifyVal}';
frame.counterAxisAlignItems = '${alignVal}';
frame.primaryAxisSizingMode = '${flex === 'col' ? (hugHeight || fillHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED') : (hugWidth || fillWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED')}';
@@ -2054,9 +2076,13 @@ export class FigmaClient {
if (globalThis.__figHugFlush) globalThis.__figHugFlush();
const __layoutWarnings = globalThis.__layoutWarnings || [];
globalThis.__layoutWarnings = [];
- return (__unresolved.length > 0 || __layoutWarnings.length > 0)
- ? { id: frame.id, name: frame.name, unresolved: __unresolved, layoutWarnings: __layoutWarnings }
- : { id: frame.id, name: frame.name };
+ const __notes = globalThis.__figNotes ? [...globalThis.__figNotes] : [];
+ // What was built, so the caller does not need a second call to check it.
+ let __summary = null;
+ try { __summary = await globalThis.__figHelpers.describe(frame); } catch (e) {}
+ return (__unresolved.length > 0 || __layoutWarnings.length > 0 || __notes.length > 0)
+ ? { id: frame.id, name: frame.name, unresolved: __unresolved, layoutWarnings: __layoutWarnings, notes: __notes, summary: __summary }
+ : { id: frame.id, name: frame.name, summary: __summary };
} catch(e) {
frame.remove();
throw new Error('[Node: ' + __currentNode + '] ' + e.message);
@@ -2385,6 +2411,16 @@ export class FigmaClient {
}
generateEffectsCode(props, elementVar) {
+ const raw = this.generateRawEffectsCode(props, elementVar);
+ if (!props.effectStyle) return raw;
+ // A named effect style from the file (e.g. effectStyle="shadow/resting/small").
+ // Applied after raw effects so the style wins; a missing name becomes a note.
+ return raw + `
+ try { await ${elementVar}.setEffectStyleIdAsync((await globalThis.__figHelpers.style(${JSON.stringify(String(props.effectStyle))})).id); }
+ catch (e) { globalThis.__figHelpers.note(e.message); }`;
+ }
+
+ generateRawEffectsCode(props, elementVar) {
const effects = [];
if (props.shadow) {
const arr = Array.isArray(props.shadow) ? props.shadow : [props.shadow];
diff --git a/src/lib/agent-rules.js b/src/lib/agent-rules.js
new file mode 100644
index 00000000..c1eb6eb2
--- /dev/null
+++ b/src/lib/agent-rules.js
@@ -0,0 +1,184 @@
+// The figma-cli agent rules (AGENTS.md for Claude Code / Codex / Cursor, and
+// .cursor/rules/figma-cli.mdc) plus the logic that keeps them current.
+//
+// The rules live between two markers. The start marker carries a hash of the
+// rules, so an outdated copy is detected without a version number anyone has
+// to remember to bump. Only the marked block is ever rewritten: whatever the
+// user wrote around it stays. `init-agent` writes or refreshes it; `connect`
+// refreshes copies that already exist in the working directory, so an update
+// of figma-cli reaches projects without anyone re-running init-agent.
+import { createHash } from 'crypto';
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { dirname, join } from 'path';
+
+// The shared, designer-facing usage rules. Kept tight on purpose — an agent
+// needs the operating rules, not the CLI's internals.
+export const RULES_BODY = `# Using figma-cli
+
+figma-cli controls **Figma Desktop** directly (no API key). It runs in any
+terminal. Open Figma Desktop, then \`figma-cli connect\` once per session.
+
+## Golden rules
+1. **Create frames with \`render\` / \`render-batch\`** — they have smart positioning.
+ NEVER use \`eval\` to create visual nodes (no positioning, bypasses guards).
+2. **"N buttons/cards" = N separate top-level nodes**, not one wrapper frame
+ containing N children. Use \`render-batch '[...]'\` or \`shadcn add --count N\`.
+3. **Never delete the user's existing nodes.**
+4. After creating, **verify**: \`figma-cli verify "" --measure\` (returns a
+ screenshot + real w/h so you catch size bugs by numbers, not by eye).
+
+Every command you need for everyday work is on this page. Only run
+\`figma-cli --help\` when a task needs a command not listed here.
+**Each tool call costs a full agent turn, so do a whole task in ONE call where
+you can**: one \`eval\` that reads everything, one \`render\` that builds the
+whole structure.
+
+## Reading: \`eval\` runs Figma Plugin API code
+\`figma-cli eval ''\` runs JavaScript in the open file and prints the
+result as compact JSON. Top-level \`await\` works. A single expression returns
+itself; statements need an explicit \`return\`. Long code: \`eval --file q.js\`.
+- The file loads pages lazily: call \`await figma.loadAllPagesAsync()\` before
+ searching beyond the current page, and use the async APIs
+ (\`getNodeByIdAsync\`, \`getMainComponentAsync\`, \`getLocalVariablesAsync\`,
+ \`getStyleByIdAsync\`); the sync ones throw.
+- \`findAllWithCriteria({types: [...]})\` is much faster than \`findAll\`.
+- Return only what answers the question (counts, names, ids). Output over
+ 20,000 characters is cut.
+- A variable binding: \`node.fills[0].boundVariables.color.id\` → \`getVariableByIdAsync\`.
+ Resolve an alias by following \`valuesByMode\` values of type \`VARIABLE_ALIAS\`.
+
+Helpers available inside \`eval\` (they handle the Figma pitfalls for you):
+- \`await $page(name)\` switch to a page, creating it when missing
+- \`await $var(name)\` local variable by full name; \`await $bind(node, varName, 'fills'|'strokes'|field)\`
+- \`await $style(name)\` any local style by name
+- \`await $component(name)\` component or component set by name, whole file
+- \`await $instance(name, 'axis=value, axis=value', {text, parent})\` instance of a
+ variant; unspecified axes use the default variant
+- \`await $fontSafe(node)\` load fonts and replace fonts that are not installed
+- \`await $describe(node)\` compact one-line-per-node summary of a subtree
+- Loops are fine: e.g. one instance per variant of a set in a single \`eval\`:
+ \`for (const v of (await $component('Name')).children) await $instance(v.id, '', {parent: frame})\`
+
+## Writing: \`render\`
+- \`--page "Name"\` renders on that page and creates it when missing.
+- \`var:\` takes any variable by full name: \`bg="var:bgColor/muted"\`, \`color="var:fgColor/default"\`.
+- Styles by name: \` \`, \`\`.
+- Existing components, any page: \` \`
+ (\`text\` relabels the first text layer).
+- A component text or style whose font is not installed is switched to Inter
+ automatically and reported as a \`note:\` line. You do not need to fix fonts.
+- \`render\` prints the new node id and **the structure it built**: sizes, layout,
+ padding, gap, bound variables, styles, which component each instance uses.
+ Check that output; do not re-read the result with \`eval\`. \`--verify\` adds a
+ screenshot when the look matters.
+
+## Components
+- One component: \`render ' ' --as-component\`.
+- A component set with variants in ONE call: \`render-batch\` with every frame
+ named by its variant, plus \`--variant-set \`:
+ \`render-batch '[" ...", ...]' --variant-set Button\`
+- Component properties (in \`eval\`): \`const k = comp.addComponentProperty('Label', 'TEXT', 'Default')\`,
+ then \`textNode.componentPropertyReferences = { characters: k }\`. For \`'BOOLEAN'\`
+ reference \`visible\`; for \`'INSTANCE_SWAP'\` reference \`mainComponent\`.
+
+## Design tokens / variables
+- Bind colors at creation with \`var:name\`, never raw hex when a system is loaded:
+ \`Go \`
+- Pin a named collection when the user names one: \`render-batch ... --collection figma\`.
+- Import a system: \`figma-cli import tailwind.config.js | globals.css | tokens.json\`.
+- Export the open file's system: \`figma-cli extract\` → DESIGN.md.
+
+## JSX cheatsheet (render)
+- Layout: \`flex="row|col" gap={16} p={24} px py pt pr pb pl justify="center|between" items="center"\`
+- Size: \`w={320} h={200} w="fill" w="hug" w="60%"\` (percent resolves vs parent)
+- Look: \`bg="#fff" stroke="#000" strokeWidth={2} rounded={12} shadow="..." opacity={0.8}\`
+- Text: \`\`
+- Icons (real SVG, never emojis): \` \`
+- Dividers: a thin child (\` \`) auto-fills the cross axis.
+
+## Text wrapping (most common bug)
+For text to wrap, the parent AND every \`\` need \`w="fill"\`, and the parent
+needs \`flex="col"\` or \`flex="row"\`.
+
+## Recreating a component from an extracted DESIGN.md (hard rule)
+Don't read the structure markdown by hand. Use:
+- \`figma-cli spec \` → authoritative variant axes + sample size (compact).
+ Build EXACTLY to those axes (e.g. Variant × Size = a Component Set, not one node).
+- \`figma-cli spec --check \` → enforces it (exit 1 on mismatch:
+ wrong structure, missing axes, wrong height). Treat non-zero as "not done".
+
+## Handy commands
+\`\`\`
+figma-cli connect # connect to Figma Desktop (yolo)
+figma-cli render ' ...' # one frame
+figma-cli render-batch '[ " ", ... ]' --direction row
+figma-cli shadcn add button --count 3 # N distinct shadcn primitives
+figma-cli node to-component "" # promote to a component
+figma-cli verify "" --measure # screenshot + dimensions
+figma-cli a11y audit # contrast / touch / text checks
+\`\`\`
+`;
+
+
+export const RULES_HASH = createHash('sha1').update(RULES_BODY).digest('hex').slice(0, 10);
+const START = ``;
+const START_RE = //;
+const END = '';
+const BLOCK = `${START}\n${RULES_BODY}${END}\n`;
+
+// Copies written before the markers existed start at this heading and end
+// with the fenced "Handy commands" block. Replace exactly that span.
+function legacySpan(text) {
+ const start = text.indexOf('# Using figma-cli');
+ if (start < 0) return null;
+ const handy = text.indexOf('## Handy commands', start);
+ if (handy < 0) return null;
+ const open = text.indexOf('```', handy);
+ const close = open < 0 ? -1 : text.indexOf('```', open + 3);
+ if (close < 0) return null;
+ let end = close + 3;
+ if (text[end] === '\n') end++;
+ return { start, end };
+}
+
+const MDC_HEAD = '---\ndescription: How to drive figma-cli (controls Figma Desktop) from this project\nalwaysApply: true\n---\n\n';
+
+export const RULE_FILES = [
+ { tool: 'cursor', rel: join('.cursor', 'rules', 'figma-cli.mdc'), fresh: () => MDC_HEAD + BLOCK },
+ { tool: 'claude', rel: 'AGENTS.md', fresh: () => BLOCK },
+];
+
+// Write or refresh one rules file.
+// create: false -> only refresh a file that already carries figma-cli rules
+// Returns { path, status: written | updated | up-to-date | exists | absent }.
+export function syncRulesFile(path, fresh, { create = true, force = false } = {}) {
+ if (!existsSync(path)) {
+ if (!create) return { path, status: 'absent' };
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, fresh());
+ return { path, status: 'written' };
+ }
+ const text = readFileSync(path, 'utf8');
+ const m = text.match(START_RE);
+ if (m) {
+ const s = m.index;
+ const e = text.indexOf(END, s);
+ if (m[1] === RULES_HASH && e >= 0 && !force) return { path, status: 'up-to-date' };
+ const end = e >= 0 ? e + END.length + (text[e + END.length] === '\n' ? 1 : 0) : text.length;
+ writeFileSync(path, text.slice(0, s) + BLOCK + text.slice(end));
+ return { path, status: 'updated' };
+ }
+ const legacy = legacySpan(text);
+ if (legacy) {
+ writeFileSync(path, text.slice(0, legacy.start) + BLOCK + text.slice(legacy.end));
+ return { path, status: 'updated' };
+ }
+ if (force) { writeFileSync(path, fresh()); return { path, status: 'written' }; }
+ return { path, status: 'exists' }; // unrelated file: never touch it
+}
+
+// Refresh the rules files in dir that already exist. Used by `connect`.
+export function refreshRules(dir) {
+ return RULE_FILES.map(f => syncRulesFile(join(dir, f.rel), f.fresh, { create: false }))
+ .filter(r => r.status === 'updated');
+}
diff --git a/src/lib/figma-helpers.js b/src/lib/figma-helpers.js
new file mode 100644
index 00000000..0194f54d
--- /dev/null
+++ b/src/lib/figma-helpers.js
@@ -0,0 +1,270 @@
+// Helpers that run INSIDE Figma, shared by `render` (, effectStyle,
+// textStyle) and by `eval` (as $page, $var, $bind, $instance, $fontSafe,
+// $style). One implementation, so the missing-font handling below behaves the
+// same whichever command an agent reaches for.
+//
+// Why they exist: a component whose text uses a font that is not installed
+// cannot be relabelled, and Figma even refuses to appendChild the instance into
+// a frame ("unloaded font"). Without help an agent rediscovers that by trial
+// and error on every task. Here it is handled once.
+//
+// Kept as a source string because it is evaluated in the Figma sandbox, not in
+// Node. It installs itself on globalThis and is idempotent per version.
+
+export const HELPERS_VERSION = 6;
+
+export const HELPERS_SOURCE = `
+if (!globalThis.__figHelpers || globalThis.__figHelpers.v !== ${HELPERS_VERSION}) {
+ const H = { v: ${HELPERS_VERSION} };
+ // Notes for the caller (deduplicated). eval and render reset and print them.
+ H.note = (msg) => { (globalThis.__figNotes || (globalThis.__figNotes = new Set())).add(msg); };
+ let pagesLoaded = false;
+ const loadPages = async () => { if (!pagesLoaded) { await figma.loadAllPagesAsync(); pagesLoaded = true; } };
+ let fontList = null;
+ const available = async (f) => {
+ if (!fontList) fontList = new Set((await figma.listAvailableFontsAsync()).map(x => x.fontName.family + '|' + x.fontName.style));
+ return fontList.has(f.family + '|' + f.style);
+ };
+ const FALLBACK = 'Inter';
+ const fallbackStyle = (style) => {
+ const s = String(style || '').toLowerCase();
+ if (s.includes('black') || s.includes('heavy')) return 'Black';
+ if (s.includes('extra') && s.includes('bold')) return 'Extra Bold';
+ if (s.includes('semi') || s.includes('demi')) return 'Semi Bold';
+ if (s.includes('bold')) return 'Bold';
+ if (s.includes('medium')) return 'Medium';
+ if (s.includes('light')) return 'Light';
+ return 'Regular';
+ };
+
+ // Make every text under node editable: load installed fonts, swap missing
+ // ones to Inter (same weight). Returns the list of swapped fonts.
+ H.fontSafe = async (node) => {
+ const texts = node.type === 'TEXT' ? [node] : (node.findAllWithCriteria ? node.findAllWithCriteria({ types: ['TEXT'] }) : []);
+ const swapped = new Set();
+ for (const t of texts) {
+ const fonts = t.fontName === figma.mixed ? t.getRangeAllFontNames(0, t.characters.length) : [t.fontName];
+ let missing = false;
+ for (const f of fonts) {
+ if (await available(f)) await figma.loadFontAsync(f); else missing = true;
+ }
+ if (missing) {
+ const style = fallbackStyle((fonts[0] || {}).style);
+ const f = { family: FALLBACK, style };
+ try { await figma.loadFontAsync(f); } catch (e) { f.style = 'Regular'; await figma.loadFontAsync(f); }
+ for (const old of fonts) swapped.add(old.family + ' ' + old.style);
+ t.fontName = f;
+ }
+ }
+ return [...swapped];
+ };
+
+ // Page by name: switch to it, create it when missing.
+ H.page = async (name) => {
+ await loadPages();
+ let p = figma.root.children.find(p => p.name === name);
+ if (!p) { p = figma.createPage(); p.name = name; }
+ await figma.setCurrentPageAsync(p);
+ return p;
+ };
+
+ let varIndex = null;
+ H.variable = async (name) => {
+ if (!varIndex || !varIndex.has(name)) {
+ varIndex = new Map();
+ for (const v of await figma.variables.getLocalVariablesAsync()) if (!varIndex.has(v.name)) varIndex.set(v.name, v);
+ }
+ const v = varIndex.get(name);
+ if (!v) throw new Error('Variable not found: ' + name);
+ return v;
+ };
+
+ // Bind a variable to fills (default), strokes, or any bindable field.
+ H.bind = async (node, name, field = 'fills') => {
+ const v = await H.variable(name);
+ if (field === 'fills' || field === 'strokes') {
+ const base = (Array.isArray(node[field]) && node[field][0] && node[field][0].type === 'SOLID') ? node[field][0] : { type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } };
+ node[field] = [figma.variables.setBoundVariableForPaint(base, 'color', v)];
+ } else {
+ node.setBoundVariable(field, v);
+ }
+ return node;
+ };
+
+ H.style = async (name) => {
+ const all = [
+ ...(await figma.getLocalTextStylesAsync()), ...(await figma.getLocalEffectStylesAsync()),
+ ...(await figma.getLocalPaintStylesAsync()), ...(await figma.getLocalGridStylesAsync()),
+ ];
+ const s = all.find(s => s.name === name);
+ if (!s) throw new Error('Style not found: ' + name);
+ return s;
+ };
+
+ const compCache = new Map();
+ // The helpers live on globalThis for the whole Figma session, so anything
+ // cached here can point at a node or variable the user has since deleted.
+ // Every render / eval starts with a reset (see RESET_NOTES).
+ H.resetCaches = () => { compCache.clear(); varIndex = null; pagesLoaded = false; fontList = null; };
+ // A component or component set by name (whole document) or by node id.
+ H.component = async (ref) => {
+ if (/^I?\\d+:\\d+/.test(ref)) {
+ const n = await figma.getNodeByIdAsync(ref);
+ if (n && (n.type === 'COMPONENT' || n.type === 'COMPONENT_SET')) return n;
+ }
+ if (compCache.has(ref)) return compCache.get(ref);
+ await loadPages();
+ const all = figma.root.findAllWithCriteria({ types: ['COMPONENT_SET', 'COMPONENT'] });
+ const hit = all.find(n => n.type === 'COMPONENT_SET' && n.name === ref)
+ || all.find(n => n.type === 'COMPONENT' && n.name === ref && (!n.parent || n.parent.type !== 'COMPONENT_SET'))
+ || all.find(n => n.type === 'COMPONENT_SET' && n.name.toLowerCase() === String(ref).toLowerCase());
+ if (!hit) throw new Error('Component not found: ' + ref);
+ compCache.set(ref, hit);
+ return hit;
+ };
+
+ // Variant of a set. "variant=danger, size=large" or {variant:'danger'}; the
+ // given pairs must match, unspecified axes fall back to the default variant.
+ H.variant = (set, spec) => {
+ if (set.type !== 'COMPONENT_SET') return set;
+ const want = {};
+ if (typeof spec === 'string') {
+ for (const part of spec.split(',')) { const i = part.indexOf('='); if (i > 0) want[part.slice(0, i).trim()] = part.slice(i + 1).trim(); }
+ } else if (spec) Object.assign(want, spec);
+ const keys = Object.keys(want);
+ if (!keys.length) return set.defaultVariant || set.children[0];
+ const matches = set.children.filter(c => keys.every(k => String((c.variantProperties || {})[k]).toLowerCase() === String(want[k]).toLowerCase()));
+ if (!matches.length) {
+ const axes = Object.entries(set.variantGroupProperties || {}).map(([k, v]) => k + '=' + v.values.join('|')).join('; ');
+ throw new Error('No variant of "' + set.name + '" matches ' + JSON.stringify(want) + '. Axes: ' + axes);
+ }
+ const def = set.defaultVariant ? set.defaultVariant.variantProperties || {} : {};
+ // prefer the match closest to the default variant on the unspecified axes
+ matches.sort((a, b) => {
+ const score = c => Object.entries(def).filter(([k, v]) => !(k in want) && (c.variantProperties || {})[k] === v).length;
+ return score(b) - score(a);
+ });
+ return matches[0];
+ };
+
+ // Instance of a component (set name, component name or id), font-safe.
+ // opts: { variant, text, texts: {layerName: chars}, parent }
+ H.instance = async (ref, variantSpec, opts = {}) => {
+ const c = await H.component(ref);
+ const comp = H.variant(c, variantSpec);
+ const inst = comp.createInstance();
+ const swapped = await H.fontSafe(inst);
+ const texts = inst.findAllWithCriteria({ types: ['TEXT'] }).filter(t => t.visible);
+ if (opts.text !== undefined && texts[0]) texts[0].characters = String(opts.text);
+ if (opts.texts) for (const [layer, chars] of Object.entries(opts.texts)) {
+ const t = inst.findOne(n => n.type === 'TEXT' && n.name === layer);
+ if (t) t.characters = String(chars);
+ }
+ if (opts.parent) opts.parent.appendChild(inst);
+ for (const f of swapped) H.note('font "' + f + '" is not installed, used Inter instead');
+ return inst;
+ };
+
+ // One line per node, compact enough to replace a verification eval:
+ // Card FRAME 360x212 col gap=12 pad=24 fill=var:bgColor/muted effect=shadow/small
+ // Title TEXT 20 Inter Bold w=FILL fill=var:fgColor/default "Release notes"
+ // Button INSTANCE Button[variant=danger, size=large] "Delete"
+ // Capped by depth and line count so a big tree never floods the output.
+ H.describe = async (root, maxDepth = 3, maxLines = 40) => {
+ const lines = [];
+ const varName = async (paint) => {
+ const id = paint && paint.boundVariables && paint.boundVariables.color && paint.boundVariables.color.id;
+ if (!id) return null;
+ const v = await figma.variables.getVariableByIdAsync(id);
+ return v ? 'var:' + v.name : null;
+ };
+ const paintDesc = async (paints) => {
+ if (!Array.isArray(paints) || !paints.length) return null;
+ const p = paints[0];
+ const v = await varName(p);
+ if (v) return v;
+ if (p.type === 'SOLID') return '#' + [p.color.r, p.color.g, p.color.b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('') + (p.opacity !== undefined && p.opacity < 1 ? '@' + Math.round(p.opacity * 100) + '%' : '');
+ return p.type.toLowerCase();
+ };
+ const styleName = async (id) => (typeof id === 'string' && id) ? ((await figma.getStyleByIdAsync(id)) || {}).name : null;
+ const visit = async (n, depth) => {
+ if (lines.length >= maxLines) return;
+ try { await line(n, depth); } catch (e) { lines.push(' '.repeat(depth) + n.name + ' ' + n.type); }
+ // do not descend into instances (their inside is the component's business)
+ if ('children' in n && n.type !== 'INSTANCE' && depth < maxDepth) {
+ for (const c of n.children) { if (lines.length >= maxLines) break; await visit(c, depth + 1); }
+ }
+ };
+ const line = async (n, depth) => {
+ const bits = [n.name, n.type];
+ if ('width' in n) bits.push(Math.round(n.width) + 'x' + Math.round(n.height));
+ if (n.layoutMode && n.layoutMode !== 'NONE') {
+ bits.push(n.layoutMode === 'HORIZONTAL' ? 'row' : 'col');
+ if (n.itemSpacing) bits.push('gap=' + n.itemSpacing);
+ const pad = [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft];
+ if (pad.some(Boolean)) bits.push('pad=' + (pad.every(v => v === pad[0]) ? pad[0] : pad.join('/')));
+ if (n.layoutWrap === 'WRAP') bits.push('wrap');
+ }
+ // sizing mode, where it is not the plain fixed size
+ const inAuto = (n.layoutMode && n.layoutMode !== 'NONE') || (n.parent && n.parent.layoutMode && n.parent.layoutMode !== 'NONE');
+ if (inAuto && n.type !== 'TEXT' || n.type === 'TEXT' && n.parent && n.parent.layoutMode && n.parent.layoutMode !== 'NONE') {
+ try {
+ if (n.layoutSizingHorizontal && n.layoutSizingHorizontal !== 'FIXED') bits.push('w=' + n.layoutSizingHorizontal);
+ if (n.layoutSizingVertical && n.layoutSizingVertical !== 'FIXED') bits.push('h=' + n.layoutSizingVertical);
+ } catch (e) {}
+ }
+ if (n.type === 'TEXT' && n.textAutoResize === 'HEIGHT') bits.push('wraps');
+ if (n.cornerRadius && n.cornerRadius !== figma.mixed) bits.push('r=' + n.cornerRadius);
+ if (n.opacity !== undefined && n.opacity < 1) bits.push('opacity=' + Math.round(n.opacity * 100) / 100);
+ if (n.type === 'TEXT') {
+ const f = n.fontName !== figma.mixed ? n.fontName : null;
+ bits.push((n.fontSize !== figma.mixed ? n.fontSize : 'mixed') + (f ? ' ' + f.family + ' ' + f.style : ''));
+ const ts = await styleName(n.textStyleId); if (ts) bits.push('style=' + ts);
+ }
+ if ('fills' in n && n.type !== 'INSTANCE') { const f = await paintDesc(n.fills); if (f) bits.push('fill=' + f); }
+ if ('strokes' in n && n.strokes && n.strokes.length) { const s = await paintDesc(n.strokes); if (s) bits.push('stroke=' + s); }
+ if ('effectStyleId' in n) { const es = await styleName(n.effectStyleId); if (es) bits.push('effect=' + es); }
+ if (n.type === 'INSTANCE') {
+ const mc = await n.getMainComponentAsync();
+ if (mc) bits.push('of ' + (mc.parent && mc.parent.type === 'COMPONENT_SET' ? mc.parent.name + '[' + mc.name + ']' : mc.name));
+ }
+ if (n.type === 'COMPONENT_SET') bits.push(n.children.length + ' variants');
+ // Figma throws when a VARIANT is asked for its property definitions;
+ // only sets and standalone components have them.
+ const ownsProps = n.type === 'COMPONENT_SET' || (n.type === 'COMPONENT' && !(n.parent && n.parent.type === 'COMPONENT_SET'));
+ if (ownsProps) {
+ try {
+ const defs = Object.entries(n.componentPropertyDefinitions).filter(([, d]) => d.type !== 'VARIANT').map(([k, d]) => k.split('#')[0] + ':' + d.type);
+ if (defs.length) bits.push('props={' + defs.join(', ') + '}');
+ } catch (e) {}
+ }
+ if (n.componentPropertyReferences && Object.keys(n.componentPropertyReferences).length) {
+ bits.push('bound=' + Object.entries(n.componentPropertyReferences).map(([k, v]) => k + '→' + String(v).split('#')[0]).join(','));
+ }
+ if (n.reactions && n.reactions.length) bits.push(n.reactions.length + ' interaction(s)');
+ if (n.type === 'TEXT') bits.push(JSON.stringify(n.characters.length > 40 ? n.characters.slice(0, 40) + '…' : n.characters));
+ else if (n.type === 'INSTANCE') {
+ const t = n.findOne(x => x.type === 'TEXT' && x.visible);
+ if (t) bits.push(JSON.stringify(t.characters.length > 40 ? t.characters.slice(0, 40) + '…' : t.characters));
+ }
+ lines.push(' '.repeat(depth) + bits.join(' '));
+ };
+ await visit(root, 0);
+ if (lines.length >= maxLines) lines.push(' … (more nodes; ' + maxLines + '-line summary)');
+ return lines.join('\\n');
+ };
+
+ globalThis.__figHelpers = H;
+ globalThis.$page = H.page; globalThis.$var = H.variable; globalThis.$bind = H.bind;
+ globalThis.$style = H.style; globalThis.$component = H.component;
+ globalThis.$instance = H.instance; globalThis.$fontSafe = H.fontSafe; globalThis.$describe = H.describe;
+}
+`;
+
+// True when user eval code calls one of the helpers.
+export const RESET_NOTES = 'globalThis.__figNotes = new Set(); if (globalThis.__figHelpers && globalThis.__figHelpers.resetCaches) globalThis.__figHelpers.resetCaches();';
+export const READ_NOTES = 'return globalThis.__figNotes ? [...globalThis.__figNotes] : [];';
+
+export function usesHelpers(code) {
+ return /\$(page|var|bind|style|component|instance|fontSafe|describe)\s*\(/.test(code || '');
+}
diff --git a/tests/agent-rules.test.js b/tests/agent-rules.test.js
new file mode 100644
index 00000000..18ec84db
--- /dev/null
+++ b/tests/agent-rules.test.js
@@ -0,0 +1,49 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+import { RULES_BODY, RULES_HASH, RULE_FILES, syncRulesFile, refreshRules } from '../src/lib/agent-rules.js';
+
+const agents = RULE_FILES.find(f => f.rel === 'AGENTS.md');
+const dir = () => mkdtempSync(join(tmpdir(), 'rules-'));
+
+test('writes a marked block into a new AGENTS.md', () => {
+ const d = dir(); const p = join(d, 'AGENTS.md');
+ assert.equal(syncRulesFile(p, agents.fresh).status, 'written');
+ const t = readFileSync(p, 'utf8');
+ assert.ok(t.includes(RULES_HASH) && t.includes(RULES_BODY));
+ assert.equal(syncRulesFile(p, agents.fresh).status, 'up-to-date');
+});
+
+test('replaces an outdated block and keeps the user text around it', () => {
+ const d = dir(); const p = join(d, 'AGENTS.md');
+ writeFileSync(p, 'my intro\n\n# Using figma-cli\nold\n\nmy outro\n');
+ assert.equal(syncRulesFile(p, agents.fresh).status, 'updated');
+ const t = readFileSync(p, 'utf8');
+ assert.ok(t.startsWith('my intro\n') && t.endsWith('my outro\n'));
+ assert.ok(t.includes(RULES_HASH) && !t.includes('\nold\n'));
+});
+
+test('upgrades a pre-marker copy up to the end of its Handy commands block', () => {
+ const d = dir(); const p = join(d, 'AGENTS.md');
+ writeFileSync(p, '# Using figma-cli\nold rules\n## Handy commands\n```\nfigma-cli connect\n```\n\n## Our own notes\nkeep me\n');
+ assert.equal(syncRulesFile(p, agents.fresh).status, 'updated');
+ const t = readFileSync(p, 'utf8');
+ assert.ok(t.includes(RULES_HASH) && t.includes('## Our own notes\nkeep me') && !t.includes('old rules'));
+});
+
+test('never touches an unrelated AGENTS.md', () => {
+ const d = dir(); const p = join(d, 'AGENTS.md');
+ writeFileSync(p, '# Team rules\n');
+ assert.equal(syncRulesFile(p, agents.fresh).status, 'exists');
+ assert.equal(readFileSync(p, 'utf8'), '# Team rules\n');
+});
+
+test('refreshRules updates existing copies and creates nothing', () => {
+ const d = dir();
+ assert.deepEqual(refreshRules(d), []);
+ assert.equal(existsSync(join(d, 'AGENTS.md')), false);
+ writeFileSync(join(d, 'AGENTS.md'), '\nold\n\n');
+ assert.equal(refreshRules(d).length, 1);
+});
diff --git a/tests/figma-client.test.js b/tests/figma-client.test.js
index e23a8b6d..e55153c6 100644
--- a/tests/figma-client.test.js
+++ b/tests/figma-client.test.js
@@ -83,3 +83,17 @@ describe('FigmaClient properties', () => {
assert.strictEqual(client.pageTitle, null);
});
});
+
+it('root frame honors per-side padding in render and render-batch', async () => {
+ const c = new FigmaClient();
+ const jsx = 'x ';
+ const single = await c.parseJSX(jsx);
+ for (const want of ['paddingTop = 4', 'paddingRight = 10', 'paddingBottom = 6', 'paddingLeft = 8']) {
+ assert.ok(single.includes(want), 'render: ' + want);
+ }
+ const batch = await c.parseJSXBatch([jsx]);
+ const code = typeof batch === 'string' ? batch : JSON.stringify(batch);
+ for (const want of ['paddingTop = 4', 'paddingRight = 10', 'paddingBottom = 6', 'paddingLeft = 8']) {
+ assert.ok(code.includes(want), 'render-batch: ' + want);
+ }
+});
diff --git a/tests/instantiate-cmd.test.js b/tests/instantiate-cmd.test.js
index 6d75c5d7..ffd774bd 100644
--- a/tests/instantiate-cmd.test.js
+++ b/tests/instantiate-cmd.test.js
@@ -1,4 +1,4 @@
-import { test } from 'node:test';
+import { test, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { instantiateCode } from '../src/commands/instantiate.js';
@@ -11,3 +11,28 @@ test('instantiateCode is syntactically valid JS for a key+id plan', () => {
// dynamic-page safe: no legacy sync getNodeById( in the generated code
assert.doesNotMatch(code, /[^A-Za-z]getNodeById\(/);
});
+
+describe('instantiateCode — bounded steps', () => {
+ it('time-bounds every step', () => {
+ // figma.importComponentByKeyAsync on a key that was never published NEVER
+ // settles (measured: still pending after 2 minutes). Unbounded, the command
+ // hangs forever and wedges the daemon behind it, when the right answer is
+ // to fall through to the node-id step.
+ const code = instantiateCode([{ via: 'key', key: 'abc' }, { via: 'id', id: '1:2' }]);
+ assert.match(code, /Promise\.race/);
+ assert.match(code, /bounded\(figma\.importComponentByKeyAsync\(step\.key\)/);
+ assert.match(code, /bounded\(figma\.getNodeByIdAsync\(step\.id\)/);
+ });
+
+ it('embeds the timeout as a number', () => {
+ assert.match(instantiateCode([], { timeoutMs: 1234 }), /const TIMEOUT = 1234;/);
+ });
+
+ it('keeps trying the remaining steps after a timeout', () => {
+ // The catch must stay inside the loop, otherwise a slow library lookup
+ // aborts the whole plan instead of falling through to the id.
+ const code = instantiateCode([{ via: 'key', key: 'a' }]);
+ const loop = code.slice(code.indexOf('for (const step of plan)'));
+ assert.match(loop, /catch \(e\) \{ tried\.push/);
+ });
+});
diff --git a/tests/instantiate-roundtrip.test.js b/tests/instantiate-roundtrip.test.js
index 846a89a3..af63bfbd 100644
--- a/tests/instantiate-roundtrip.test.js
+++ b/tests/instantiate-roundtrip.test.js
@@ -6,40 +6,60 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CLI = join(process.cwd(), 'src', 'index.js');
+
+// Every call is bounded. Without a timeout this test HUNG for 15 minutes when
+// the connected file was large (Primer Web: 67 pages), taking the whole suite
+// with it — and its cleanup never ran, so a Button fixture was left behind in
+// someone else's file. A bounded call fails loudly instead.
+const TIMEOUT = 90_000;
function run(args, opts = {}) {
- return execFileSync('node', [CLI, ...args], { encoding: 'utf8', ...opts });
+ return execFileSync('node', [CLI, ...args], { encoding: 'utf8', timeout: TIMEOUT, ...opts });
}
+
// Gate on a LIVE Figma connection, not just a running daemon: probe with a
// trivial eval. When Figma is not connected the CLI exits 1 → execFileSync
// throws → we skip. Only a real round-trip (exit 0, 'ok') unlocks the test.
function figmaReady() {
- try { return /ok/.test(run(['eval', `(async () => 'ok')()`])); } catch { return false; }
+ try { return /ok/.test(run(['eval', `(async () => 'ok')()`], { timeout: 20_000 })); } catch { return false; }
}
-// eval source that creates a 2-variant "Button" COMPONENT_SET and returns its id.
-const BUILD_SET = `(async () => {
+// The fixture gets its OWN page. Two reasons: extract can then be scoped to it
+// (walking 67 pages of a real design system is what made this test hang), and
+// cleanup is a single page.remove() that cannot leave stragglers behind.
+const PAGE = 'figma-cli test fixture';
+const BUILD = `(async () => {
+ const page = figma.createPage();
+ page.name = ${JSON.stringify(PAGE)};
+ await figma.setCurrentPageAsync(page);
const mk = (name) => { const c = figma.createComponent(); c.name = name; c.resize(80, 40); return c; };
const a = mk('Size=Small'); const b = mk('Size=Large');
- const set = figma.combineAsVariants([a, b], figma.currentPage);
+ const set = figma.combineAsVariants([a, b], page);
set.name = 'Button';
- return JSON.stringify({ id: set.id });
+ return JSON.stringify({ pageId: page.id, setId: set.id });
})()`;
-const cleanup = (id) => `(async () => {
- const n = await figma.getNodeByIdAsync(${JSON.stringify(id)});
- if (n) n.remove();
+const teardown = (pageId) => `(async () => {
+ await figma.loadAllPagesAsync();
+ const p = await figma.getNodeByIdAsync(${JSON.stringify(pageId)});
+ if (!p) return 'gone';
+ // Figma refuses to remove the page it is currently showing.
+ if (figma.currentPage === p) {
+ const other = figma.root.children.find(x => x !== p);
+ if (other) await figma.setCurrentPageAsync(other);
+ }
+ p.remove();
return 'ok';
})()`;
test('extract → instantiate roundtrip (gated on a connected Figma)', { skip: !figmaReady() }, () => {
const dir = mkdtempSync(join(tmpdir(), 'inst-'));
const md = join(dir, 'DESIGN.md');
- let setId;
+ let pageId;
try {
- const built = JSON.parse(run(['eval', BUILD_SET]).trim().split('\n').pop());
- setId = built.id;
+ const built = JSON.parse(run(['eval', BUILD]).trim().split('\n').pop());
+ pageId = built.pageId;
- run(['extract', md]);
+ run(['extract', md, '--pages', PAGE]);
const text = readFileSync(md, 'utf8');
assert.match(text, /### Button/);
assert.match(text, /Reuse: import existing/);
@@ -47,8 +67,8 @@ test('extract → instantiate roundtrip (gated on a connected Figma)', { skip: !
const out = run(['instantiate', 'Button', '--file', md]);
assert.match(out, /Instanced "Button" via (key|id)/);
} finally {
- // Delete the created set (and the instance, selected by `instantiate`).
- if (setId) { try { run(['eval', cleanup(setId)]); } catch {} }
- try { run(['eval', `(async () => { const s = figma.currentPage.selection; s.forEach(n => n.remove()); return 'ok'; })()`]); } catch {}
+ // Removing the fixture page takes the component set AND the instance
+ // `instantiate` dropped on it — nothing survives this test.
+ if (pageId) { try { run(['eval', teardown(pageId)]); } catch {} }
}
});
diff --git a/tests/live/regression.mjs b/tests/live/regression.mjs
new file mode 100644
index 00000000..8418c172
--- /dev/null
+++ b/tests/live/regression.mjs
@@ -0,0 +1,139 @@
+// Live regression check for the commands users already rely on. Runs against a
+// connected Figma file on its own scratch page and removes that page afterwards.
+// FIGMA_FILE="Untitled" node tests/live/regression.mjs
+import { execFileSync } from 'child_process';
+import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { tmpdir } from 'os';
+import { fileURLToPath } from 'url';
+
+const CLI = join(dirname(fileURLToPath(import.meta.url)), '../../src/index.js');
+const PAGE = 'figma-cli regression';
+const run = (args, opts = {}) => execFileSync('node', [CLI, ...args], { encoding: 'utf8', ...opts });
+const evalJson = code => {
+ const last = run(['eval', code]).trim().split('\n').filter(l => !l.startsWith('note:')).pop();
+ try { return JSON.parse(last); } catch { return last; } // strings print raw
+};
+const idOf = out => (out.match(/Rendered: (\d+:\d+)/) || [])[1];
+const ids = out => [...out.matchAll(/Rendered: (\d+:\d+)/g)].map(m => m[1]);
+
+const results = [];
+async function check(name, fn) {
+ try { const detail = await fn(); results.push([name, true, detail || '']); }
+ catch (e) { results.push([name, false, e.message.split('\n')[0].slice(0, 200)]); }
+}
+const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
+
+const tmp = mkdtempSync(join(tmpdir(), 'reg-'));
+const startPage = evalJson('figma.currentPage.id');
+evalJson(`const p = figma.createPage(); p.name = ${JSON.stringify(PAGE)}; await figma.setCurrentPageAsync(p); return p.id`);
+
+try {
+ await check('render: card with p, gap, hex, rounded, wrapping text', () => {
+ const id = idOf(run(['render', 'Title A long description that has to wrap onto a second line inside the card. ']));
+ const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return { w: n.width, pad: [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft], gap: n.itemSpacing, r: n.cornerRadius, t: n.children.map(c => [c.layoutSizingHorizontal, c.textAutoResize, c.height]) }`);
+ assert(r.w === 320 && r.pad.every(v => v === 24) && r.gap === 12 && r.r === 12, JSON.stringify(r));
+ assert(r.t.every(t => t[0] === 'FILL') && r.t[1][2] > 20, 'text did not wrap: ' + JSON.stringify(r.t));
+ });
+ await check('render: px/py padding unchanged by the per-side fix', () => {
+ const id = idOf(run(['render', 'Go ']));
+ const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft]`);
+ assert(r.join() === '8,16,8,16', r.join());
+ });
+ await check('render: nested frame per-side padding', () => {
+ const id = idOf(run(['render', 'x ']));
+ const r = evalJson(`const n = (await figma.getNodeByIdAsync("${id}")).children[0]; return [n.paddingTop, n.paddingRight, n.paddingBottom, n.paddingLeft]`);
+ assert(r.join() === '1,2,3,4', r.join());
+ });
+ await check('render: auto-split of a flex wrapper into separate nodes', () => {
+ const out = run(['render', ' ']);
+ assert(ids(out).length === 3, out.slice(0, 300));
+ });
+ await check('render --as-component', () => {
+ const out = run(['render', 'Chip ', '--as-component']);
+ const cid = (out.match(/Converted to component: (\d+:\d+)/) || [])[1];
+ assert(cid, out);
+ writeFileSync(join(tmp, 'chip-id'), cid);
+ return cid;
+ });
+ await check('render-batch --as-component: 3 separate components', () => {
+ const out = run(['render-batch', '[" "," "," "]', '--as-component']);
+ assert((out.match(/Converted to component/g) || []).length === 3, out.slice(0, 300));
+ });
+ await check(' (old id form)', () => {
+ const cid = readFileSync(join(tmp, 'chip-id'), 'utf8');
+ const id = idOf(run(['render', ` `]));
+ const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); const c = n.children[0]; return { type: c && c.type, main: c ? (await c.getMainComponentAsync()).id : null }`);
+ assert(r.type === 'INSTANCE' && r.main === cid, JSON.stringify(r));
+ });
+ await check(' (old name form)', () => {
+ const id = idOf(run(['render', ' ']));
+ const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return n.children.map(c => c.type)`);
+ assert(r.join() === 'INSTANCE', JSON.stringify(r));
+ });
+ await check(' after the component was deleted and recreated (no stale cache)', () => {
+ const mk = () => evalJson(`const c = figma.createComponent(); c.name = 'Recycled'; c.resize(20, 20); return c.id`);
+ const first = mk();
+ idOf(run(['render', ' ']));
+ evalJson(`(await figma.getNodeByIdAsync("${first}")).remove(); return true`);
+ const second = mk();
+ const id = idOf(run(['render', ' ']));
+ assert(id, 'render failed');
+ const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return (await n.children[0].getMainComponentAsync()).id`);
+ assert(r === second, r + ' != ' + second);
+ });
+ await check('render: shadow, opacity, icon', () => {
+ const id = idOf(run(['render', ' ']));
+ const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); return { fx: n.effects.map(e => e.type), o: Math.round(n.opacity * 100), kids: n.children.map(c => c.type) }`);
+ assert(r.fx.includes('DROP_SHADOW') && r.o === 80 && r.kids.length === 1, JSON.stringify(r));
+ });
+ await check('render: var: binding to a local variable', () => {
+ evalJson(`const col = figma.variables.createVariableCollection('regression'); const v = figma.variables.createVariable('reg/primary', col, 'COLOR'); v.setValueForMode(col.defaultModeId, { r: 0.1, g: 0.4, b: 0.9 }); return v.id`);
+ const id = idOf(run(['render', ' ']));
+ const r = evalJson(`const n = await figma.getNodeByIdAsync("${id}"); const b = n.fills[0].boundVariables?.color?.id; return b ? (await figma.variables.getVariableByIdAsync(b)).name : null`);
+ assert(r === 'reg/primary', String(r));
+ });
+ await check('eval: expression, statements, compact JSON when piped', () => {
+ assert(run(['eval', '1 + 1']).trim() === '2', 'expression');
+ assert(run(['eval', 'const a = 2; return a * 3']).trim() === '6', 'statements');
+ const out = run(['eval', '({ a: 1, b: [1, 2] })']).trim();
+ assert(out === '{"a":1,"b":[1,2]}', out);
+ });
+ await check('eval: output cap on huge results', () => {
+ const out = run(['eval', 'Array.from({ length: 5000 }, (_, i) => "item-" + i)']);
+ assert(out.length < 21000 && out.includes('output cut'), String(out.length));
+ });
+ await check('variants from --property (old command)', () => {
+ const out = run(['render-batch', '[" "," "]']);
+ const [a, b] = ids(out);
+ const v = run(['variants', 'from', `${a},${b}`, '--property', 'Size', '--values', 'Small,Medium', '--name', 'Box']);
+ const r = evalJson(`await figma.loadAllPagesAsync(); const s = figma.currentPage.findOne(n => n.type === 'COMPONENT_SET' && n.name === 'Box'); return s ? Object.keys(s.variantGroupProperties) : null`);
+ assert(r && r.join() === 'Size', v.slice(0, 200));
+ });
+ await check('shadcn add button --count 2', () => {
+ const out = run(['shadcn', 'add', 'button', '--count', '2']);
+ const n = (out.match(/\d+:\d+/g) || []).length;
+ assert(n >= 2, out.slice(0, 300));
+ });
+ await check('undo removes the last render', () => {
+ const id = idOf(run(['render', ' ']));
+ run(['undo']);
+ const r = evalJson(`return !!(await figma.getNodeByIdAsync("${id}"))`);
+ assert(r === false, 'node still there');
+ });
+} finally {
+ evalJson(`await figma.loadAllPagesAsync();
+const back = await figma.getNodeByIdAsync(${JSON.stringify(startPage)});
+if (back) await figma.setCurrentPageAsync(back);
+for (const p of figma.root.children.filter(p => p.name === ${JSON.stringify(PAGE)})) p.remove();
+for (const c of await figma.variables.getLocalVariableCollectionsAsync()) if (c.name === 'regression') c.remove();
+return true`);
+}
+
+let failed = 0;
+for (const [name, ok, detail] of results) {
+ if (!ok) failed++;
+ console.log((ok ? '✓ ' : '✗ ') + name + (detail ? ' ' + (ok ? '' : '→ ') + detail : ''));
+}
+console.log(`\n${results.length - failed}/${results.length} regression checks passed`);
+process.exit(failed ? 1 : 0);