diff --git a/CHANGELOG.md b/CHANGELOG.md
index 578b7e22..f5032317 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## 2.2.2 (2026-09-25)
+
+### Added
+
+- Images from the web in `render`: `image="https://..."` on frames and rectangles and ``. figma-cli downloads the file itself, so it also works while a plugin with network restrictions is open in the file
+- A lone `` is accepted as the outermost element of `render`
+- Text in a column with `items="center"` is centered automatically. `align` still wins
+- The daemon loads all pages in the background right after connecting, so the first command in a large file does not wait for page loading
+- The agent rules explain images and absolute positioning and clipping
+
+### Fixed
+
+- `` `` `` and `` were dropped when a quoted value contained a `/` (for example a URL)
+- The live test scripts remove their pages when they finish
+
## 2.2.1 (2026-09-25)
### Added: new versions reach users without extra steps
diff --git a/examples/autolayout-patterns.mjs b/examples/autolayout-patterns.mjs
index ee0625f9..a75606ab 100644
--- a/examples/autolayout-patterns.mjs
+++ b/examples/autolayout-patterns.mjs
@@ -187,12 +187,15 @@ const evalIn = (code) => {
function main() {
const keep = process.argv.includes('--keep');
- evalIn(`(async () => {
+ // Remember where the user was. This script builds on its own page, and
+ // without --keep it must leave the file exactly as it found it.
+ const previousPage = evalIn(`(async () => {
let p = figma.root.children.find(c => c.name === ${JSON.stringify(PAGE)});
+ const prev = figma.currentPage.id;
if (!p) { p = figma.createPage(); p.name = ${JSON.stringify(PAGE)}; }
await figma.setCurrentPageAsync(p);
${keep ? '' : 'for (const c of [...p.children]) c.remove();'}
- return 'ready';
+ return prev;
})()`);
// Caption above each example, so the page reads as documentation.
@@ -272,7 +275,36 @@ function main() {
console.log(`\n${PATTERNS.length - failures}/${PATTERNS.length} patterns match their expected layout`);
if (warned) console.log('note: render reported an auto-layout warning above — none of these patterns should trigger one');
+ if (!keep) teardown(previousPage);
+ else console.log(`kept on page "${PAGE}" — run without --keep to clean up`);
process.exit(failures || warned ? 1 : 0);
}
-main();
+/** Remove the scratch page and put the user back where they were. */
+function teardown(previousPageId) {
+ try {
+ evalIn(`(async () => {
+ await figma.loadAllPagesAsync();
+ const p = figma.root.children.find(c => c.name === ${JSON.stringify(PAGE)});
+ const prev = ${JSON.stringify(String(previousPageId || ''))};
+ const back = prev ? await figma.getNodeByIdAsync(prev) : null;
+ if (back && back.type === 'PAGE') await figma.setCurrentPageAsync(back);
+ else if (p) { const other = figma.root.children.find(x => x !== p); if (other) await figma.setCurrentPageAsync(other); }
+ if (p) p.remove();
+ return 'torn down';
+ })()`);
+ } catch (e) {
+ // Best effort — never mask the real result, but do say so: a silent
+ // teardown failure is how scratch pages accumulate in someone's file.
+ console.error(`⚠ teardown failed: ${e && e.message ? e.message.split('\n')[0] : e}`);
+ }
+}
+
+try {
+ main();
+} catch (e) {
+ // A crash must not leave the scratch page behind either.
+ teardown(null);
+ console.error(e && e.message ? e.message : e);
+ process.exit(1);
+}
diff --git a/package-lock.json b/package-lock.json
index 92eaa76b..15558e83 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "figma-ds-cli",
- "version": "2.2.1",
+ "version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "figma-ds-cli",
- "version": "2.2.1",
+ "version": "2.2.2",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
diff --git a/package.json b/package.json
index 836097fe..64829323 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "figma-ds-cli",
- "version": "2.2.1",
+ "version": "2.2.2",
"description": "CLI for managing Figma design systems. Create variables, components, and more. No API key required.",
"author": {
"name": "Sil Bormüller",
diff --git a/src/daemon.js b/src/daemon.js
index b1c15598..0e384e3a 100644
--- a/src/daemon.js
+++ b/src/daemon.js
@@ -209,6 +209,11 @@ async function getCdpClient() {
lastHealthCheck = Date.now();
lastHealthResult = true;
console.log('[daemon] Connected to Figma via CDP (Yolo Mode)');
+ // File warming: Figma loads pages lazily, so the first command that looks
+ // beyond the current page waits for every page to load (seconds on a big
+ // design system). Start that load now, in the background, so it is done
+ // before the first real command arrives. Never awaited, never fatal.
+ cdpClient.eval('figma.loadAllPagesAsync().then(() => true)').catch(() => {});
} finally {
isCdpConnecting = false;
}
diff --git a/src/figma-client.js b/src/figma-client.js
index 74bbca5f..f2cb6d8f 100644
--- a/src/figma-client.js
+++ b/src/figma-client.js
@@ -456,7 +456,8 @@ export class FigmaClient {
const vertical = options.vertical || false;
// Parse each JSX to get props and children
- const parsed = jsxArray.map(jsx => {
+ const parsed = jsxArray.map(raw => {
+ const jsx = this.normalizeRoot(raw);
const openMatch = jsx.match(/]*)>/);
if (!openMatch) throw new Error('Invalid JSX: must start with ');
const propsStr = openMatch[1];
@@ -470,6 +471,7 @@ export class FigmaClient {
// Pre-fetch any icon SVGs used in any frame (shared child generator
// renders real Iconify SVGs, falling back to placeholders offline)
const iconSvgMap = await this.prefetchIconSvgs(parsed.flatMap(p => p.children));
+ await this.prefetchImages(parsed.flatMap(p => [p.props, ...p.children]));
// Collect all fonts needed ({ family, style } pairs, deduped)
const allFontMap = new Map();
@@ -653,7 +655,7 @@ export class FigmaClient {
const effectsCode = this.generateEffectsCode(props, `f${frameIdx}`);
const imageCode = props.image ? this.generateImageFillCode(props.image, `f${frameIdx}`, props.imageScale) : '';
- const childCode = this.generateChildrenCode(children, `f${frameIdx}`, flex, { counter: { value: 0 }, prefix: `${frameIdx}_`, iconSvgMap });
+ const childCode = this.generateChildrenCode(children, `f${frameIdx}`, flex, { counter: { value: 0 }, prefix: `${frameIdx}_`, iconSvgMap }, props.items || props.align || null);
return `
const f${frameIdx} = figma.createFrame();
@@ -744,7 +746,42 @@ export class FigmaClient {
* divergent auto-layout implementation). Supporting them here is what lets
* every render go through ONE code path.
*/
+ /**
+ * A lone or is a common first attempt at "a
+ * box with an image". A Frame does everything a rectangle does, so accept it
+ * as the root instead of failing with "must start with ".
+ */
+ normalizeRoot(jsx) {
+ const m = String(jsx).trim().match(/^<(Rectangle|Rect)\b([\s\S]*?)\/>$/);
+ return m ? `` : jsx;
+ }
+
+ /**
+ * Download every image="https://..." / in the tree on the CLI side
+ * and hand Figma the bytes. figma.createImageAsync(url) runs under the network
+ * rules of whatever plugin context the code lands in (an open plugin's
+ * allowedDomains), so it fails for most URLs; createImage(bytes) never does.
+ */
+ async prefetchImages(nodes) {
+ const urls = new Set();
+ const walk = (n) => {
+ if (!n) return;
+ for (const u of [n.image, n._type === 'image' ? n.src : null]) if (typeof u === 'string' && /^https?:\/\//.test(u)) urls.add(u);
+ (n._children || []).forEach(walk);
+ };
+ nodes.forEach(walk);
+ this._imageData = this._imageData || {};
+ await Promise.all([...urls].map(async (u) => {
+ if (this._imageData[u]) return;
+ try {
+ const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
+ if (res.ok) this._imageData[u] = Buffer.from(await res.arrayBuffer()).toString('base64');
+ } catch (e) { /* falls back to createImageAsync inside Figma */ }
+ }));
+ }
+
async parseJSX(jsx, opts = {}) {
+ jsx = this.normalizeRoot(jsx);
// Find opening Frame tag
const openMatch = jsx.match(/]*)>/);
if (!openMatch) {
@@ -771,8 +808,9 @@ export class FigmaClient {
console.warn('[render] Supported elements: , , , , , ');
}
- // Pre-fetch any icon SVGs before code generation
+ // Pre-fetch any icon SVGs and images before code generation
const iconSvgMap = await this.prefetchIconSvgs(childElements);
+ await this.prefetchImages([props, ...childElements]);
// Generate code
return this.generateCode(props, childElements, iconSvgMap, opts);
@@ -881,12 +919,12 @@ export class FigmaClient {
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'],
+ Rect: ['name', 'image', 'imageScale', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
Rectangle: null, // alias of Rect, filled below
Ellipse: ['name', 'w', 'h', 'width', 'height', 'bg', 'fill', 'stroke', 'strokeWidth', 'strokeAlign',
'arc', 'arcStart', 'innerRadius', 'opacity', 'x', 'y', 'position'],
Circle: null, // alias of Ellipse, filled below
- Image: ['name', 'w', 'h', 'width', 'height', 'bg', 'fill', 'rounded', 'radius', 'opacity', 'x', 'y', 'position'],
+ Image: ['name', 'src', 'image', 'fit', 'imageScale', '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', 'variant', 'text', 'w', 'h', 'width', 'height'],
};
@@ -1039,7 +1077,7 @@ export class FigmaClient {
}
// Parse self-closing Slot elements
- const slotSelfCloseRegex = //g;
+ const slotSelfCloseRegex = //g;
while ((match = slotSelfCloseRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
@@ -1074,7 +1112,7 @@ export class FigmaClient {
// Parse Rectangle elements (self-closing)
// Use (?:\s+([^/]*?))? to allow Rect with or without attributes
- const rectRegex = /<(?:Rectangle|Rect)(?:\s+([^/]*?))?\s*\/>/g;
+ const rectRegex = /<(?:Rectangle|Rect)(?:\s+((?:[^/"]|"[^"]*")*?))?\s*\/>/g;
while ((match = rectRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
@@ -1088,7 +1126,7 @@ export class FigmaClient {
// Parse Ellipse / Circle elements (self-closing). Supports rings, spinners,
// donut/pie via arc (sweep°), arcStart (start°, 0=3 o'clock) and innerRadius.
- const ellipseRegex = /<(?:Ellipse|Circle)(?:\s+([^/]*?))?\s*\/>/g;
+ const ellipseRegex = /<(?:Ellipse|Circle)(?:\s+((?:[^/"]|"[^"]*")*?))?\s*\/>/g;
while ((match = ellipseRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
@@ -1101,7 +1139,8 @@ export class FigmaClient {
}
// Parse Image elements (self-closing) - creates placeholder rectangle
- const imageRegex = //g;
+ // quoted values may contain '/' (src="https://..."), so skip over them
+ const imageRegex = //g;
while ((match = imageRegex.exec(childrenStr)) !== null) {
const idx = match.index;
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
@@ -1321,7 +1360,7 @@ export class FigmaClient {
* ctx: { counter: {value}, prefix: string (el-name prefix, e.g. '0_'),
* iconSvgMap: {name: svg} }
*/
- generateChildrenCode(items, parentVar, parentFlex, ctx) {
+ generateChildrenCode(items, parentVar, parentFlex, ctx, parentItems = null) {
return items.map(item => {
const idx = ctx.prefix + (ctx.counter.value++);
if (item._type === 'text') {
@@ -1344,7 +1383,10 @@ export class FigmaClient {
return `{ value: ${Number(v)}, unit: 'PIXELS' }`;
};
const alignMapT = { left: 'LEFT', center: 'CENTER', right: 'RIGHT', justify: 'JUSTIFIED', start: 'LEFT', end: 'RIGHT' };
- const tAlign = item.align ? alignMapT[String(item.align).toLowerCase()] : null;
+ // A text in a column whose items are centered reads centered: without
+ // this a wrapping headline sits flush left inside a centered stack.
+ const inheritsCenter = !item.align && (parentFlex === 'col' || parentFlex === 'column') && parentItems === 'center';
+ const tAlign = item.align ? alignMapT[String(item.align).toLowerCase()] : (inheritsCenter ? 'CENTER' : null);
const tLineHeight = item.lineHeight !== undefined ? dimUnit(item.lineHeight) : null;
const tLetterSpacing = item.letterSpacing !== undefined ? dimUnit(item.letterSpacing) : null;
const tTruncate = item.truncate === true || item.truncate === 'true';
@@ -1503,7 +1545,7 @@ export class FigmaClient {
const { alignVal: fAlignVal, justifyVal: fJustifyVal } = resolveAlign(fFlex, item);
- const nestedChildren = item._children ? this.generateChildrenCode(item._children, `el${idx}`, fFlex, ctx) : '';
+ const nestedChildren = item._children ? this.generateChildrenCode(item._children, `el${idx}`, fFlex, ctx, item.items || item.align || null) : '';
const frameFillCode = fBg ? this.generateFillCode(fBg, `el${idx}`) : { code: `el${idx}.fills = [];`, usesVars: false };
const frameStrokeCode = fStroke ? this.generateStrokeCode(fStroke, `el${idx}`, fStrokeWidth, fStrokeAlign) : { code: '' };
const frameEffectsCode = this.generateEffectsCode(item, `el${idx}`);
@@ -1573,6 +1615,7 @@ export class FigmaClient {
${frameFillCode.code}
${frameStrokeCode.code}
${frameEffectsCode}
+ ${item.image ? this.generateImageFillCode(item.image, `el${idx}`, item.imageScale) : ''}
${isNone ? '' : `el${idx}.primaryAxisAlignItems = '${fJustifyVal}';
el${idx}.counterAxisAlignItems = '${fAlignVal}';`}
el${idx}.clipsContent = ${fClip};
@@ -1720,6 +1763,7 @@ export class FigmaClient {
el${idx}.resize(${iWidth}, ${iHeight});
el${idx}.cornerRadius = ${iRounded};
${imgFillCode.code}
+ ${item.src || item.image ? this.generateImageFillCode(item.src || item.image, `el${idx}`, item.imageScale || item.fit) : ''}
${parentVar}.appendChild(el${idx});
${this.genCommonNodeProps(item, `el${idx}`, parentFlex === 'none' || parentFlex === 'stack' || parentFlex === 'free')}`;
} else if (item._type === 'icon') {
@@ -1905,7 +1949,7 @@ export class FigmaClient {
const collected = this.collectFontsAndVarUsage(children);
if (collected.usesVars) usesVars = true;
- const childCode = this.generateChildrenCode(children, 'frame', flex, { counter: { value: 0 }, prefix: '', iconSvgMap });
+ const childCode = this.generateChildrenCode(children, 'frame', flex, { counter: { value: 0 }, prefix: '', iconSvgMap }, props.items || props.align || null);
const { alignVal, justifyVal } = resolveAlign(flex, props);
@@ -2216,8 +2260,12 @@ export class FigmaClient {
const finalMode = validModes.includes(mode) ? mode : 'FILL';
const safeName = elementVar.replace(/[^a-zA-Z0-9]/g, '');
// Image REPLACES fills (not appends) — user expects bg-style behavior
+ const bytes = this._imageData && this._imageData[url];
+ const create = bytes
+ ? `figma.createImage(figma.base64Decode(${JSON.stringify(bytes)}))`
+ : `await figma.createImageAsync(${JSON.stringify(url)})`;
return `
- const __img${safeName} = await figma.createImageAsync(${JSON.stringify(url)});
+ const __img${safeName} = ${create};
${elementVar}.fills = [{ type: 'IMAGE', imageHash: __img${safeName}.hash, scaleMode: '${finalMode}' }];`;
}
diff --git a/src/lib/agent-rules.js b/src/lib/agent-rules.js
index 90e0c40f..25ca1fa5 100644
--- a/src/lib/agent-rules.js
+++ b/src/lib/agent-rules.js
@@ -68,6 +68,11 @@ Helpers available inside \`eval\` (they handle the Figma pitfalls for you):
(\`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.
+- Images from the web: \`\` or
+ \`\`. figma-cli downloads the file itself.
+- Overlays and badges: \`position="absolute" x={0} y={0}\` takes a child out of the
+ auto-layout flow (a named child). Clip children to rounded corners with \`overflow="hidden"\`.
+- Text in a column with \`items="center"\` is centered automatically; set \`align\` to override.
- \`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
diff --git a/tests/figma-client.test.js b/tests/figma-client.test.js
index e55153c6..4a5b36b6 100644
--- a/tests/figma-client.test.js
+++ b/tests/figma-client.test.js
@@ -97,3 +97,24 @@ it('root frame honors per-side padding in render and render-batch', async () =>
assert.ok(code.includes(want), 'render-batch: ' + want);
}
});
+
+describe('self-closing tags with URLs and centered text', () => {
+ const c = new FigmaClient();
+ it('parses and despite the slashes', () => {
+ const kids = c.parseChildren('');
+ assert.deepStrictEqual(kids.map(k => [k._type, k.name]), [['image', 'Hero'], ['rect', 'R']]);
+ assert.strictEqual(kids[0].src, 'https://example.com/a/b.png');
+ });
+ it('accepts a lone Rectangle as the root', async () => {
+ const code = await c.parseJSX('');
+ assert.ok(code.includes('"Box"'));
+ });
+ it('centers text in a column with items="center" unless align is set', async () => {
+ const centered = await c.parseJSX('Hi');
+ assert.ok(centered.includes("textAlignHorizontal = 'CENTER'"));
+ const left = await c.parseJSX('Hi');
+ assert.ok(!left.includes("textAlignHorizontal = 'CENTER'"));
+ const plain = await c.parseJSX('Hi');
+ assert.ok(!plain.includes("textAlignHorizontal = 'CENTER'"));
+ });
+});
diff --git a/tests/live/parity-harness.mjs b/tests/live/parity-harness.mjs
index f0f37e44..d9f21a85 100644
--- a/tests/live/parity-harness.mjs
+++ b/tests/live/parity-harness.mjs
@@ -184,16 +184,40 @@ const diff = (a, b, trail = 'root', out = []) => {
return out;
};
+/** Remove the scratch page and put the user back where they were. */
+function teardown(previousPageId) {
+ try {
+ evalIn(`(async () => {
+ await figma.loadAllPagesAsync();
+ const p = figma.root.children.find(c => c.name === ${JSON.stringify(PAGE)});
+ const prev = ${JSON.stringify(String(previousPageId || ''))};
+ const back = prev ? await figma.getNodeByIdAsync(prev) : null;
+ if (back && back.type === 'PAGE') await figma.setCurrentPageAsync(back);
+ else if (p) { const other = figma.root.children.find(x => x !== p); if (other) await figma.setCurrentPageAsync(other); }
+ if (p) p.remove();
+ return 'torn down';
+ })()`);
+ } catch (e) {
+ // Best effort — never mask the real result, but do say so: a silent
+ // teardown failure is how scratch pages accumulate in someone's file.
+ console.error(`⚠ teardown failed: ${e && e.message ? e.message.split('\n')[0] : e}`);
+ }
+}
+
async function main() {
const filter = process.argv[2];
const cases = filter ? CASES.filter((c) => c.name.includes(filter)) : CASES;
- evalIn(`(async () => {
+ // Remember where the user was: the harness switches pages to build on its
+ // own scratch page, and leaving them somewhere else (or leaving the scratch
+ // page behind at all) is debris in someone's real file.
+ const previousPage = evalIn(`(async () => {
let p = figma.root.children.find(c => c.name === ${JSON.stringify(PAGE)});
+ const prev = figma.currentPage.id;
if (!p) { p = figma.createPage(); p.name = ${JSON.stringify(PAGE)}; }
await figma.setCurrentPageAsync(p);
for (const c of [...p.children]) c.remove();
- return 'clean';
+ return prev;
})()`);
let failed = 0;
@@ -235,7 +259,15 @@ async function main() {
` render ${timing.single}ms · render-batch ${timing.batch}ms` +
` (${(timing.single / Math.max(timing.batch, 1)).toFixed(1)}x)`
);
+ teardown(previousPage);
process.exit(failed ? 1 : 0);
}
-if (process.argv[1] === fileURLToPath(import.meta.url)) main();
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ // A case that throws must not leave the scratch page behind either.
+ main().catch((e) => {
+ teardown(null);
+ console.error(e && e.message ? e.message : e);
+ process.exit(1);
+ });
+}