diff --git a/packages/core/src/figma-api/compatibility.ts b/packages/core/src/figma-api/compatibility.ts
index ccc83c468..e1cfb3545 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,49 @@ export type SupportedPluginAPI = Pick<
export type FigmaAPIIncompatibleKeys = IncompatibleKeys
export type FigmaAPICompatibility = Expect
+
+type Extends = Actual extends Expected ? true : false
+
+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'
+ >
+ >
+>
+
+const _instancePropertySurfaceMatch: InstancePropertySurfaceMatch = true
diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts
index 6582b674a..844a1ae4e 100644
--- a/packages/core/src/figma-api/components.ts
+++ b/packages/core/src/figma-api/components.ts
@@ -1,13 +1,45 @@
-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,
+ 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'
+type InstanceSwapPreferredValue = { type: 'COMPONENT' | 'COMPONENT_SET'; key: string }
+
const COMPONENT_SET_PADDING = 40
+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]: FigmaComponentProperty
+}
+
export function exposeInstanceSwap(
graph: SceneGraph,
slots: ReadonlyArray,
@@ -89,6 +121,317 @@ function requireDistinctComponents(graph: SceneGraph, nodeIds: ReadonlyArray {
+ 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 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
+): 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,
+ ...propertyMetadata(target, internals, definition, true)
+ }
+ ])
+ )
+}
+
+function componentProperties(
+ target: ProxyThis,
+ internals: NodeProxyInternals
+): FigmaComponentProperties {
+ 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,
+ ...propertyMetadata(target, internals, definition, false)
+ }
+ ]
+ })
+ )
+}
+
+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
+ 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 updatedName = changes.name?.trim()
+ if (updatedName === '') throw new Error('Property name must not be empty')
+ const updated = {
+ ...definition,
+ ...(updatedName ? { name: updatedName } : {}),
+ ...(changes.defaultValue !== undefined
+ ? {
+ defaultValue:
+ definition.type === 'BOOLEAN'
+ ? String(changes.defaultValue === true || changes.defaultValue === 'true')
+ : String(changes.defaultValue)
+ }
+ : {}),
+ ...(changes.preferredValues
+ ? { preferredValues: changes.preferredValues.map((value) => value.key) }
+ : {})
+ }
+ updateNode(target, internals, {
+ componentPropertyDefinitions: node.componentPropertyDefinitions.map((item) =>
+ item.id === definition.id ? updated : item
+ )
+ })
+ 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,
+ node: SceneNode,
+ definition: ComponentPropertyDefinition,
+ value: string | boolean
+): void {
+ if (definition.type === 'VARIANT') {
+ throw new Error('setProperties() cannot set VARIANT properties through the adapter')
+ }
+ 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,
+ 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' &&
+ node.type !== 'FRAME' &&
+ node.type !== 'TEXT'
+ )
+ return null
+ return Object.fromEntries(
+ node.componentPropertyReferences.map((reference) => [
+ 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: {
+ 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) throw new Error(`Unknown component property: ${name}`)
+ applyProperty(this, internals, node, definition, value)
+ }
+ }
+ },
+ addComponentProperty: {
+ value(
+ this: ProxyThis,
+ name: string,
+ type: ComponentPropertyType,
+ defaultValue: string | boolean,
+ options?: { preferredValues?: InstanceSwapPreferredValue[] }
+ ) {
+ 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,
+ defaultValue:
+ type === 'BOOLEAN'
+ ? String(defaultValue === true || defaultValue === 'true')
+ : String(defaultValue),
+ ...(options?.preferredValues
+ ? { preferredValues: options.preferredValues.map((value) => value.key) }
+ : {})
+ }
+ 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..038a8a03f 100644
--- a/tests/engine/figma/api/components.test.ts
+++ b/tests/engine/figma/api/components.test.ts
@@ -5,6 +5,53 @@ 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('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()