From b01ed830e80e40bb0592d607f3e820d41b97a396 Mon Sep 17 00:00:00 2001 From: xemc <0xemc@protonmail.com> Date: Tue, 18 Aug 2026 06:15:48 +1000 Subject: [PATCH] feat(figma-api): add combineAsVariants for real component variant sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/figma-api/index.ts | 105 +++++++++++++++++- packages/core/src/tools/create.ts | 2 +- packages/core/src/tools/create/components.ts | 28 +++++ packages/core/src/tools/registry-extended.ts | 2 + .../api/create/combine-as-variants.test.ts | 55 +++++++++ 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 tests/engine/figma/api/create/combine-as-variants.test.ts diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index ef1ef4dab..75a8fe13c 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, @@ -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 { + 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>() + + 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 = {} + 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 { diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index e2812dbbf..548ff939e 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 { 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' diff --git a/packages/core/src/tools/create/components.ts b/packages/core/src/tools/create/components.ts index 30dd6eedf..8f850642e 100644 --- a/packages/core/src/tools/create/components.ts +++ b/packages/core/src/tools/create/components.ts @@ -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) } + } + } +}) diff --git a/packages/core/src/tools/registry-extended.ts b/packages/core/src/tools/registry-extended.ts index d865732b8..9d9cb38f1 100644 --- a/packages/core/src/tools/registry-extended.ts +++ b/packages/core/src/tools/registry-extended.ts @@ -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, diff --git a/tests/engine/figma/api/create/combine-as-variants.test.ts b/tests/engine/figma/api/create/combine-as-variants.test.ts new file mode 100644 index 000000000..1c7d918bd --- /dev/null +++ b/tests/engine/figma/api/create/combine-as-variants.test.ts @@ -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() + }) +})