feat: author scoped component properties in Design JSX

This commit is contained in:
Danila Poyarkov 2026-09-13 19:27:06 +03:00
parent 863b1e6c6a
commit becc4f7432
12 changed files with 331 additions and 22 deletions

View file

@ -9,6 +9,7 @@
### Added
- Bind Design JSX spacing, sizing, corners, and typography directly to numeric document variables.
- Define component properties and assign instance values in Design JSX using stable property IDs.
- Save AI conversations and attachment previews locally, switch between chats, rename or delete them, and browse saved transcripts across documents. Choose whether reasoning stays collapsed, expands while thinking, or stays expanded, with animated disclosure controls that respect reduced motion.
- Add a searchable command palette for editor and application actions.

View file

@ -0,0 +1,69 @@
import * as v from 'valibot'
import { applyComponentPropertyValue, componentPropertyDefinitions } from '@open-pencil/scene-graph'
import type {
ComponentPropertyDefinition,
ComponentPropertyReference,
SceneGraph,
SceneNode
} from '@open-pencil/scene-graph'
const definitionSchema = v.object({
id: v.string(),
name: v.string(),
type: v.picklist(['VARIANT', 'TEXT', 'BOOLEAN', 'INSTANCE_SWAP']),
defaultValue: v.string(),
variantOptions: v.optional(v.array(v.string())),
preferredValues: v.optional(v.array(v.string()))
}) satisfies v.GenericSchema<ComponentPropertyDefinition>
const referenceSchema = v.object({
propertyId: v.string(),
field: v.picklist(['VISIBLE', 'TEXT', 'INSTANCE_SWAP'])
}) satisfies v.GenericSchema<ComponentPropertyReference>
/** Accept the native graph contracts, without introducing a second property model. */
export function componentMetadata(
props: Record<string, unknown>,
type: SceneNode['type']
): Partial<SceneNode> {
const result: Partial<SceneNode> = {}
if (props.properties !== undefined && type !== 'INSTANCE') {
if (type !== 'COMPONENT' && type !== 'COMPONENT_SET')
throw new Error('Only components, component sets, and instances accept properties')
const definitions = v.parse(v.array(definitionSchema), props.properties)
if (new Set(definitions.map((definition) => definition.id)).size !== definitions.length)
throw new Error('Duplicate component property IDs')
result.componentPropertyDefinitions = definitions
}
if (props.propertyRefs !== undefined) {
const references = v.parse(v.array(referenceSchema), props.propertyRefs)
for (const reference of references) {
if (reference.field === 'TEXT' && type !== 'TEXT')
throw new Error('TEXT properties require a text node')
if (reference.field === 'INSTANCE_SWAP' && type !== 'INSTANCE')
throw new Error('INSTANCE_SWAP properties require an instance')
}
result.componentPropertyReferences = references
}
return result
}
export function assignComponentProperties(
graph: SceneGraph,
instance: SceneNode,
input: unknown
): void {
if (input === undefined) return
const assignments = v.parse(v.record(v.string(), v.string()), input)
const definitions = componentPropertyDefinitions(graph, instance)
for (const [id, value] of Object.entries(assignments)) {
const definition = definitions.find((item) => item.id === id)
if (!definition) throw new Error(`Unknown component property: ${id}`)
if (definition.type === 'VARIANT')
throw new Error('Select a component-set variant when creating the instance')
if (definition.type === 'BOOLEAN' && value !== 'true' && value !== 'false')
throw new Error(`Expected true or false for component property: ${id}`)
if (!applyComponentPropertyValue(graph, instance.id, definition, value))
throw new Error(`Cannot assign component property: ${id}`)
}
}

View file

@ -1,4 +1,11 @@
import { node, type BaseProps, type TextProps, type TreeNode } from './tree'
import {
node,
type BaseProps,
type ComponentProps,
type InstanceProps,
type TextProps,
type TreeNode
} from './tree'
type Child = TreeNode | string
@ -55,18 +62,15 @@ export function Section(props: BaseProps, ...children: Child[]): TreeNode {
return withChildren('section', props, children)
}
export function Component(props: BaseProps, ...children: Child[]): TreeNode {
export function Component(props: ComponentProps, ...children: Child[]): TreeNode {
return withChildren('component', props, children)
}
export function ComponentSet(props: BaseProps, ...children: Child[]): TreeNode {
export function ComponentSet(props: ComponentProps, ...children: Child[]): TreeNode {
return withChildren('component-set', props, children)
}
export function Instance(
props: BaseProps & { component?: string; componentId?: string; of?: string },
...children: Child[]
): TreeNode {
export function Instance(props: InstanceProps, ...children: Child[]): TreeNode {
return withChildren('instance', props, children)
}

View file

@ -21,6 +21,8 @@ export {
export {
type TreeNode,
type BaseProps,
type ComponentProps,
type InstanceProps,
type TextProps,
type StyleProps,
type PaintProp,

View file

@ -31,7 +31,10 @@ This reference describes scene creation, not React DOM output. Use the `render`
- `bind` maps supported scene-field paths to variable IDs or references when no shorthand exists. Use semantic tokens consistently rather than declaring unused collections.
- A reusable JavaScript function shares source code, not component identity. Use `Component`, `ComponentSet`, and `Instance` for editable main components and linked instances.
- `Instance` resolves an existing component through `of`, `component`, or `componentId`. Component-set children named `variant=Primary`, for example, define variants that can be selected when instantiating the set.
- Reuse existing local or library components before recreating them. Expose meaningful text, visibility, and swap properties through the existing component-property APIs; do not assume every native property API is already exposed as a JSX prop.
- `Component` and `ComponentSet` accept `properties`, an array of native property definitions (`id`, `name`, `type`, `defaultValue`). `Instance` accepts `properties`, an ID-to-value assignment object. Ordinary nodes do not accept `properties`.
- Child `propertyRefs` connect fields to stable property IDs, for example `[{ propertyId: 'message', field: 'TEXT' }]`. Supported fields are `TEXT`, `VISIBLE`, and `INSTANCE_SWAP`; text and swap references require text and instance nodes respectively. References do not depend on layer names.
- Instance assignments use the native string values (including `'true'` / `'false'` for BOOLEAN properties and component IDs for swaps). For example `Instance({ of: noteId, properties: { message: 'Updated review' } })`. Assignments persist through component synchronization; unknown IDs and invalid values fail rather than silently creating inert overrides. Select variants through component-set variant props, not through instance property assignments.
- Reuse existing local or library components before recreating them. Keep meaningful text, visibility, and swap properties exposed rather than hand-editing cloned child nodes.
## Verification

View file

@ -16,6 +16,7 @@ import type { IconData } from '#core/icons/types'
import { computeAllLayouts } from '#core/layout'
import { randomHex } from '#core/random'
import { assignComponentProperties, componentMetadata } from './component-properties'
import { applySizeOverrides, propsToOverrides } from './props-overrides'
import { prepareScalarBindings } from './scalar-bindings'
import { isTreeNode } from './tree'
@ -367,7 +368,11 @@ function findVariantInSet(
) {
const requested = Object.fromEntries(
Object.entries(props)
.filter(([key]) => !['component', 'componentId', 'of', 'name', 'children'].includes(key))
.filter(([key]) =>
componentSet.componentPropertyDefinitions.some(
(definition) => definition.type === 'VARIANT' && definition.name === key
)
)
.map(([key, value]) => [key, String(value)])
)
const variants = graph.getChildren(componentSet.id).filter((node) => node.type === 'COMPONENT')
@ -415,12 +420,21 @@ async function renderInstanceNode(
const label = typeof ref === 'string' || typeof ref === 'number' ? String(ref) : ''
throw new Error(`<Instance> component not found: ${label}`)
}
const overrides = propsToOverrides(props, false, parentLayout)
const overrides = {
...propsToOverrides(props, false, parentLayout),
...componentMetadata(props, 'INSTANCE')
}
const instance =
graph.createInstance(component.id, parentId, overrides) ?? graph.createNode('FRAME', parentId)
applyBindings(graph, instance.id, bindings)
applyInstanceOverrides(graph, instance, tree.props.overrides)
return instance
try {
applyBindings(graph, instance.id, bindings)
applyInstanceOverrides(graph, instance, tree.props.overrides)
assignComponentProperties(graph, instance, props.properties)
return instance
} catch (error) {
graph.deleteNode(instance.id)
throw error
}
}
/**
@ -465,9 +479,22 @@ function applyInstanceOverrides(
}
}
async function renderArtworkNode(
graph: SceneGraph,
tree: TreeNode,
parentId: string
): Promise<SceneNode> {
const metadata = componentMetadata(tree.props, 'VECTOR')
const node =
tree.type === 'icon'
? await renderIconNode(graph, tree, parentId)
: await renderSVGNode(graph, tree, parentId)
if (Object.keys(metadata).length > 0) graph.updateNode(node.id, metadata)
return node
}
async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise<SceneNode> {
if (tree.type === 'icon') return renderIconNode(graph, tree, parentId)
if (tree.type === 'svg') return renderSVGNode(graph, tree, parentId)
if (tree.type === 'icon' || tree.type === 'svg') return renderArtworkNode(graph, tree, parentId)
if (tree.type === 'instance') return renderInstanceNode(graph, tree, parentId)
const nodeType = TYPE_MAP[tree.type]
@ -478,7 +505,10 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string):
const isText = nodeType === 'TEXT'
const { props, bindings } = preparePropsForRender(graph, tree.props, isText, parentId)
const overrides = propsToOverrides(props, isText, parentLayout)
const overrides = {
...propsToOverrides(props, isText, parentLayout),
...componentMetadata(props, nodeType)
}
if (isText) {
const childText = tree.children.filter((c): c is string => typeof c === 'string').join('')

View file

@ -137,6 +137,8 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
'bind',
'component',
'componentId',
'properties',
'propertyRefs',
'of'
] as const

View file

@ -1,4 +1,4 @@
import type { Effect, Fill } from '@open-pencil/scene-graph'
import type { Effect, Fill, SceneNode } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { DesignVariable } from './vars'
@ -160,12 +160,25 @@ export type StyleProps = {
textAutoResize?: 'none' | 'width' | 'height'
}
export type BaseProps = StyleProps & {
type NodeProps = StyleProps & {
name?: string
key?: string | number
children?: unknown
bind?: Record<string, unknown>
propertyRefs?: SceneNode['componentPropertyReferences']
[key: string]: unknown
}
export type BaseProps = NodeProps & { properties?: never }
export type TextProps = BaseProps
export type ComponentProps = NodeProps & {
properties?: SceneNode['componentPropertyDefinitions']
}
export type InstanceProps = NodeProps & {
component?: string
componentId?: string
of?: string
properties?: SceneNode['componentPropertyAssignments']
}

View file

@ -351,6 +351,8 @@ export {
node,
type TreeNode,
type BaseProps,
type ComponentProps,
type InstanceProps,
type TextProps,
type StyleProps,
type PaintProp,

View file

@ -33,7 +33,10 @@ This reference describes scene creation, not React DOM output. Use the `render`
- `bind` maps supported scene-field paths to variable IDs or references when no shorthand exists. Use semantic tokens consistently rather than declaring unused collections.
- A reusable JavaScript function shares source code, not component identity. Use `Component`, `ComponentSet`, and `Instance` for editable main components and linked instances.
- `Instance` resolves an existing component through `of`, `component`, or `componentId`. Component-set children named `variant=Primary`, for example, define variants that can be selected when instantiating the set.
- Reuse existing local or library components before recreating them. Expose meaningful text, visibility, and swap properties through the existing component-property APIs; do not assume every native property API is already exposed as a JSX prop.
- `Component` and `ComponentSet` accept `properties`, an array of native property definitions (`id`, `name`, `type`, `defaultValue`). `Instance` accepts `properties`, an ID-to-value assignment object. Ordinary nodes do not accept `properties`.
- Child `propertyRefs` connect fields to stable property IDs, for example `[{ propertyId: 'message', field: 'TEXT' }]`. Supported fields are `TEXT`, `VISIBLE`, and `INSTANCE_SWAP`; text and swap references require text and instance nodes respectively. References do not depend on layer names.
- Instance assignments use the native string values (including `'true'` / `'false'` for BOOLEAN properties and component IDs for swaps). For example `Instance({ of: noteId, properties: { message: 'Updated review' } })`. Assignments persist through component synchronization; unknown IDs and invalid values fail rather than silently creating inert overrides. Select variants through component-set variant props, not through instance property assignments.
- Reuse existing local or library components before recreating them. Keep meaningful text, visibility, and swap properties exposed rather than hand-editing cloned child nodes.
## Verification
@ -66,4 +69,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a
**Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `of`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.

View file

@ -33,7 +33,10 @@ This reference describes scene creation, not React DOM output. Use the `render`
- `bind` maps supported scene-field paths to variable IDs or references when no shorthand exists. Use semantic tokens consistently rather than declaring unused collections.
- A reusable JavaScript function shares source code, not component identity. Use `Component`, `ComponentSet`, and `Instance` for editable main components and linked instances.
- `Instance` resolves an existing component through `of`, `component`, or `componentId`. Component-set children named `variant=Primary`, for example, define variants that can be selected when instantiating the set.
- Reuse existing local or library components before recreating them. Expose meaningful text, visibility, and swap properties through the existing component-property APIs; do not assume every native property API is already exposed as a JSX prop.
- `Component` and `ComponentSet` accept `properties`, an array of native property definitions (`id`, `name`, `type`, `defaultValue`). `Instance` accepts `properties`, an ID-to-value assignment object. Ordinary nodes do not accept `properties`.
- Child `propertyRefs` connect fields to stable property IDs, for example `[{ propertyId: 'message', field: 'TEXT' }]`. Supported fields are `TEXT`, `VISIBLE`, and `INSTANCE_SWAP`; text and swap references require text and instance nodes respectively. References do not depend on layer names.
- Instance assignments use the native string values (including `'true'` / `'false'` for BOOLEAN properties and component IDs for swaps). For example `Instance({ of: noteId, properties: { message: 'Updated review' } })`. Assignments persist through component synchronization; unknown IDs and invalid values fail rather than silently creating inert overrides. Select variants through component-set variant props, not through instance property assignments.
- Reuse existing local or library components before recreating them. Keep meaningful text, visibility, and swap properties exposed rather than hand-editing cloned child nodes.
## Verification
@ -66,4 +69,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a
**Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `of`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.

View file

@ -0,0 +1,177 @@
import { expect, expectTypeOf, test } from 'bun:test'
import {
Component,
ComponentSet,
type Frame,
Instance,
Text,
node,
renderTree
} from '@open-pencil/core/design-jsx'
import type { ComponentPropertyDefinition, SceneNode } from '@open-pencil/scene-graph'
import { getNodeOrThrow } from '#tests/helpers/assert'
import { makeSceneGraph } from '#tests/helpers/scene'
test('property props are scoped to their authoring entities', () => {
expectTypeOf<Parameters<typeof Frame>[0]['properties']>().toEqualTypeOf<undefined>()
expectTypeOf<Parameters<typeof Text>[0]['properties']>().toEqualTypeOf<undefined>()
expectTypeOf<Parameters<typeof Component>[0]['properties']>().toEqualTypeOf<
SceneNode['componentPropertyDefinitions'] | undefined
>()
expectTypeOf<Parameters<typeof Instance>[0]['properties']>().toEqualTypeOf<
SceneNode['componentPropertyAssignments'] | undefined
>()
})
const MESSAGE: ComponentPropertyDefinition = {
id: 'message',
name: 'Message',
type: 'TEXT',
defaultValue: 'Default'
}
const VISIBLE: ComponentPropertyDefinition = {
id: 'visible',
name: 'Visible',
type: 'BOOLEAN',
defaultValue: 'true'
}
async function setup() {
const graph = makeSceneGraph()
const component = await renderTree(
graph,
Component({
name: 'Note',
flex: 'col',
w: 280,
h: 'hug',
properties: [MESSAGE, VISIBLE],
children: [
Text({
name: 'Duplicate name',
children: 'Default',
w: 'fill',
color: '#111111',
propertyRefs: [
{ propertyId: MESSAGE.id, field: 'TEXT' },
{ propertyId: VISIBLE.id, field: 'VISIBLE' }
]
}),
Text({ name: 'Duplicate name', children: 'Unrelated', color: '#111111' })
]
})
)
return { graph, component }
}
test('assigns real text and visibility properties without resolving child names', async () => {
const { graph, component } = await setup()
const result = await renderTree(
graph,
Instance({ of: component.id, properties: { message: 'June’s review', visible: 'false' } })
)
const instance = getNodeOrThrow(graph, result.id)
const children = graph.getChildren(instance.id)
expect(children.map((child) => child.text)).toEqual(['June’s review', 'Unrelated'])
expect(children[0].visible).toBe(false)
expect(instance.componentPropertyAssignments).toEqual({
message: 'June’s review',
visible: 'false'
})
graph.syncInstances(component.id)
expect(graph.getChildren(instance.id)[0].text).toBe('June’s review')
expect(graph.getChildren(instance.id)[0].visible).toBe(false)
expect(graph.getChildren(component.id)[0].text).toBe('Default')
})
test('variant selection ignores non-variant instance props', async () => {
const graph = makeSceneGraph()
const set = await renderTree(
graph,
ComponentSet({
name: 'Button',
children: [
Component({ name: 'variant=Primary', w: 100, h: 40 }),
Component({
name: 'variant=Secondary',
w: 100,
h: 40,
properties: [MESSAGE],
children: Text({
children: 'Default',
propertyRefs: [{ propertyId: MESSAGE.id, field: 'TEXT' }]
})
})
]
})
)
const result = await renderTree(
graph,
Instance({ of: set.id, variant: 'Secondary', w: 120, properties: { message: 'Changed' } })
)
const instance = getNodeOrThrow(graph, result.id)
expect(instance.componentId).toBe(graph.getChildren(set.id)[1].id)
expect(graph.getChildren(instance.id)[0].text).toBe('Changed')
})
test('invalid assignments remove the new instance and do not edit its source', async () => {
const { graph, component } = await setup()
const count = graph.nodes.size
for (const assignments of [
{ missing: 'value' },
{ visible: 'yes' },
{ message: 'Changed', missing: 'value' }
]) {
await expect(
renderTree(graph, Instance({ of: component.id, properties: assignments }))
).rejects.toThrow()
expect(graph.nodes.size).toBe(count)
expect(graph.getChildren(component.id)[0].text).toBe('Default')
}
})
test('assigns nested instance swaps through stable property references', async () => {
const graph = makeSceneGraph()
const first = await renderTree(graph, Component({ name: 'First', w: 20, h: 20 }))
const second = await renderTree(graph, Component({ name: 'Second', w: 30, h: 30 }))
const component = await renderTree(
graph,
Component({
name: 'With slot',
properties: [{ id: 'slot', name: 'Slot', type: 'INSTANCE_SWAP', defaultValue: first.id }],
children: Instance({
of: first.id,
propertyRefs: [{ propertyId: 'slot', field: 'INSTANCE_SWAP' }]
})
})
)
const result = await renderTree(
graph,
Instance({ of: component.id, properties: { slot: second.id } })
)
expect(graph.getChildren(result.id)[0].componentId).toBe(second.id)
graph.syncInstances(component.id)
expect(graph.getChildren(result.id)[0].componentId).toBe(second.id)
expect(graph.getChildren(component.id)[0].componentId).toBe(first.id)
})
test('ordinary elements reject properties before rendering', async () => {
const graph = makeSceneGraph()
for (const type of ['frame', 'text', 'rectangle', 'icon', 'svg']) {
await expect(renderTree(graph, node(type, { properties: [MESSAGE] }))).rejects.toThrow(
'Only components'
)
}
})
test('validates metadata rather than accepting silent malformed property definitions', async () => {
const graph = makeSceneGraph()
await expect(renderTree(graph, node('text', { properties: [MESSAGE] }))).rejects.toThrow(
'Only components'
)
await expect(renderTree(graph, Component({ properties: [MESSAGE, MESSAGE] }))).rejects.toThrow(
'Duplicate'
)
})