diff --git a/mcp/packages/plugin/src/PenpotUtils.test.ts b/mcp/packages/plugin/src/PenpotUtils.test.ts new file mode 100644 index 0000000000..845de06b51 --- /dev/null +++ b/mcp/packages/plugin/src/PenpotUtils.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PenpotUtils } from "./PenpotUtils.ts"; + +// --------------------------------------------------------------------------- +// Minimal penpot global mock +// --------------------------------------------------------------------------- + +function makeVariantComponent(calls: string[]) { + return { + isVariant: () => true, + setVariantProperty: (pos: number, value: string) => { + calls.push(`setVariantProperty(${pos}, ${value})`); + }, + }; +} + +function makeMockPenpot(variantComps: ReturnType[]) { + const variants = { + renamePropertyCalls: [] as string[], + addPropertyCount: 0, + renameProperty(pos: number, name: string) { + this.renamePropertyCalls.push(`${pos}:${name}`); + }, + addProperty() { + this.addPropertyCount++; + }, + variantComponents() { + return variantComps; + }, + }; + + const container = { variants }; + + return { + mock: { + utils: { + types: { + isVariantComponent: (c: any) => c.isVariant(), + }, + }, + createVariantFromComponents: (_shapes: any[]) => container, + } as any, + variants, + container, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("createVariant — single property: renames Property 1 and sets values", () => { + const calls: string[] = []; + const comps = [makeVariantComponent(calls), makeVariantComponent(calls), makeVariantComponent(calls)]; + const { mock, variants } = makeMockPenpot(comps); + + (globalThis as any).penpot = mock; + + PenpotUtils.createVariant([ + { shape: {} as any, properties: { Size: "Small" } }, + { shape: {} as any, properties: { Size: "Medium" } }, + { shape: {} as any, properties: { Size: "Large" } }, + ]); + + // renameProperty(0, "Size") — no addProperty since there's only one property + assert.deepEqual(variants.renamePropertyCalls, ["0:Size"]); + assert.equal(variants.addPropertyCount, 0); + + // each component gets setVariantProperty(0, value) + assert.deepEqual(calls, [ + "setVariantProperty(0, Small)", + "setVariantProperty(0, Medium)", + "setVariantProperty(0, Large)", + ]); +}); + +test("createVariant — two properties: renames first, adds and renames second, sets all values", () => { + const calls: string[] = []; + const comps = [makeVariantComponent(calls), makeVariantComponent(calls)]; + const { mock, variants } = makeMockPenpot(comps); + + (globalThis as any).penpot = mock; + + PenpotUtils.createVariant([ + { shape: {} as any, properties: { Size: "Small", State: "Default" } }, + { shape: {} as any, properties: { Size: "Large", State: "Hover" } }, + ]); + + // Property 1 renamed to Size; addProperty() called once; Property 2 renamed to State + assert.deepEqual(variants.renamePropertyCalls, ["0:Size", "1:State"]); + assert.equal(variants.addPropertyCount, 1); + + // comp 0: pos 0 = Small, pos 1 = Default; comp 1: pos 0 = Large, pos 1 = Hover + assert.deepEqual(calls, [ + "setVariantProperty(0, Small)", + "setVariantProperty(1, Default)", + "setVariantProperty(0, Large)", + "setVariantProperty(1, Hover)", + ]); +}); + +test("createVariant — property name order follows first-seen order across components", () => { + const calls: string[] = []; + const comps = [makeVariantComponent(calls), makeVariantComponent(calls)]; + const { mock, variants } = makeMockPenpot(comps); + + (globalThis as any).penpot = mock; + + // "Color" appears first in comp[0]; "Size" appears first in comp[0] too + PenpotUtils.createVariant([ + { shape: {} as any, properties: { Color: "Red", Size: "Small" } }, + { shape: {} as any, properties: { Color: "Blue", Size: "Large" } }, + ]); + + assert.deepEqual(variants.renamePropertyCalls, ["0:Color", "1:Size"]); + assert.deepEqual(calls, [ + "setVariantProperty(0, Red)", + "setVariantProperty(1, Small)", + "setVariantProperty(0, Blue)", + "setVariantProperty(1, Large)", + ]); +}); + +test("createVariant — returns the container from createVariantFromComponents", () => { + const { mock, container } = makeMockPenpot([]); + (globalThis as any).penpot = mock; + + const result = PenpotUtils.createVariant([{ shape: {} as any, properties: { X: "1" } }]); + + assert.equal(result, container); +}); diff --git a/mcp/packages/plugin/src/PenpotUtils.ts b/mcp/packages/plugin/src/PenpotUtils.ts index 2c2cc3e4e5..f9a8cba245 100644 --- a/mcp/packages/plugin/src/PenpotUtils.ts +++ b/mcp/packages/plugin/src/PenpotUtils.ts @@ -1,4 +1,15 @@ -import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types"; +import type { + Board, + Bounds, + Fill, + FlexLayout, + GridLayout, + Page, + Rectangle, + Shape, + Text, + VariantContainer, +} from "@penpot/plugin-types"; export class PenpotUtils { /** @@ -512,6 +523,82 @@ export class PenpotUtils { return null; } + /** + * Creates a variant group from a list of main component instances and immediately + * configures its property names and values. + * + * Encapsulates the full multi-step workflow in a single call: + * 1. `penpot.createVariantFromComponents()` — groups the components + * 2. `variants.renameProperty()` / `variants.addProperty()` — sets property names + * 3. `comp.setVariantProperty()` — sets each component's property values + * + * @param components - Array of main-instance boards to combine. Each element carries + * a `shape` (the Board on the canvas) and a `properties` map of + * `{ propertyName: value }` for that component. + * @returns The newly created `VariantContainer`. + * + * @example + * // Three button variants differing in Size + * const [s, m, l] = penpot.currentPage.findAllShapes(sh => sh.isMainComponent()).slice(0, 3) as Board[]; + * const container = PenpotUtils.createVariant([ + * { shape: s, properties: { Size: 'Small' } }, + * { shape: m, properties: { Size: 'Medium' } }, + * { shape: l, properties: { Size: 'Large' } }, + * ]); + * + * @example + * // Two properties: Size × State + * const container = PenpotUtils.createVariant([ + * { shape: s, properties: { Size: 'Small', State: 'Default' } }, + * { shape: m, properties: { Size: 'Medium', State: 'Default' } }, + * { shape: l, properties: { Size: 'Large', State: 'Hover' } }, + * ]); + */ + public static createVariant( + components: Array<{ shape: Board; properties: Record }> + ): VariantContainer { + // Collect all unique property names (preserving first-seen order) + const propNames: string[] = []; + for (const { properties } of components) { + for (const name of Object.keys(properties)) { + if (!propNames.includes(name)) propNames.push(name); + } + } + + // 1. Create the variant container + // @ts-ignore — createVariantFromComponents was added after plugin-types@1.4.1 + const container: VariantContainer = (penpot as any).createVariantFromComponents(components.map((c) => c.shape)); + const variants = container.variants; + if (!variants) return container; + + // 2. Rename / add properties + // createVariantFromComponents always creates exactly one property ("Property 1") + for (let i = 0; i < propNames.length; i++) { + if (i === 0) { + variants.renameProperty(0, propNames[0]); + } else { + variants.addProperty(); + variants.renameProperty(i, propNames[i]); + } + } + + // 3. Set each component's property values + const variantComps = variants.variantComponents(); + for (let compIdx = 0; compIdx < variantComps.length; compIdx++) { + const comp = variantComps[compIdx]; + if (!penpot.utils.types.isVariantComponent(comp)) continue; + const props = components[compIdx]?.properties ?? {}; + for (let propIdx = 0; propIdx < propNames.length; propIdx++) { + const value = props[propNames[propIdx]]; + if (value !== undefined) { + comp.setVariantProperty(propIdx, value); + } + } + } + + return container; + } + /** * Generates an overview of all tokens organized by token set name, token type, and token name. * The result is a nested object structure: {tokenSetName: {tokenType: [tokenName, ...]}}. diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 0c129d6f38..69b2c07114 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -304,10 +304,29 @@ Variants are a system for grouping related component versions along named proper Properties are often addressed positionally: `pos` parameter in various methods = index in `Variants.properties`. **Creating a variant group**: -- `penpot.createVariantFromComponents(mainInstances: Board[]): VariantContainer`: Combines several main component instances into a new variant group. - All components end up inside a single new container on the canvas. - The container's `Variants` instance is initialised with one property `Property 1`, with the property values set to the respective component's name. -- After creation, edit properties using `variants.renameProperty(pos, name)`, `variants.addProperty()`, and `comp.setVariantProperty(pos, value)`. + +Use `penpotUtils.createVariant(components)` — it handles the full multi-step workflow in one call: +```js +const [s, m, l] = penpot.currentPage.findAllShapes(sh => sh.isMainComponent()).slice(0, 3); +// Single property: +const container = penpotUtils.createVariant([ + { shape: s, properties: { Size: 'Small' } }, + { shape: m, properties: { Size: 'Medium' } }, + { shape: l, properties: { Size: 'Large' } }, +]); +// Multiple properties: +const container2 = penpotUtils.createVariant([ + { shape: s, properties: { Size: 'Small', State: 'Default' } }, + { shape: m, properties: { Size: 'Medium', State: 'Default' } }, + { shape: l, properties: { Size: 'Large', State: 'Hover' } }, +]); +``` + +If you must use the lower-level API, follow this exact order — skipping or reordering steps leaves the variant broken: +1. `penpot.createVariantFromComponents(mainInstances)` — groups components; always creates one property called `"Property 1"`. +2. `container.variants.renameProperty(0, name)` — rename `Property 1`. +3. For each extra property: `variants.addProperty()` then `variants.renameProperty(pos, name)`. +4. For every component × every property: iterate `variants.variantComponents()` and call `comp.setVariantProperty(pos, value)`. **Adding a variant to an existing group**: Use `variantContainer.appendChild(mainInstance)` to move a component's main instance into the container, then set its position manually and assign property values via `setVariantProperty`.