From 82191b75f7bcee28559227b4a4af1f84bd1d32fe Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 09:32:13 +0300 Subject: [PATCH 01/11] feat(figma-api): expose component property accessors --- packages/core/src/figma-api/components.ts | 248 ++++++++++++++++++++++ packages/core/src/figma-api/proxy.ts | 2 + tests/engine/figma/api/components.test.ts | 13 ++ 3 files changed, 263 insertions(+) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index 6582b674a..01eb9f6b0 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -1,9 +1,16 @@ import type { ComponentPropertyDefinition, SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { + applyComponentPropertyValue, + componentPropertyDefinitions as sharedComponentPropertyDefinitions, + removeComponentProperty +} 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 { NodeProxyInternals, ProxyThis } from './accessor-utils' +import { graph, raw, updateNode } from './accessor-utils' import type { FigmaNodeProxy } from './proxy' const COMPONENT_SET_PADDING = 40 @@ -89,6 +96,247 @@ function requireDistinctComponents(graph: SceneGraph, nodeIds: ReadonlyArray { + return ids.flatMap((id) => { + const node = graph.getNode(id) + return node && (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') + ? [{ type: node.type, key: node.componentKey ?? node.sourceLibraryKey ?? node.id }] + : [] + }) +} + +function definitions(target: ProxyThis, internals: NodeProxyInternals): Record { + const node = raw(target, internals) + if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') return {} + return Object.fromEntries( + node.componentPropertyDefinitions.map((definition) => [ + propertyName(definition), + { + type: definition.type, + defaultValue: + definition.type === 'BOOLEAN' + ? definition.defaultValue === 'true' + : definition.defaultValue, + ...(definition.preferredValues + ? { + preferredValues: preferredValues(graph(target, internals), definition.preferredValues) + } + : {}) + } + ]) + ) +} + +function componentProperties( + target: ProxyThis, + internals: NodeProxyInternals +): Record { + const node = raw(target, internals) + if (node.type !== 'INSTANCE') return {} + return Object.fromEntries( + sharedComponentPropertyDefinitions(graph(target, internals), node).map((definition) => { + const value = node.componentPropertyAssignments[definition.id] ?? definition.defaultValue + return [ + propertyName(definition), + { type: definition.type, value: definition.type === 'BOOLEAN' ? value === 'true' : value } + ] + }) + ) +} + +function findDefinition( + target: ProxyThis, + internals: NodeProxyInternals, + name: string +): ComponentPropertyDefinition | null { + const node = raw(target, internals) + const defs = + node.type === 'INSTANCE' + ? sharedComponentPropertyDefinitions(graph(target, internals), node) + : node.componentPropertyDefinitions + return ( + defs.find( + (definition) => + propertyName(definition) === name || + (definition.type === 'VARIANT' && definition.name === name) + ) ?? null + ) +} + +function editPropertyDefinitions( + target: ProxyThis, + internals: NodeProxyInternals, + propertyNameValue: string, + changes: { name?: string; defaultValue?: string | boolean } +): string { + const node = raw(target, internals) + if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') + throw new Error('editComponentProperty() can only be called on components') + const definition = findDefinition(target, internals, propertyNameValue) + if (!definition) throw new Error(`Unknown component property: ${propertyNameValue}`) + const updated = { + ...definition, + ...(changes.name ? { name: changes.name.trim() } : {}), + ...(changes.defaultValue !== undefined + ? { + defaultValue: + definition.type === 'BOOLEAN' + ? String(changes.defaultValue === true || changes.defaultValue === 'true') + : String(changes.defaultValue) + } + : {}) + } + updateNode(target, internals, { + componentPropertyDefinitions: node.componentPropertyDefinitions.map((item) => + item.id === definition.id ? updated : item + ) + }) + return propertyName(updated) +} +function applyProperty( + target: ProxyThis, + internals: NodeProxyInternals, + node: SceneNode, + definition: ComponentPropertyDefinition, + value: string | boolean +): void { + if (definition.type === 'VARIANT' || node.type !== 'INSTANCE') return + applyComponentPropertyValue(graph(target, internals), node.id, definition, String(value)) +} +export function installComponentPropertyAccessors( + prototype: object, + internals: NodeProxyInternals +): void { + Object.defineProperties(prototype, { + componentPropertyDefinitions: { + get(this: ProxyThis) { + return definitions(this, internals) + } + }, + componentPropertyReferences: { + get(this: ProxyThis) { + const node = raw(this, internals) + if (node.type !== 'INSTANCE' && node.type !== 'COMPONENT') return null + return Object.fromEntries( + node.componentPropertyReferences.map((reference) => [ + reference.field === 'INSTANCE_SWAP' ? 'mainComponent' : reference.field.toLowerCase(), + reference.propertyId + ]) + ) + } + }, + componentProperties: { + get(this: ProxyThis) { + return componentProperties(this, internals) + } + }, + isExposedInstance: { + get(this: ProxyThis) { + const node = raw(this, internals) + return ( + node.type === 'INSTANCE' && + node.componentPropertyReferences.some((reference) => reference.field === 'INSTANCE_SWAP') + ) + }, + set(this: ProxyThis, value: boolean) { + const node = raw(this, internals) + if (node.type !== 'INSTANCE') + throw new Error('isExposedInstance is only supported on instances') + if (!value) + updateNode(this, internals, { + componentPropertyReferences: node.componentPropertyReferences.filter( + (reference) => reference.field !== 'INSTANCE_SWAP' + ) + }) + } + }, + exposedInstances: { + get(this: ProxyThis) { + const node = raw(this, internals) + if (node.type !== 'INSTANCE') return [] + const result: FigmaNodeProxy[] = [] + const visit = (id: string): void => { + const child = graph(this, internals).getNode(id) + if (!child) return + if ( + child.type === 'INSTANCE' && + child.componentPropertyReferences.some( + (reference) => reference.field === 'INSTANCE_SWAP' + ) + ) + result.push( + (this[internals.api] as { wrapNode(id: string): FigmaNodeProxy }).wrapNode(child.id) + ) + child.childIds.forEach(visit) + } + node.childIds.forEach(visit) + return result + } + }, + setProperties: { + value(this: ProxyThis, properties: Record) { + const node = raw(this, internals) + if (node.type !== 'INSTANCE') + throw new Error('setProperties() can only be called on instances') + for (const [name, value] of Object.entries(properties)) { + const definition = findDefinition(this, internals, name) + if (definition) applyProperty(this, internals, node, definition, value) + } + } + }, + addComponentProperty: { + value( + this: ProxyThis, + name: string, + type: ComponentPropertyType, + defaultValue: string | boolean + ) { + const node = raw(this, internals) + if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') + throw new Error('addComponentProperty() can only be called on components') + const definition: ComponentPropertyDefinition = { + id: `prop:${randomHex(8)}`, + name: name.trim(), + type: type as SceneNode['componentPropertyDefinitions'][number]['type'], + defaultValue: + type === 'BOOLEAN' + ? String(defaultValue === true || defaultValue === 'true') + : String(defaultValue) + } + updateNode(this, internals, { + componentPropertyDefinitions: [...node.componentPropertyDefinitions, definition] + }) + return propertyName(definition) + } + }, + editComponentProperty: { + value( + this: ProxyThis, + name: string, + changes: { name?: string; defaultValue?: string | boolean } + ) { + return editPropertyDefinitions(this, internals, name, changes) + } + }, + deleteComponentProperty: { + value(this: ProxyThis, name: string) { + const node = raw(this, internals) + if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') + throw new Error('deleteComponentProperty() can only be called on components') + const definition = findDefinition(this, internals, name) + if (!definition) throw new Error(`Unknown component property: ${name}`) + removeComponentProperty(graph(this, internals), node.id, definition.id) + } + } + }) +} export function combineComponentsAsVariants( graph: SceneGraph, nodeIds: ReadonlyArray, diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 0584a2c28..81b53f5da 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -27,6 +27,7 @@ import { type FigmaVectorPath } from './accessors/vector' import { installVisualNodeProxyAccessors } from './accessors/visual' +import { installComponentPropertyAccessors } from './components' import type { FigmaFontName } from './fonts' import { getPageBackgrounds, setPageBackgrounds } from './page-backgrounds' import * as PluginData from './plugin-data' @@ -596,3 +597,4 @@ const proxyInternals = { installLayoutNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals) installVariableModeNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals) +installComponentPropertyAccessors(FigmaNodeProxy.prototype, proxyInternals) diff --git a/tests/engine/figma/api/components.test.ts b/tests/engine/figma/api/components.test.ts index e8b4434f6..ed9361243 100644 --- a/tests/engine/figma/api/components.test.ts +++ b/tests/engine/figma/api/components.test.ts @@ -5,6 +5,19 @@ import { expectDefined } from '#tests/helpers/assert' import { createAPI } from './helpers' describe('components', () => { + test('exposes component property accessors and applies instance properties', () => { + const api = createAPI() + const component = api.createComponent() + component.name = 'Card' + component.appendChild(Object.assign(api.createText(), { name: 'Label', characters: 'Default' })) + const propertyName = component.addComponentProperty('Label', 'TEXT', 'Default') + const instance = component.createInstance() + + expect(component.componentPropertyDefinitions[propertyName]?.defaultValue).toBe('Default') + expect(instance.componentProperties[propertyName]?.value).toBe('Default') + instance.setProperties({ [propertyName]: 'Updated' }) + expect(instance.componentProperties[propertyName]?.value).toBe('Updated') + }) test('createInstance from component', () => { const api = createAPI() const comp = api.createComponent() From 2e29787c5e79f6aca605eed4eae587a2e52b235f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 09:47:46 +0300 Subject: [PATCH 02/11] feat(figma-api): complete component property compatibility --- packages/core/src/figma-api/components.ts | 67 ++++++++++++++++++++--- tests/engine/figma/api/components.test.ts | 34 ++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index 01eb9f6b0..14e0d3bd9 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -1,4 +1,9 @@ -import type { ComponentPropertyDefinition, SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import type { + ComponentPropertyDefinition, + ComponentPropertyType, + SceneGraph, + SceneNode +} from '@open-pencil/scene-graph' import { applyComponentPropertyValue, componentPropertyDefinitions as sharedComponentPropertyDefinitions, @@ -112,7 +117,17 @@ function preferredValues( }) } -function definitions(target: ProxyThis, internals: NodeProxyInternals): Record { +function definitions( + target: ProxyThis, + internals: NodeProxyInternals +): Record< + string, + { + type: ComponentPropertyType + defaultValue: string | boolean + preferredValues?: Array<{ type: 'COMPONENT' | 'COMPONENT_SET'; key: string }> + } +> { const node = raw(target, internals) if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') return {} return Object.fromEntries( @@ -137,7 +152,13 @@ function definitions(target: ProxyThis, internals: NodeProxyInternals): Record { +): Record< + string, + { + type: ComponentPropertyType + value: string | boolean + } +> { const node = raw(target, internals) if (node.type !== 'INSTANCE') return {} return Object.fromEntries( @@ -200,6 +221,15 @@ function editPropertyDefinitions( }) return propertyName(updated) } +function propertyReferenceField(field: string): 'TEXT' | 'VISIBLE' | 'INSTANCE_SWAP' { + if (field === 'mainComponent') return 'INSTANCE_SWAP' + return field === 'characters' ? 'TEXT' : 'VISIBLE' +} + +function propertyReferenceName(field: 'TEXT' | 'VISIBLE' | 'INSTANCE_SWAP'): string { + if (field === 'INSTANCE_SWAP') return 'mainComponent' + return field === 'TEXT' ? 'characters' : 'visible' +} function applyProperty( target: ProxyThis, internals: NodeProxyInternals, @@ -207,7 +237,9 @@ function applyProperty( definition: ComponentPropertyDefinition, value: string | boolean ): void { - if (definition.type === 'VARIANT' || node.type !== 'INSTANCE') return + if (definition.type === 'VARIANT') { + throw new Error('setProperties() cannot set VARIANT properties through the adapter') + } applyComponentPropertyValue(graph(target, internals), node.id, definition, String(value)) } export function installComponentPropertyAccessors( @@ -223,13 +255,31 @@ export function installComponentPropertyAccessors( componentPropertyReferences: { get(this: ProxyThis) { const node = raw(this, internals) - if (node.type !== 'INSTANCE' && node.type !== 'COMPONENT') return null + if ( + node.type !== 'INSTANCE' && + node.type !== 'COMPONENT' && + node.type !== 'FRAME' && + node.type !== 'TEXT' + ) + return null return Object.fromEntries( node.componentPropertyReferences.map((reference) => [ - reference.field === 'INSTANCE_SWAP' ? 'mainComponent' : reference.field.toLowerCase(), + propertyReferenceName(reference.field), reference.propertyId ]) ) + }, + set(this: ProxyThis, value: Record | null) { + if (value === null) { + updateNode(this, internals, { componentPropertyReferences: [] }) + return + } + updateNode(this, internals, { + componentPropertyReferences: Object.entries(value).map(([field, propertyId]) => ({ + propertyId, + field: propertyReferenceField(field) + })) + }) } }, componentProperties: { @@ -287,7 +337,8 @@ export function installComponentPropertyAccessors( throw new Error('setProperties() can only be called on instances') for (const [name, value] of Object.entries(properties)) { const definition = findDefinition(this, internals, name) - if (definition) applyProperty(this, internals, node, definition, value) + if (!definition) throw new Error(`Unknown component property: ${name}`) + applyProperty(this, internals, node, definition, value) } } }, @@ -304,7 +355,7 @@ export function installComponentPropertyAccessors( const definition: ComponentPropertyDefinition = { id: `prop:${randomHex(8)}`, name: name.trim(), - type: type as SceneNode['componentPropertyDefinitions'][number]['type'], + type, defaultValue: type === 'BOOLEAN' ? String(defaultValue === true || defaultValue === 'true') diff --git a/tests/engine/figma/api/components.test.ts b/tests/engine/figma/api/components.test.ts index ed9361243..038a8a03f 100644 --- a/tests/engine/figma/api/components.test.ts +++ b/tests/engine/figma/api/components.test.ts @@ -18,6 +18,40 @@ describe('components', () => { instance.setProperties({ [propertyName]: 'Updated' }) expect(instance.componentProperties[propertyName]?.value).toBe('Updated') }) + test('supports boolean properties, references, exposure, and CRUD', () => { + const api = createAPI() + const component = api.createComponent() + const label = api.createText() + label.name = 'Label' + component.appendChild(label) + const badge = api.createFrame() + badge.name = 'Badge' + component.appendChild(badge) + + const textName = component.addComponentProperty('Label', 'TEXT', 'Default') + const visibleName = component.addComponentProperty('Visible', 'BOOLEAN', true) + label.componentPropertyReferences = { characters: textName } + badge.componentPropertyReferences = { visible: visibleName } + const instance = component.createInstance() + + expect(component.componentPropertyReferences).toEqual({}) + expect(instance.componentProperties[visibleName]?.value).toBe(true) + expect(badge.componentPropertyReferences).toEqual({ visible: visibleName }) + instance.setProperties({ [visibleName]: false }) + expect(instance.componentProperties[visibleName]?.value).toBe(false) + + const nested = api.createComponent() + const slot = nested.createInstance() + slot.componentPropertyReferences = { mainComponent: textName } + expect(slot.isExposedInstance).toBe(true) + slot.isExposedInstance = false + expect(slot.isExposedInstance).toBe(false) + + const editedName = component.editComponentProperty(visibleName, { name: 'Shown' }) + expect(editedName).toContain('Shown#') + component.deleteComponentProperty(editedName) + expect(component.componentPropertyDefinitions[editedName]).toBeUndefined() + }) test('createInstance from component', () => { const api = createAPI() const comp = api.createComponent() From 35fb60bd4fda2da40dde67f257cc8844799d665f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 11:28:52 +0300 Subject: [PATCH 03/11] fix(figma-api): align component property contracts --- packages/core/src/figma-api/components.ts | 36 +++++++++++------------ 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index 14e0d3bd9..e7d88d2ba 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -18,7 +18,21 @@ import type { NodeProxyInternals, ProxyThis } from './accessor-utils' import { graph, raw, updateNode } from './accessor-utils' import type { FigmaNodeProxy } from './proxy' +type InstanceSwapPreferredValue = { type: 'COMPONENT' | 'COMPONENT_SET'; key: string } + const COMPONENT_SET_PADDING = 40 +interface FigmaComponentPropertyDefinition { + type: ComponentPropertyType + defaultValue: string | boolean + preferredValues?: InstanceSwapPreferredValue[] +} + +interface FigmaComponentProperties { + [propertyName: string]: { + type: ComponentPropertyType + value: string | boolean + } +} export function exposeInstanceSwap( graph: SceneGraph, @@ -105,10 +119,7 @@ function propertyName(definition: ComponentPropertyDefinition): string { return definition.type === 'VARIANT' ? definition.name : `${definition.name}#${definition.id}` } -function preferredValues( - graph: SceneGraph, - ids: string[] -): Array<{ type: 'COMPONENT' | 'COMPONENT_SET'; key: string }> { +function preferredValues(graph: SceneGraph, ids: string[]): InstanceSwapPreferredValue[] { return ids.flatMap((id) => { const node = graph.getNode(id) return node && (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') @@ -120,14 +131,7 @@ function preferredValues( function definitions( target: ProxyThis, internals: NodeProxyInternals -): Record< - string, - { - type: ComponentPropertyType - defaultValue: string | boolean - preferredValues?: Array<{ type: 'COMPONENT' | 'COMPONENT_SET'; key: string }> - } -> { +): Record { const node = raw(target, internals) if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') return {} return Object.fromEntries( @@ -152,13 +156,7 @@ function definitions( function componentProperties( target: ProxyThis, internals: NodeProxyInternals -): Record< - string, - { - type: ComponentPropertyType - value: string | boolean - } -> { +): FigmaComponentProperties { const node = raw(target, internals) if (node.type !== 'INSTANCE') return {} return Object.fromEntries( From 0b49d57d05303360af644c09c389ac3856d98321 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 11:46:19 +0300 Subject: [PATCH 04/11] fix(figma-api): preserve component property metadata --- packages/core/src/figma-api/components.ts | 30 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index e7d88d2ba..d7428b587 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -25,13 +25,18 @@ interface FigmaComponentPropertyDefinition { type: ComponentPropertyType defaultValue: string | boolean preferredValues?: InstanceSwapPreferredValue[] + variantOptions?: string[] +} + +interface FigmaComponentProperty { + type: ComponentPropertyType + value: string | boolean + preferredValues?: InstanceSwapPreferredValue[] + variantOptions?: string[] } interface FigmaComponentProperties { - [propertyName: string]: { - type: ComponentPropertyType - value: string | boolean - } + [propertyName: string]: FigmaComponentProperty } export function exposeInstanceSwap( @@ -147,7 +152,8 @@ function definitions( ? { preferredValues: preferredValues(graph(target, internals), definition.preferredValues) } - : {}) + : {}), + ...(definition.variantOptions ? { variantOptions: definition.variantOptions } : {}) } ]) ) @@ -164,7 +170,19 @@ function componentProperties( const value = node.componentPropertyAssignments[definition.id] ?? definition.defaultValue return [ propertyName(definition), - { type: definition.type, value: definition.type === 'BOOLEAN' ? value === 'true' : value } + { + type: definition.type, + value: definition.type === 'BOOLEAN' ? value === 'true' : value, + ...(definition.preferredValues + ? { + preferredValues: preferredValues( + graph(target, internals), + definition.preferredValues + ) + } + : {}), + ...(definition.variantOptions ? { variantOptions: definition.variantOptions } : {}) + } ] }) ) From ac30791308c7c9eac76eaa5d670ba891235dc3dd Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 12:01:14 +0300 Subject: [PATCH 05/11] fix(figma-api): return immutable property metadata --- packages/core/src/figma-api/components.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index d7428b587..da15be79a 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -153,7 +153,7 @@ function definitions( preferredValues: preferredValues(graph(target, internals), definition.preferredValues) } : {}), - ...(definition.variantOptions ? { variantOptions: definition.variantOptions } : {}) + ...(definition.variantOptions ? { variantOptions: [...definition.variantOptions] } : {}) } ]) ) @@ -181,7 +181,7 @@ function componentProperties( ) } : {}), - ...(definition.variantOptions ? { variantOptions: definition.variantOptions } : {}) + ...(definition.variantOptions ? { variantOptions: [...definition.variantOptions] } : {}) } ] }) From cb361c4b11331990960264479a3c4423ee255862 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 12:30:06 +0300 Subject: [PATCH 06/11] fix(figma-api): preserve variant metadata immutably --- packages/core/src/figma-api/components.ts | 32 ++++++++++++----------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index da15be79a..c154d1096 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -133,6 +133,21 @@ function preferredValues(graph: SceneGraph, ids: string[]): InstanceSwapPreferre }) } +function propertyMetadata( + target: ProxyThis, + internals: NodeProxyInternals, + definition: ComponentPropertyDefinition, + includeVariantOptions: boolean +): Pick { + return { + ...(definition.preferredValues + ? { preferredValues: preferredValues(graph(target, internals), definition.preferredValues) } + : {}), + ...(includeVariantOptions && definition.variantOptions + ? { variantOptions: [...definition.variantOptions] } + : {}) + } +} function definitions( target: ProxyThis, internals: NodeProxyInternals @@ -148,12 +163,7 @@ function definitions( definition.type === 'BOOLEAN' ? definition.defaultValue === 'true' : definition.defaultValue, - ...(definition.preferredValues - ? { - preferredValues: preferredValues(graph(target, internals), definition.preferredValues) - } - : {}), - ...(definition.variantOptions ? { variantOptions: [...definition.variantOptions] } : {}) + ...propertyMetadata(target, internals, definition, true) } ]) ) @@ -173,15 +183,7 @@ function componentProperties( { type: definition.type, value: definition.type === 'BOOLEAN' ? value === 'true' : value, - ...(definition.preferredValues - ? { - preferredValues: preferredValues( - graph(target, internals), - definition.preferredValues - ) - } - : {}), - ...(definition.variantOptions ? { variantOptions: [...definition.variantOptions] } : {}) + ...propertyMetadata(target, internals, definition, false) } ] }) From 4f9368f929a3e15ba85285aeaceafdec135a6fdf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 12:34:39 +0300 Subject: [PATCH 07/11] fix(figma-api): complete component property review --- packages/core/src/figma-api/components.ts | 24 ++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index c154d1096..78ea0d471 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -21,6 +21,7 @@ import type { FigmaNodeProxy } from './proxy' type InstanceSwapPreferredValue = { type: 'COMPONENT' | 'COMPONENT_SET'; key: string } const COMPONENT_SET_PADDING = 40 + interface FigmaComponentPropertyDefinition { type: ComponentPropertyType defaultValue: string | boolean @@ -213,13 +214,23 @@ function editPropertyDefinitions( target: ProxyThis, internals: NodeProxyInternals, propertyNameValue: string, - changes: { name?: string; defaultValue?: string | boolean } + changes: { + name?: string + defaultValue?: string | boolean + preferredValues?: InstanceSwapPreferredValue[] + } ): string { const node = raw(target, internals) if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') throw new Error('editComponentProperty() can only be called on components') const definition = findDefinition(target, internals, propertyNameValue) if (!definition) throw new Error(`Unknown component property: ${propertyNameValue}`) + if ( + changes.defaultValue !== undefined && + !['BOOLEAN', 'TEXT', 'INSTANCE_SWAP'].includes(definition.type) + ) { + throw new Error(`defaultValue is not supported for ${definition.type} properties`) + } const updated = { ...definition, ...(changes.name ? { name: changes.name.trim() } : {}), @@ -230,6 +241,9 @@ function editPropertyDefinitions( ? String(changes.defaultValue === true || changes.defaultValue === 'true') : String(changes.defaultValue) } + : {}), + ...(changes.preferredValues + ? { preferredValues: changes.preferredValues.map((value) => value.key) } : {}) } updateNode(target, internals, { @@ -365,7 +379,8 @@ export function installComponentPropertyAccessors( this: ProxyThis, name: string, type: ComponentPropertyType, - defaultValue: string | boolean + defaultValue: string | boolean, + options?: { preferredValues?: InstanceSwapPreferredValue[] } ) { const node = raw(this, internals) if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') @@ -377,7 +392,10 @@ export function installComponentPropertyAccessors( defaultValue: type === 'BOOLEAN' ? String(defaultValue === true || defaultValue === 'true') - : String(defaultValue) + : String(defaultValue), + ...(options?.preferredValues + ? { preferredValues: options.preferredValues.map((value) => value.key) } + : {}) } updateNode(this, internals, { componentPropertyDefinitions: [...node.componentPropertyDefinitions, definition] From 75c358b214088d0355930c39526abf7c7ebafe4b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 13:25:15 +0300 Subject: [PATCH 08/11] fix(figma-api): validate component property updates --- packages/core/src/figma-api/components.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index 78ea0d471..844a1ae4e 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -231,9 +231,11 @@ function editPropertyDefinitions( ) { throw new Error(`defaultValue is not supported for ${definition.type} properties`) } + const updatedName = changes.name?.trim() + if (updatedName === '') throw new Error('Property name must not be empty') const updated = { ...definition, - ...(changes.name ? { name: changes.name.trim() } : {}), + ...(updatedName ? { name: updatedName } : {}), ...(changes.defaultValue !== undefined ? { defaultValue: @@ -272,7 +274,13 @@ function applyProperty( if (definition.type === 'VARIANT') { throw new Error('setProperties() cannot set VARIANT properties through the adapter') } - applyComponentPropertyValue(graph(target, internals), node.id, definition, String(value)) + const result = applyComponentPropertyValue( + graph(target, internals), + node.id, + definition, + String(value) + ) + if (!result) throw new Error(`Unable to apply component property: ${propertyName(definition)}`) } export function installComponentPropertyAccessors( prototype: object, From 71423fb0f20a62c3151b3899f3597a6a2e2b7e5c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 14:14:53 +0300 Subject: [PATCH 09/11] test(figma-api): assert component property types --- packages/core/src/figma-api/compatibility.ts | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/core/src/figma-api/compatibility.ts b/packages/core/src/figma-api/compatibility.ts index ccc83c468..2a7ce3429 100644 --- a/packages/core/src/figma-api/compatibility.ts +++ b/packages/core/src/figma-api/compatibility.ts @@ -1,6 +1,8 @@ /// import type { FigmaAPI } from './index' +import type { FigmaComponentNode, FigmaComponentSetNode } from './node-types' +import type { FigmaNodeProxy } from './proxy' type Expect = T @@ -35,3 +37,43 @@ export type SupportedPluginAPI = Pick< export type FigmaAPIIncompatibleKeys = IncompatibleKeys export type FigmaAPICompatibility = Expect + +type ComponentPropertyDefinitionsMatch = Expect< + FigmaComponentNode['componentPropertyDefinitions'] extends ComponentPropertyDefinitions + ? ComponentPropertyDefinitions extends FigmaComponentNode['componentPropertyDefinitions'] + ? true + : false + : false +> + +type ComponentPropertyMethodsMatch = Expect< + FigmaComponentNode['addComponentProperty'] extends ComponentNode['addComponentProperty'] + ? FigmaComponentSetNode['editComponentProperty'] extends ComponentSetNode['editComponentProperty'] + ? true + : false + : false +> + +type InstancePropertySurfaceMatch = Expect< + Pick< + FigmaNodeProxy & InstanceNode, + | 'componentProperties' + | 'componentPropertyReferences' + | 'setProperties' + | 'isExposedInstance' + | 'exposedInstances' + > extends Pick< + InstanceNode, + | 'componentProperties' + | 'componentPropertyReferences' + | 'setProperties' + | 'isExposedInstance' + | 'exposedInstances' + > + ? true + : false +> + +export type ComponentPropertyAPICompatibility = ComponentPropertyDefinitionsMatch & + ComponentPropertyMethodsMatch & + InstancePropertySurfaceMatch From b66d13c0986aac50dea06921d35ee040cb0f79e9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 14:36:18 +0300 Subject: [PATCH 10/11] fix(figma-api): keep compatibility checks lintable --- packages/core/src/figma-api/compatibility.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/figma-api/compatibility.ts b/packages/core/src/figma-api/compatibility.ts index 2a7ce3429..7030d02b7 100644 --- a/packages/core/src/figma-api/compatibility.ts +++ b/packages/core/src/figma-api/compatibility.ts @@ -74,6 +74,6 @@ type InstancePropertySurfaceMatch = Expect< : false > -export type ComponentPropertyAPICompatibility = ComponentPropertyDefinitionsMatch & - ComponentPropertyMethodsMatch & - InstancePropertySurfaceMatch +export type ComponentPropertyDefinitionsCompatibility = ComponentPropertyDefinitionsMatch +export type ComponentPropertyMethodsCompatibility = ComponentPropertyMethodsMatch +export type InstancePropertySurfaceCompatibility = InstancePropertySurfaceMatch From 0b0d4f27745134b21938554fbc0f53bdd48fdcf7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 14:54:03 +0300 Subject: [PATCH 11/11] test(figma-api): modernize type assertions --- packages/core/src/figma-api/compatibility.ts | 74 +++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/core/src/figma-api/compatibility.ts b/packages/core/src/figma-api/compatibility.ts index 7030d02b7..e1cfb3545 100644 --- a/packages/core/src/figma-api/compatibility.ts +++ b/packages/core/src/figma-api/compatibility.ts @@ -38,42 +38,48 @@ export type SupportedPluginAPI = Pick< export type FigmaAPIIncompatibleKeys = IncompatibleKeys export type FigmaAPICompatibility = Expect -type ComponentPropertyDefinitionsMatch = Expect< - FigmaComponentNode['componentPropertyDefinitions'] extends ComponentPropertyDefinitions - ? ComponentPropertyDefinitions extends FigmaComponentNode['componentPropertyDefinitions'] - ? true - : false - : false -> +type Extends = Actual extends Expected ? true : false -type ComponentPropertyMethodsMatch = Expect< - FigmaComponentNode['addComponentProperty'] extends ComponentNode['addComponentProperty'] - ? FigmaComponentSetNode['editComponentProperty'] extends ComponentSetNode['editComponentProperty'] - ? true - : false - : false -> - -type InstancePropertySurfaceMatch = Expect< - Pick< - FigmaNodeProxy & InstanceNode, - | 'componentProperties' - | 'componentPropertyReferences' - | 'setProperties' - | 'isExposedInstance' - | 'exposedInstances' - > extends Pick< - InstanceNode, - | 'componentProperties' - | 'componentPropertyReferences' - | 'setProperties' - | 'isExposedInstance' - | 'exposedInstances' - > +type Equal = [Actual] extends [Expected] + ? [Expected] extends [Actual] ? true : false + : false + +type ComponentPropertyDefinitionsMatch = Expect< + Equal +> +const _componentPropertyDefinitionsMatch: ComponentPropertyDefinitionsMatch = true + +type ComponentPropertyMethodsMatch = Expect< + Extends +> +const _componentPropertyMethodsMatch: ComponentPropertyMethodsMatch = true + +type ComponentSetPropertyMethodsMatch = Expect< + Extends +> +const _componentSetPropertyMethodsMatch: ComponentSetPropertyMethodsMatch = true + +type InstancePropertySurfaceMatch = Expect< + Extends< + Pick< + FigmaNodeProxy & InstanceNode, + | 'componentProperties' + | 'componentPropertyReferences' + | 'setProperties' + | 'isExposedInstance' + | 'exposedInstances' + >, + Pick< + InstanceNode, + | 'componentProperties' + | 'componentPropertyReferences' + | 'setProperties' + | 'isExposedInstance' + | 'exposedInstances' + > + > > -export type ComponentPropertyDefinitionsCompatibility = ComponentPropertyDefinitionsMatch -export type ComponentPropertyMethodsCompatibility = ComponentPropertyMethodsMatch -export type InstancePropertySurfaceCompatibility = InstancePropertySurfaceMatch +const _instancePropertySurfaceMatch: InstancePropertySurfaceMatch = true