feat(figma-api): add exposeInstanceSwap for real instance-swap component properties

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 <noreply@anthropic.com>
This commit is contained in:
xemc 2026-08-18 08:14:38 +10:00 committed by Danila Poyarkov
parent d178a7fd70
commit 7dc46bb993
5 changed files with 194 additions and 50 deletions

View file

@ -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<FigmaComponentNode>,
parent: FigmaNodeProxy,
index?: number
): FigmaComponentSetNode
combineAsVariants(
nodes: ReadonlyArray<ComponentNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): ComponentSetNode
combineAsVariants(
nodes: ReadonlyArray<ComponentNode | FigmaComponentNode>,
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<FigmaNodeProxy>,
candidates: ReadonlyArray<FigmaNodeProxy>,
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 ---

View file

@ -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'

View file

@ -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) }
}

View file

@ -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,

View file

@ -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()
})
})