Merge pull request #688 from open-pencil/jsx-component-properties

feat: author scoped component properties in Design JSX
This commit is contained in:
Danila Poyarkov 2026-09-14 00:24:50 +03:00 committed by GitHub
commit 3df430f036
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 526 additions and 29 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

@ -203,6 +203,7 @@
"svgpath": "^2.6.0",
"twirlwind": "^0.3.0",
"unifont": "0.7.4",
"valibot": "^1.4.2",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3",
},
"devDependencies": {

View file

@ -205,6 +205,7 @@
"svgpath": "^2.6.0",
"twirlwind": "^0.3.0",
"unifont": "0.7.4",
"valibot": "^1.4.2",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3"
},
"devDependencies": {

View file

@ -0,0 +1,103 @@
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'],
definitions?: readonly ComponentPropertyDefinition[]
): 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')
const variantNames = definitions
.filter((item) => item.type === 'VARIANT')
.map((item) => item.name)
if (new Set(variantNames).size !== variantNames.length)
throw new Error('Duplicate variant property names')
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')
if (definitions) {
const definition = definitions.find((item) => item.id === reference.propertyId)
if (!definition)
throw new Error(`Unknown component property reference: ${reference.propertyId}`)
const expectedField = definition.type === 'BOOLEAN' ? 'VISIBLE' : definition.type
if (reference.field !== expectedField)
throw new Error(`Component property ${definition.id} cannot bind ${reference.field}`)
}
}
result.componentPropertyReferences = references
}
return result
}
/** Match the nearest component scope, including definitions inherited from its set. */
export function componentPropertyScope(
graph: SceneGraph,
parentId: string
): readonly ComponentPropertyDefinition[] | undefined {
let parent = graph.getNode(parentId)
while (parent) {
if (parent.type === 'INSTANCE') return componentPropertyDefinitions(graph, parent)
if (parent.type === 'COMPONENT_SET') return parent.componentPropertyDefinitions
if (parent.type === 'COMPONENT') {
const set = parent.parentId ? graph.getNode(parent.parentId) : undefined
return set?.type === 'COMPONENT_SET'
? [...set.componentPropertyDefinitions, ...parent.componentPropertyDefinitions]
: parent.componentPropertyDefinitions
}
parent = parent.parentId ? graph.getNode(parent.parentId) : undefined
}
return undefined
}
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,8 +31,11 @@ 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.
- `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.
- Distinguish a main component's default size from its instance's placement constraints. Verify the actual instance bounds in narrower parents; a requested Fill dimension alone is not proof that inherited sizing changed. Do not compensate for a sizing mismatch with guessed heights, clipping, or manually positioned siblings.
- 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.
## Verification

View file

@ -16,6 +16,11 @@ import type { IconData } from '#core/icons/types'
import { computeAllLayouts } from '#core/layout'
import { randomHex } from '#core/random'
import {
assignComponentProperties,
componentMetadata,
componentPropertyScope
} from './component-properties'
import { applySizeOverrides, propsToOverrides } from './props-overrides'
import { prepareScalarBindings } from './scalar-bindings'
import { isTreeNode } from './tree'
@ -314,7 +319,7 @@ function parseVariantValues(name: string): Record<string, string> {
function inferComponentSetProperties(graph: SceneGraph, componentSetId: string): void {
const componentSet = graph.getNode(componentSetId)
if (componentSet?.type !== 'COMPONENT_SET') return
if (componentSet.componentPropertyDefinitions.length > 0) return
const existingDefinitions = componentSet.componentPropertyDefinitions
const variants = graph.getChildren(componentSetId).filter((node) => node.type === 'COMPONENT')
const options = new Map<string, Set<string>>()
@ -333,8 +338,14 @@ function inferComponentSetProperties(graph: SceneGraph, componentSetId: string):
}
}
const definitions: ComponentPropertyDefinition[] = [...options.entries()].map(
([name, values]) => {
const definitions: ComponentPropertyDefinition[] = [...options.entries()]
.filter(
([name]) =>
!existingDefinitions.some(
(definition) => definition.type === 'VARIANT' && definition.name === name
)
)
.map(([name, values]) => {
const variantOptions = [...values]
return {
id: `prop:${randomHex(8)}`,
@ -343,14 +354,14 @@ function inferComponentSetProperties(graph: SceneGraph, componentSetId: string):
defaultValue: variantOptions[0] ?? '',
variantOptions
}
}
)
if (definitions.length === 0) return
})
for (const [id, values] of valuesById) {
graph.updateNode(id, { componentPropertyValues: values })
}
graph.updateNode(componentSetId, { componentPropertyDefinitions: definitions })
graph.updateNode(componentSetId, {
componentPropertyDefinitions: [...existingDefinitions, ...definitions]
})
}
function findComponentByName(graph: SceneGraph, name: string): SceneNode | undefined {
@ -367,7 +378,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 +430,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', componentPropertyScope(graph, parentId))
}
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 +489,22 @@ function applyInstanceOverrides(
}
}
async function renderArtworkNode(
graph: SceneGraph,
tree: TreeNode,
parentId: string
): Promise<SceneNode> {
const metadata = componentMetadata(tree.props, 'VECTOR', componentPropertyScope(graph, parentId))
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 +515,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, componentPropertyScope(graph, parentId))
}
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'
@ -163,12 +163,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,8 +33,11 @@ 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.
- `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.
- Distinguish a main component's default size from its instance's placement constraints. Verify the actual instance bounds in narrower parents; a requested Fill dimension alone is not proof that inherited sizing changed. Do not compensate for a sizing mismatch with guessed heights, clipping, or manually positioned siblings.
- 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.
## Verification
@ -67,4 +70,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,8 +33,11 @@ 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.
- `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.
- Distinguish a main component's default size from its instance's placement constraints. Verify the actual instance bounds in narrower parents; a requested Fill dimension alone is not proof that inherited sizing changed. Do not compensate for a sizing mismatch with guessed heights, clipping, or manually positioned siblings.
- 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.
## Verification
@ -67,4 +70,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,201 @@
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'
)
for (const element of [Component, ComponentSet]) {
await expect(
renderTree(
graph,
element({
properties: [
{ id: 'kind-a', name: 'Kind', type: 'VARIANT', defaultValue: 'A' },
{ id: 'kind-b', name: 'Kind', type: 'VARIANT', defaultValue: 'B' }
]
})
)
).rejects.toThrow('Duplicate variant property names')
}
})
test('preserves duplicate display names for non-variant properties with distinct IDs', async () => {
const graph = makeSceneGraph()
const result = await renderTree(
graph,
Component({ properties: [MESSAGE, { ...MESSAGE, id: 'other-message' }] })
)
expect(
getNodeOrThrow(graph, result.id).componentPropertyDefinitions.map((item) => item.id)
).toEqual(['message', 'other-message'])
})

View file

@ -0,0 +1,65 @@
import { expect, test } from 'bun:test'
import { Component, ComponentSet, Frame, Text, renderTree } from '@open-pencil/core/design-jsx'
import type { ComponentPropertyDefinition } from '@open-pencil/scene-graph'
import { makeSceneGraph } from '#tests/helpers/scene'
const definitions: ComponentPropertyDefinition[] = [
{ id: 'caption', name: 'Caption', type: 'TEXT', defaultValue: 'Hello' },
{ id: 'visible', name: 'Visible', type: 'BOOLEAN', defaultValue: 'true' }
]
for (const reference of [
{ propertyId: 'missing', field: 'TEXT' },
{ propertyId: 'visible', field: 'TEXT' },
{ propertyId: 'caption', field: 'VISIBLE' }
] as const) {
test(`rejects invalid scoped reference ${reference.propertyId}/${reference.field}`, async () => {
await expect(
renderTree(
makeSceneGraph(),
Component({
properties: definitions,
children: Frame({ children: Text({ children: 'Hello', propertyRefs: [reference] }) })
})
)
).rejects.toThrow(/component property/i)
})
}
test('accepts references inherited from the owning component set', async () => {
const result = await renderTree(
makeSceneGraph(),
ComponentSet({
properties: definitions,
children: Component({
name: 'Kind=Default',
children: Frame({
children: Text({
children: 'Hello',
propertyRefs: [{ propertyId: 'caption', field: 'TEXT' }]
})
})
})
})
)
expect(result.type).toBe('COMPONENT_SET')
})
test('does not resolve a nested component reference against the outer component', async () => {
await expect(
renderTree(
makeSceneGraph(),
Component({
properties: definitions,
children: Component({
children: Text({
children: 'Hello',
propertyRefs: [{ propertyId: 'caption', field: 'TEXT' }]
})
})
})
)
).rejects.toThrow('Unknown component property reference: caption')
})

View file

@ -0,0 +1,53 @@
import { expect, test } from 'bun:test'
import { Component, ComponentSet, Instance, Text, renderTree } from '@open-pencil/core/design-jsx'
import type { ComponentPropertyDefinition } from '@open-pencil/scene-graph'
import { getNodeOrThrow } from '#tests/helpers/assert'
import { makeSceneGraph } from '#tests/helpers/scene'
const MESSAGE: ComponentPropertyDefinition = {
id: 'message',
name: 'Message',
type: 'TEXT',
defaultValue: 'Default'
}
const VARIANT: ComponentPropertyDefinition = {
id: 'variant',
name: 'variant',
type: 'VARIANT',
defaultValue: 'Primary',
variantOptions: ['Primary', 'Secondary']
}
for (const explicitVariant of [false, true]) {
test(`component-set properties preserve ${explicitVariant ? 'explicit' : 'inferred'} variant selection`, async () => {
const graph = makeSceneGraph()
const set = await renderTree(
graph,
ComponentSet({
properties: explicitVariant ? [MESSAGE, VARIANT] : [MESSAGE],
children: ['Primary', 'Secondary'].map((variant) =>
Component({
name: `variant=${variant}`,
children: Text({
children: 'Default',
propertyRefs: [{ propertyId: MESSAGE.id, field: 'TEXT' }]
})
})
)
})
)
const result = await renderTree(
graph,
Instance({ of: set.id, variant: 'Secondary', 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')
const definitions = getNodeOrThrow(graph, set.id).componentPropertyDefinitions
expect(definitions).toContainEqual(MESSAGE)
expect(definitions.filter((definition) => definition.type === 'VARIANT')).toHaveLength(1)
if (explicitVariant) expect(definitions).toContainEqual(VARIANT)
})
}