feat(figma-api): add combineAsVariants for real component variant sets

figma.combineAsVariants() was unimplemented in the scripting/automation
layer (figma-api/proxy.ts's compatibility surface), even though the editor
UI already has a full equivalent — createComponentSetFromComponents()
(editor/components.ts) plus wrapSelectionInContainer()
(editor/structure/container-wrap.ts) — wired to the app's own "Create
component set" menu action.

This ports that logic down to FigmaAPI (the class MCP tools, the CLI, and
AI chat scripting all run against): wraps selected COMPONENT nodes sharing
a parent into a COMPONENT_SET, and derives variant property definitions from
"Category/Value" node-name segments, matching the editor behavior exactly.
Drops the undo-stack push and selection-state writes the editor path has,
since neither concept exists at the scripting layer — everything else is
the same primitives (SceneGraph.createNode/insertChildAt/reparentNode),
which FigmaAPI already had direct access to.

Also adds a matching combine_as_variants tool (packages/core/src/tools/
create/components.ts) registered in EXTENDED_TOOLS, so it's exposed
through MCP, the CLI, and AI chat alongside the existing create_component/
create_instance tools.
This commit is contained in:
xemc 2026-08-18 06:15:48 +10:00 committed by Danila Poyarkov
parent 4b8bf021f6
commit b01ed830e8
5 changed files with 190 additions and 2 deletions

View file

@ -1,4 +1,5 @@
import type {
ComponentPropertyDefinition,
SceneGraph,
SceneNode as CoreSceneNode,
NodeType,
@ -8,7 +9,7 @@ import type {
VariableValue
} from '@open-pencil/scene-graph'
import { copyFills, copyStrokes, copyEffects } from '@open-pencil/scene-graph/copy'
import { computeBounds } from '@open-pencil/scene-graph/geometry'
import { computeAbsoluteBounds, computeBounds } from '@open-pencil/scene-graph/geometry'
import { computeImageHash } from '@open-pencil/scene-graph/images'
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
@ -18,6 +19,7 @@ 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 type {
@ -265,6 +267,107 @@ export class FigmaAPI implements NodeProxyHost {
return this.wrapNode(comp.id)
}
private _isTopLevel(parentId: string | null): boolean {
return !parentId || parentId === this.graph.rootId || parentId === this._currentPageId
}
private _wrapNodesInComponentSet(rawNodes: CoreSceneNode[]): FigmaNodeProxy | null {
const parentId = rawNodes[0].parentId ?? this._currentPageId
const sameParent = rawNodes.every((n) => (n.parentId ?? this._currentPageId) === parentId)
if (!sameParent) return null
const parent = this.graph.getNode(parentId)
if (!parent) return null
const nodeIds = rawNodes.map((n) => n.id)
const {
x: minX,
y: minY,
width: bw,
height: bh
} = computeAbsoluteBounds(rawNodes, (id) => this.graph.getAbsolutePosition(id))
const maxX = minX + bw
const maxY = minY + bh
const parentAbs = this._isTopLevel(parentId)
? { x: 0, y: 0 }
: this.graph.getAbsolutePosition(parentId)
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
const padding = 40
const containerNode = this.graph.createNode('COMPONENT_SET', parentId, {
name: rawNodes[0].name.split('/')[0]?.trim() || 'Component Set',
x: minX - parentAbs.x - padding,
y: minY - parentAbs.y - padding,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
fills: [{ type: 'SOLID', color: { r: 0.96, g: 0.96, b: 0.96, a: 1 }, opacity: 1, visible: true }]
})
this.graph.insertChildAt(containerNode.id, parentId, firstIndex)
for (const id of nodeIds) {
this.graph.reparentNode(id, containerNode.id)
}
return this.wrapNode(containerNode.id)
}
/**
* Wraps components sharing a common parent into a COMPONENT_SET, deriving
* variant properties from `Category/Value` name segments (mirrors the
* editor's createComponentSetFromComponents, minus undo/selection state).
*/
combineAsVariants(nodes: ReadonlyArray<FigmaNodeProxy>): FigmaNodeProxy {
if (nodes.length < 2) throw new Error('Need at least 2 components to combine as variants')
const rawNodes = nodes.map((n) => this.graph.getNode(n[INTERNAL_ID]))
if (!rawNodes.every((n): n is CoreSceneNode => n?.type === 'COMPONENT')) {
throw new Error('combineAsVariants requires COMPONENT nodes')
}
const container = this._wrapNodesInComponentSet(rawNodes)
if (!container) throw new Error('Components must share the same parent')
const slashCounts = rawNodes.map((n) => (n.name.match(/\//g) ?? []).length)
const hasConsistentSlashes = slashCounts.every((c) => c === slashCounts[0]) && slashCounts[0] > 0
if (hasConsistentSlashes) {
const propCount = slashCounts[0]
const propDefs: ComponentPropertyDefinition[] = []
const propValues = new Map<string, Set<string>>()
for (let i = 0; i < propCount; i++) {
const propId = `prop:${randomHex(8)}`
const propName = i === 0 ? 'Variant' : `Property ${i + 1}`
propDefs.push({ id: propId, name: propName, type: 'VARIANT', defaultValue: '' })
propValues.set(propName, new Set())
}
for (const node of rawNodes) {
const parts = node.name.split('/').slice(1)
const values: Record<string, string> = {}
for (let i = 0; i < propDefs.length; i++) {
const value = parts[i]?.trim() ?? ''
values[propDefs[i].name] = value
propValues.get(propDefs[i].name)?.add(value)
}
this.graph.updateNode(node.id, {
componentPropertyValues: values,
name: Object.values(values).join(', ')
})
}
for (const def of propDefs) {
def.variantOptions = [...(propValues.get(def.name) ?? [])]
if (!def.defaultValue && def.variantOptions[0]) def.defaultValue = def.variantOptions[0]
}
this.graph.updateNode(container[INTERNAL_ID], { componentPropertyDefinitions: propDefs })
}
return container
}
// --- Variables ---
getVariableById(id: string): Variable | null {

View file

@ -1,5 +1,5 @@
export { createPage, createShape, createSlice } from './create/basic'
export { createComponent, createInstance } from './create/components'
export { combineAsVariants, createComponent, createInstance } from './create/components'
export { fetchIconsTool, insertIcon, searchIconsTool } from './create/icons'
export { render } from './create/render'
export { importSVG } from './create/svg'

View file

@ -1,3 +1,4 @@
import type { FigmaNodeProxy } from '#core/figma-api'
import { defineTool, nodeSummary } from '#core/tools/schema'
export const createComponent = defineTool({
@ -33,3 +34,30 @@ export const createInstance = defineTool({
return nodeSummary(instance)
}
})
export const combineAsVariants = defineTool({
name: 'combine_as_variants',
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.',
params: {
ids: { type: 'string[]', description: 'Component node IDs to combine', required: true }
},
execute: (figma, { ids }) => {
const nodes = ids
.map((id) => figma.getNodeById(id))
.filter((node): node is FigmaNodeProxy => node !== null)
if (nodes.length !== ids.length) 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.type === 'COMPONENT')) {
return { error: 'combineAsVariants requires COMPONENT nodes' }
}
try {
const componentSet = figma.combineAsVariants(nodes)
return nodeSummary(componentSet)
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
}
})

View file

@ -9,6 +9,7 @@ import {
} from './analyze'
import { designToComponentMap, designToTokens } from './codegen'
import {
combineAsVariants,
createComponent,
createInstance,
createPage,
@ -126,6 +127,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [
fetchIconsTool,
createComponent,
createInstance,
combineAsVariants,
createPage,
createVector,
createSlice,

View file

@ -0,0 +1,55 @@
import { describe, expect, test } from 'bun:test'
import { createAPI } from '../helpers'
describe('combineAsVariants', () => {
test('wraps components into a COMPONENT_SET', () => {
const api = createAPI()
const a = api.createComponent()
a.name = 'Button/Primary'
a.resize(100, 40)
const b = api.createComponent()
b.name = 'Button/Secondary'
b.resize(100, 40)
const set = api.combineAsVariants([a, b])
expect(set.type).toBe('COMPONENT_SET')
expect(set.name).toBe('Button')
expect(set.children.length).toBe(2)
expect(set.children.map((c) => c.name).sort()).toEqual(['Primary', 'Secondary'])
})
test('derives variant property definitions from name segments', () => {
const api = createAPI()
const a = api.createComponent()
a.name = 'State/Default'
a.resize(100, 40)
const b = api.createComponent()
b.name = 'State/Hover'
b.resize(100, 40)
const set = api.combineAsVariants([a, b])
const raw = api.graph.getNode(set.id)
expect(raw?.componentPropertyDefinitions?.length).toBe(1)
expect(raw?.componentPropertyDefinitions?.[0].name).toBe('Variant')
expect(raw?.componentPropertyDefinitions?.[0].variantOptions?.sort()).toEqual([
'Default',
'Hover'
])
})
test('rejects fewer than 2 nodes', () => {
const api = createAPI()
const a = api.createComponent()
expect(() => api.combineAsVariants([a])).toThrow()
})
test('rejects non-component nodes', () => {
const api = createAPI()
const a = api.createComponent()
const b = api.createFrame()
expect(() => api.combineAsVariants([a, b])).toThrow()
})
})