From 7dc46bb993bdecd22726c29f27ae13a552459b3e Mon Sep 17 00:00:00 2001 From: xemc <0xemc@protonmail.com> Date: Tue, 18 Aug 2026 08:14:38 +1000 Subject: [PATCH 1/3] feat(figma-api): add exposeInstanceSwap for real instance-swap component properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds figma.exposeInstanceSwap(slots, candidates, propertyName), which exposes one or more nested instances as an instance-swap slot — the enclosing component (or component set, walking up past intermediate variant members) gets an INSTANCE_SWAP property offering the given candidates, and each slot instance is tagged to respond to it. Mirrors Figma's "Create component property > Instance swap", letting a designer pick which component fills a slot (e.g. an icon on a button) instead of the slot being baked-in static content. Exposed as the expose_instance_swap MCP/CLI/chat tool. Co-Authored-By: Claude Sonnet 5 --- packages/core/src/figma-api/index.ts | 93 +++++++++++++----- packages/core/src/tools/create.ts | 2 +- packages/core/src/tools/create/components.ts | 47 +++++---- packages/core/src/tools/registry-extended.ts | 4 +- .../api/create/expose-instance-swap.test.ts | 98 +++++++++++++++++++ 5 files changed, 194 insertions(+), 50 deletions(-) create mode 100644 tests/engine/figma/api/create/expose-instance-swap.test.ts diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index e7d4c7531..c4e665584 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -1,4 +1,5 @@ import type { + ComponentPropertyDefinition, SceneGraph, SceneNode as CoreSceneNode, NodeType, @@ -18,13 +19,12 @@ import { canMakeBooleanSourceNode } from '#core/canvas/boolean' import { flattenNodesToVectorProps } from '#core/canvas/flatten' import { IS_BROWSER } from '#core/constants' import type { RasterExportFormat } from '#core/io/formats/raster' +import { randomHex } from '#core/random' import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status' -import { combineComponentsAsVariants } from './components' import type { FigmaBooleanOperationNode, FigmaComponentNode, - FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -51,7 +51,6 @@ export { FigmaNodeProxy } from './proxy' export type { FigmaBooleanOperationNode, FigmaComponentNode, - FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -253,8 +252,6 @@ export class FigmaAPI implements NodeProxyHost { layoutMode: raw.layoutMode, primaryAxisAlign: raw.primaryAxisAlign, counterAxisAlign: raw.counterAxisAlign, - primaryAxisSizing: raw.primaryAxisSizing, - counterAxisSizing: raw.counterAxisSizing, itemSpacing: raw.itemSpacing, paddingTop: raw.paddingTop, paddingRight: raw.paddingRight, @@ -270,28 +267,70 @@ export class FigmaAPI implements NodeProxyHost { return this.wrapNode(comp.id) } - combineAsVariants( - nodes: ReadonlyArray, - parent: FigmaNodeProxy, - index?: number - ): FigmaComponentSetNode - combineAsVariants( - nodes: ReadonlyArray, - parent: BaseNode & ChildrenMixin, - index?: number - ): ComponentSetNode - combineAsVariants( - nodes: ReadonlyArray, - parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy, - index?: number - ): FigmaComponentSetNode { - const componentSet = combineComponentsAsVariants( - this.graph, - nodes.map((node) => this._nodeId(node)), - this._nodeId(parent), - index - ) - return this.wrapNode(componentSet.id) as FigmaComponentSetNode + private _findPropertyHost(nodeId: string | null): CoreSceneNode | null { + let current = nodeId ? this.graph.getNode(nodeId) : null + let fallbackComponent: CoreSceneNode | null = null + while (current) { + if (current.type === 'COMPONENT_SET') return current + if (current.type === 'COMPONENT' && !fallbackComponent) fallbackComponent = current + current = current.parentId ? this.graph.getNode(current.parentId) : null + } + return fallbackComponent + } + + /** + * Exposes one or more nested instances as an instance-swap slot: the + * enclosing component (or component set) gets an INSTANCE_SWAP property + * offering `candidates`, and each slot instance is tagged to respond to it + * — mirrors Figma's "Create component property > Instance swap". + */ + exposeInstanceSwap( + slots: ReadonlyArray, + candidates: ReadonlyArray, + propertyName = 'Instance' + ): FigmaNodeProxy { + if (slots.length === 0) throw new Error('Provide at least one instance to expose') + if (candidates.length === 0) throw new Error('Provide at least one candidate component') + + const slotNodes = slots.map((s) => this.graph.getNode(s[INTERNAL_ID])) + if (!slotNodes.every((n): n is CoreSceneNode => n?.type === 'INSTANCE')) { + throw new Error('exposeInstanceSwap requires INSTANCE nodes') + } + + const candidateNodes = candidates.map((c) => this.graph.getNode(c[INTERNAL_ID])) + if (!candidateNodes.every((n): n is CoreSceneNode => n?.type === 'COMPONENT')) { + throw new Error('Candidates must be COMPONENT nodes') + } + const candidateIds = candidateNodes.map((n) => n.id) + + const host = this._findPropertyHost(slotNodes[0].parentId) + if (!host) throw new Error('Instance must be nested inside a COMPONENT or COMPONENT_SET') + const sameHost = slotNodes.every((n) => this._findPropertyHost(n.parentId)?.id === host.id) + if (!sameHost) throw new Error('All instances must belong to the same component or component set') + + const propId = `prop:${randomHex(8)}` + const propDef: ComponentPropertyDefinition = { + id: propId, + name: propertyName, + type: 'INSTANCE_SWAP', + defaultValue: slotNodes[0].componentId ?? candidateIds[0], + preferredValues: candidateIds + } + + this.graph.updateNode(host.id, { + componentPropertyDefinitions: [...host.componentPropertyDefinitions, propDef] + }) + + for (const node of slotNodes) { + this.graph.updateNode(node.id, { + componentPropertyReferences: [ + ...node.componentPropertyReferences.filter((r) => r.field !== 'INSTANCE_SWAP'), + { propertyId: propId, field: 'INSTANCE_SWAP' } + ] + }) + } + + return this.wrapNode(host.id) } // --- Variables --- diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index 548ff939e..a45c44c19 100644 --- a/packages/core/src/tools/create.ts +++ b/packages/core/src/tools/create.ts @@ -1,5 +1,5 @@ export { createPage, createShape, createSlice } from './create/basic' -export { combineAsVariants, createComponent, createInstance } from './create/components' +export { createComponent, createInstance, exposeInstanceSwap } from './create/components' export { fetchIconsTool, insertIcon, searchIconsTool } from './create/icons' export { render } from './create/render' export { importSVG } from './create/svg' diff --git a/packages/core/src/tools/create/components.ts b/packages/core/src/tools/create/components.ts index c91bfe898..e72738b7f 100644 --- a/packages/core/src/tools/create/components.ts +++ b/packages/core/src/tools/create/components.ts @@ -1,5 +1,5 @@ -import type { FigmaComponentNode } from '#core/figma-api' -import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema' +import type { FigmaNodeProxy } from '#core/figma-api' +import { defineTool, nodeSummary } from '#core/tools/schema' export const createComponent = defineTool({ name: 'create_component', @@ -35,29 +35,36 @@ export const createInstance = defineTool({ } }) -export const combineAsVariants = defineTool({ - name: 'combine_as_variants', +export const exposeInstanceSwap = defineTool({ + name: 'expose_instance_swap', mutates: true, description: - 'Combine components sharing a parent into a component set (variant set). Components named ' + - '"Category/Value" (e.g. "Button/Primary") derive variant properties from the name segments.', + 'Expose one or more nested instances as an instance-swap slot, so instances of the enclosing ' + + 'component can pick which component fills it (e.g. an icon slot on a button). All slot instances ' + + 'must live inside the same component or component set.', params: { - ids: { type: 'string[]', description: 'Component node IDs to combine', required: true } + instance_ids: { + type: 'string[]', + description: 'Instance node IDs to expose as the swap slot (one per variant that has this slot)', + required: true + }, + candidate_ids: { + type: 'string[]', + description: 'Component node IDs the designer can swap the slot to', + required: true + }, + property_name: { type: 'string', description: 'Name for the property (default: "Instance")' } }, - execute: (figma, { ids }) => { - const nodes = requireNodes(figma, ids) - if (!nodes) return { error: 'One or more node IDs were not found' } - if (nodes.length < 2) return { error: 'Need at least 2 components to combine as variants' } - if (!nodes.every((node): node is FigmaComponentNode => node.type === 'COMPONENT')) { - return { error: 'combineAsVariants requires COMPONENT nodes' } - } - const parent = nodes[0].parent ?? figma.currentPage - if (!nodes.every((node) => node.parent?.id === parent.id)) { - return { error: 'combineAsVariants requires components to share a parent' } - } + execute: (figma, { instance_ids, candidate_ids, property_name }) => { + const slots = instance_ids.map((id) => figma.getNodeById(id)).filter((n): n is FigmaNodeProxy => n !== null) + if (slots.length !== instance_ids.length) return { error: 'One or more instance IDs were not found' } + const candidates = candidate_ids + .map((id) => figma.getNodeById(id)) + .filter((n): n is FigmaNodeProxy => n !== null) + if (candidates.length !== candidate_ids.length) return { error: 'One or more candidate IDs were not found' } try { - const componentSet = figma.combineAsVariants(nodes, parent) - return nodeSummary(componentSet) + const host = figma.exposeInstanceSwap(slots, candidates, property_name) + return nodeSummary(host) } catch (error) { return { error: error instanceof Error ? error.message : String(error) } } diff --git a/packages/core/src/tools/registry-extended.ts b/packages/core/src/tools/registry-extended.ts index 9d9cb38f1..6dc98b02a 100644 --- a/packages/core/src/tools/registry-extended.ts +++ b/packages/core/src/tools/registry-extended.ts @@ -9,13 +9,13 @@ import { } from './analyze' import { designToComponentMap, designToTokens } from './codegen' import { - combineAsVariants, createComponent, createInstance, createPage, createShape, createSlice, createVector, + exposeInstanceSwap, fetchIconsTool, importSVG, insertIcon, @@ -127,7 +127,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [ fetchIconsTool, createComponent, createInstance, - combineAsVariants, + exposeInstanceSwap, createPage, createVector, createSlice, diff --git a/tests/engine/figma/api/create/expose-instance-swap.test.ts b/tests/engine/figma/api/create/expose-instance-swap.test.ts new file mode 100644 index 000000000..89905647e --- /dev/null +++ b/tests/engine/figma/api/create/expose-instance-swap.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from '../helpers' + +describe('exposeInstanceSwap', () => { + test('adds an INSTANCE_SWAP property to the host component and tags the slot', () => { + const api = createAPI() + const host = api.createComponent() + host.name = 'Button' + host.resize(120, 40) + + const iconA = api.createComponent() + iconA.name = 'Icon/A' + iconA.resize(16, 16) + const iconB = api.createComponent() + iconB.name = 'Icon/B' + iconB.resize(16, 16) + + const slot = iconA.createInstance() + host.appendChild(slot) + + const result = api.exposeInstanceSwap([slot], [iconA, iconB], 'Icon') + const raw = api.graph.getNode(result.id) + + expect(result.id).toBe(host.id) + expect(raw?.componentPropertyDefinitions?.length).toBe(1) + expect(raw?.componentPropertyDefinitions?.[0].type).toBe('INSTANCE_SWAP') + expect(raw?.componentPropertyDefinitions?.[0].preferredValues?.slice().sort()).toEqual( + [iconA.id, iconB.id].sort() + ) + + const slotRaw = api.graph.getNode(slot.id) + expect(slotRaw?.componentPropertyReferences?.length).toBe(1) + expect(slotRaw?.componentPropertyReferences?.[0].field).toBe('INSTANCE_SWAP') + }) + + test('shares one property across slots in different variants of the same set', () => { + const api = createAPI() + const a = api.createComponent() + a.name = 'Primary' + a.resize(120, 40) + const b = api.createComponent() + b.name = 'Secondary' + b.resize(120, 40) + + const icon = api.createComponent() + icon.name = 'Icon/A' + icon.resize(16, 16) + + const slotA = icon.createInstance() + a.appendChild(slotA) + const slotB = icon.createInstance() + b.appendChild(slotB) + + const set = api.graph.createNode('COMPONENT_SET', api.currentPageId, { name: 'Button' }) + api.graph.reparentNode(a.id, set.id) + api.graph.reparentNode(b.id, set.id) + const result = api.exposeInstanceSwap([slotA, slotB], [icon], 'Icon') + + expect(result.id).toBe(set.id) + const raw = api.graph.getNode(set.id) + expect(raw?.componentPropertyDefinitions?.some((d) => d.type === 'INSTANCE_SWAP')).toBe(true) + }) + + test('rejects non-instance slots', () => { + const api = createAPI() + const host = api.createComponent() + const icon = api.createComponent() + const frame = api.createFrame() + host.appendChild(frame) + expect(() => api.exposeInstanceSwap([frame], [icon])).toThrow() + }) + + test('rejects non-component candidates', () => { + const api = createAPI() + const host = api.createComponent() + const icon = api.createComponent() + const slot = icon.createInstance() + host.appendChild(slot) + const notAComponent = api.createFrame() + expect(() => api.exposeInstanceSwap([slot], [notAComponent])).toThrow() + }) + + test('rejects slots from unrelated hosts', () => { + const api = createAPI() + const hostA = api.createComponent() + hostA.resize(100, 40) + const hostB = api.createComponent() + hostB.resize(100, 40) + const icon = api.createComponent() + icon.resize(16, 16) + const slotA = icon.createInstance() + hostA.appendChild(slotA) + const slotB = icon.createInstance() + hostB.appendChild(slotB) + expect(() => api.exposeInstanceSwap([slotA, slotB], [icon])).toThrow() + }) +}) From 419a8330c2edab1671dc51772ed169a4a0ad2a06 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 29 Aug 2026 16:31:08 +0300 Subject: [PATCH 2/3] feat(figma-api): sync instance swap feature with master --- packages/core/src/figma-api/components.ts | 72 +++++++++++++++- packages/core/src/figma-api/index.ts | 86 ++++++-------------- packages/core/src/tools/create.ts | 7 +- packages/core/src/tools/create/components.ts | 79 ++++++++++++------ 4 files changed, 155 insertions(+), 89 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index 73dde2475..6582b674a 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -1,11 +1,81 @@ -import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import type { ComponentPropertyDefinition, SceneGraph, SceneNode } from '@open-pencil/scene-graph' import { computeAbsoluteBounds } from '@open-pencil/scene-graph/geometry' import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties' import { randomHex } from '#core/random' +import type { FigmaNodeProxy } from './proxy' + const COMPONENT_SET_PADDING = 40 +export function exposeInstanceSwap( + graph: SceneGraph, + slots: ReadonlyArray, + candidates: ReadonlyArray, + propertyName = 'Instance' +): SceneNode { + if (slots.length === 0) throw new Error('Provide at least one instance to expose') + if (candidates.length === 0) throw new Error('Provide at least one candidate component') + const name = propertyName.trim() + if (!name) throw new Error('Property name must not be empty') + const slotNodes = slots.map((slot) => graph.getNode(slot.id)) + if (new Set(slotNodes.map((node) => node?.id)).size !== slotNodes.length) + throw new Error('exposeInstanceSwap requires distinct INSTANCE nodes') + if (!slotNodes.every((node): node is SceneNode => node?.type === 'INSTANCE')) + throw new Error('exposeInstanceSwap requires INSTANCE nodes') + if ( + slotNodes.some((node) => + node.componentPropertyReferences.some((ref) => ref.field === 'INSTANCE_SWAP') + ) + ) + throw new Error('Instance already has an INSTANCE_SWAP property') + const candidateNodes = candidates.map((candidate) => graph.getNode(candidate.id)) + if ( + !candidateNodes.every( + (node): node is SceneNode => node?.type === 'COMPONENT' || node?.type === 'COMPONENT_SET' + ) + ) + throw new Error('Candidates must be COMPONENT or COMPONENT_SET nodes') + const candidateIds = [...new Set(candidateNodes.map((node) => node.id))] + if (candidateIds.length !== candidateNodes.length) throw new Error('Candidates must be distinct') + const host = findPropertyHost(graph, slotNodes[0].parentId) + if (!host) throw new Error('Instance must be nested inside a COMPONENT or COMPONENT_SET') + if (host.componentPropertyDefinitions.some((definition) => definition.name === name)) + throw new Error(`A component property named "${name}" already exists`) + if (!slotNodes.every((node) => findPropertyHost(graph, node.parentId)?.id === host.id)) + throw new Error('All instances must belong to the same component or component set') + const definition: ComponentPropertyDefinition = { + id: `prop:${randomHex(8)}`, + name, + type: 'INSTANCE_SWAP', + defaultValue: slotNodes[0].componentId ?? candidateIds[0], + preferredValues: candidateIds + } + graph.updateNode(host.id, { + componentPropertyDefinitions: [...host.componentPropertyDefinitions, definition] + }) + for (const node of slotNodes) { + graph.updateNode(node.id, { + componentPropertyReferences: [ + ...node.componentPropertyReferences, + { propertyId: definition.id, field: 'INSTANCE_SWAP' } + ] + }) + } + return host +} + +function findPropertyHost(graph: SceneGraph, nodeId: string | null): SceneNode | null { + let current = nodeId ? graph.getNode(nodeId) : null + let fallback: SceneNode | null = null + while (current) { + if (current.type === 'COMPONENT_SET') return current + if (current.type === 'COMPONENT' && !fallback) fallback = current + current = current.parentId ? graph.getNode(current.parentId) : null + } + return fallback +} + function requireDistinctComponents(graph: SceneGraph, nodeIds: ReadonlyArray): SceneNode[] { if (nodeIds.length === 0) throw new Error('Need at least 1 component to combine as variants') if (new Set(nodeIds).size !== nodeIds.length) { diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index c4e665584..9b6fb2d42 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -1,5 +1,4 @@ import type { - ComponentPropertyDefinition, SceneGraph, SceneNode as CoreSceneNode, NodeType, @@ -19,12 +18,13 @@ import { canMakeBooleanSourceNode } from '#core/canvas/boolean' import { flattenNodesToVectorProps } from '#core/canvas/flatten' import { IS_BROWSER } from '#core/constants' import type { RasterExportFormat } from '#core/io/formats/raster' -import { randomHex } from '#core/random' import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status' +import { combineComponentsAsVariants, exposeInstanceSwap } from './components' import type { FigmaBooleanOperationNode, FigmaComponentNode, + FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -51,6 +51,7 @@ export { FigmaNodeProxy } from './proxy' export type { FigmaBooleanOperationNode, FigmaComponentNode, + FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -267,74 +268,39 @@ export class FigmaAPI implements NodeProxyHost { return this.wrapNode(comp.id) } - private _findPropertyHost(nodeId: string | null): CoreSceneNode | null { - let current = nodeId ? this.graph.getNode(nodeId) : null - let fallbackComponent: CoreSceneNode | null = null - while (current) { - if (current.type === 'COMPONENT_SET') return current - if (current.type === 'COMPONENT' && !fallbackComponent) fallbackComponent = current - current = current.parentId ? this.graph.getNode(current.parentId) : null - } - return fallbackComponent + combineAsVariants( + nodes: ReadonlyArray, + parent: FigmaNodeProxy, + index?: number + ): FigmaComponentSetNode + combineAsVariants( + nodes: ReadonlyArray, + parent: BaseNode & ChildrenMixin, + index?: number + ): ComponentSetNode + combineAsVariants( + nodes: ReadonlyArray, + parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy, + index?: number + ): FigmaComponentSetNode { + const componentSet = combineComponentsAsVariants( + this.graph, + nodes.map((node) => this._nodeId(node)), + this._nodeId(parent), + index + ) + return this.wrapNode(componentSet.id) as FigmaComponentSetNode } - /** - * Exposes one or more nested instances as an instance-swap slot: the - * enclosing component (or component set) gets an INSTANCE_SWAP property - * offering `candidates`, and each slot instance is tagged to respond to it - * — mirrors Figma's "Create component property > Instance swap". - */ exposeInstanceSwap( slots: ReadonlyArray, candidates: ReadonlyArray, propertyName = 'Instance' ): FigmaNodeProxy { - if (slots.length === 0) throw new Error('Provide at least one instance to expose') - if (candidates.length === 0) throw new Error('Provide at least one candidate component') - - const slotNodes = slots.map((s) => this.graph.getNode(s[INTERNAL_ID])) - if (!slotNodes.every((n): n is CoreSceneNode => n?.type === 'INSTANCE')) { - throw new Error('exposeInstanceSwap requires INSTANCE nodes') - } - - const candidateNodes = candidates.map((c) => this.graph.getNode(c[INTERNAL_ID])) - if (!candidateNodes.every((n): n is CoreSceneNode => n?.type === 'COMPONENT')) { - throw new Error('Candidates must be COMPONENT nodes') - } - const candidateIds = candidateNodes.map((n) => n.id) - - const host = this._findPropertyHost(slotNodes[0].parentId) - if (!host) throw new Error('Instance must be nested inside a COMPONENT or COMPONENT_SET') - const sameHost = slotNodes.every((n) => this._findPropertyHost(n.parentId)?.id === host.id) - if (!sameHost) throw new Error('All instances must belong to the same component or component set') - - const propId = `prop:${randomHex(8)}` - const propDef: ComponentPropertyDefinition = { - id: propId, - name: propertyName, - type: 'INSTANCE_SWAP', - defaultValue: slotNodes[0].componentId ?? candidateIds[0], - preferredValues: candidateIds - } - - this.graph.updateNode(host.id, { - componentPropertyDefinitions: [...host.componentPropertyDefinitions, propDef] - }) - - for (const node of slotNodes) { - this.graph.updateNode(node.id, { - componentPropertyReferences: [ - ...node.componentPropertyReferences.filter((r) => r.field !== 'INSTANCE_SWAP'), - { propertyId: propId, field: 'INSTANCE_SWAP' } - ] - }) - } - + const host = exposeInstanceSwap(this.graph, slots, candidates, propertyName) return this.wrapNode(host.id) } - // --- Variables --- - getVariableById(id: string): Variable | null { return this.graph.variables.get(id) ?? null } diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index a45c44c19..fad3783fd 100644 --- a/packages/core/src/tools/create.ts +++ b/packages/core/src/tools/create.ts @@ -1,5 +1,10 @@ export { createPage, createShape, createSlice } from './create/basic' -export { createComponent, createInstance, exposeInstanceSwap } from './create/components' +export { + combineAsVariants, + createComponent, + createInstance, + exposeInstanceSwap +} from './create/components' export { fetchIconsTool, insertIcon, searchIconsTool } from './create/icons' export { render } from './create/render' export { importSVG } from './create/svg' diff --git a/packages/core/src/tools/create/components.ts b/packages/core/src/tools/create/components.ts index e72738b7f..b6585a42a 100644 --- a/packages/core/src/tools/create/components.ts +++ b/packages/core/src/tools/create/components.ts @@ -1,5 +1,5 @@ -import type { FigmaNodeProxy } from '#core/figma-api' -import { defineTool, nodeSummary } from '#core/tools/schema' +import type { FigmaComponentNode, FigmaNodeProxy } from '#core/figma-api' +import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema' export const createComponent = defineTool({ name: 'create_component', @@ -35,36 +35,61 @@ export const createInstance = defineTool({ } }) -export const exposeInstanceSwap = defineTool({ - name: 'expose_instance_swap', +export const combineAsVariants = defineTool({ + name: 'combine_as_variants', mutates: true, description: - 'Expose one or more nested instances as an instance-swap slot, so instances of the enclosing ' + - 'component can pick which component fills it (e.g. an icon slot on a button). All slot instances ' + - 'must live inside the same component or component set.', + 'Combine components sharing a parent into a component set (variant set). Components named ' + + '"Category/Value" (e.g. "Button/Primary") derive variant properties from the name segments.', params: { - instance_ids: { - type: 'string[]', - description: 'Instance node IDs to expose as the swap slot (one per variant that has this slot)', - required: true - }, - candidate_ids: { - type: 'string[]', - description: 'Component node IDs the designer can swap the slot to', - required: true - }, - property_name: { type: 'string', description: 'Name for the property (default: "Instance")' } + ids: { type: 'string[]', description: 'Component node IDs to combine', required: true } }, - execute: (figma, { instance_ids, candidate_ids, property_name }) => { - const slots = instance_ids.map((id) => figma.getNodeById(id)).filter((n): n is FigmaNodeProxy => n !== null) - if (slots.length !== instance_ids.length) return { error: 'One or more instance IDs were not found' } - const candidates = candidate_ids - .map((id) => figma.getNodeById(id)) - .filter((n): n is FigmaNodeProxy => n !== null) - if (candidates.length !== candidate_ids.length) return { error: 'One or more candidate IDs were not found' } + execute: (figma, { ids }) => { + const nodes = requireNodes(figma, ids) + if (!nodes) return { error: 'One or more node IDs were not found' } + if (nodes.length < 2) return { error: 'Need at least 2 components to combine as variants' } + if (!nodes.every((node): node is FigmaComponentNode => node.type === 'COMPONENT')) { + return { error: 'combineAsVariants requires COMPONENT nodes' } + } + const parent = nodes[0].parent ?? figma.currentPage + if (!nodes.every((node) => node.parent?.id === parent.id)) { + return { error: 'combineAsVariants requires components to share a parent' } + } try { - const host = figma.exposeInstanceSwap(slots, candidates, property_name) - return nodeSummary(host) + const componentSet = figma.combineAsVariants(nodes, parent) + return nodeSummary(componentSet) + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } + } +}) + +export const exposeInstanceSwap = defineTool({ + name: 'expose_instance_swap', + mutates: true, + description: 'Expose nested instances as an instance-swap slot on their component.', + params: { + instance_ids: { type: 'string[]', description: 'Instance node IDs', required: true }, + candidate_ids: { + type: 'string[]', + description: 'Candidate component node IDs', + required: true + }, + property_name: { type: 'string', description: 'Property name' } + }, + execute: (figma, { instance_ids, candidate_ids, property_name }) => { + const slots = instance_ids + .map((id) => figma.getNodeById(id)) + .filter((node): node is FigmaNodeProxy => node !== null) + if (slots.length !== instance_ids.length) + return { error: 'One or more instance IDs were not found' } + const candidates = candidate_ids + .map((id) => figma.getNodeById(id)) + .filter((node): node is FigmaNodeProxy => node !== null) + if (candidates.length !== candidate_ids.length) + return { error: 'One or more candidate IDs were not found' } + try { + return nodeSummary(figma.exposeInstanceSwap(slots, candidates, property_name)) } catch (error) { return { error: error instanceof Error ? error.message : String(error) } } From 8bd6f78bfb71e0fb69f1a0f1217189c6cdfb1707 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 29 Aug 2026 18:19:26 +0300 Subject: [PATCH 3/3] fix(figma-api): sync instance swap with master --- packages/core/src/figma-api/index.ts | 2 ++ packages/core/src/tools/registry-extended.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index 9b6fb2d42..8f1201adc 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -253,6 +253,8 @@ export class FigmaAPI implements NodeProxyHost { layoutMode: raw.layoutMode, primaryAxisAlign: raw.primaryAxisAlign, counterAxisAlign: raw.counterAxisAlign, + primaryAxisSizing: raw.primaryAxisSizing, + counterAxisSizing: raw.counterAxisSizing, itemSpacing: raw.itemSpacing, paddingTop: raw.paddingTop, paddingRight: raw.paddingRight, diff --git a/packages/core/src/tools/registry-extended.ts b/packages/core/src/tools/registry-extended.ts index 6dc98b02a..4c67d8155 100644 --- a/packages/core/src/tools/registry-extended.ts +++ b/packages/core/src/tools/registry-extended.ts @@ -16,6 +16,7 @@ import { createSlice, createVector, exposeInstanceSwap, + combineAsVariants, fetchIconsTool, importSVG, insertIcon, @@ -125,6 +126,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [ searchIconsTool, insertIcon, fetchIconsTool, + combineAsVariants, createComponent, createInstance, exposeInstanceSwap,