feat: bind Design JSX scalar props to variables

This commit is contained in:
Danila Poyarkov 2026-09-13 19:06:20 +03:00
parent 59c31c74bf
commit 98e0ba9563
6 changed files with 270 additions and 45 deletions

View file

@ -8,6 +8,7 @@
### Added
- Bind Design JSX spacing, sizing, corners, and typography directly to numeric document variables.
- 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

@ -17,9 +17,10 @@ import { computeAllLayouts } from '#core/layout'
import { randomHex } from '#core/random'
import { applySizeOverrides, propsToOverrides } from './props-overrides'
import { prepareScalarBindings } from './scalar-bindings'
import { isTreeNode } from './tree'
import type { TreeNode } from './tree'
import { isVariable, type DesignVariable } from './vars'
import { isVariable, resolveVariableId, type DesignVariable } from './vars'
const TYPE_MAP: Partial<Record<string, NodeType>> = {
frame: 'FRAME',
@ -93,17 +94,8 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function resolveVariableId(graph: SceneGraph, variable: DesignVariable): string | undefined {
if (variable.id && graph.variables.has(variable.id)) return variable.id
if (variable.id && !variable.name) return variable.id
for (const candidate of graph.variables.values()) {
if (candidate.name === variable.name || candidate.id === variable.name) return candidate.id
}
return variable.id
}
function variableFallback(graph: SceneGraph, variable: DesignVariable): string | Color | undefined {
if (variable.value !== undefined) return variable.value
if (variable.value !== undefined && typeof variable.value !== 'number') return variable.value
const variableId = resolveVariableId(graph, variable)
return variableId ? graph.resolveColorVariable(variableId) : undefined
}
@ -141,7 +133,8 @@ function bindStyleVariableProp(
function preparePropsForRender(
graph: SceneGraph,
source: Record<string, unknown>,
isText: boolean
isText: boolean,
parentId: string
): PreparedProps {
const props = { ...source }
const bindings: Record<string, string> = {}
@ -173,6 +166,8 @@ function preparePropsForRender(
props.style = style
}
prepareScalarBindings(graph, props, bindings, isText, parentId)
if (isObjectRecord(props.bind)) {
for (const [field, value] of Object.entries(props.bind)) {
if (isVariable(value)) {
@ -413,7 +408,7 @@ async function renderInstanceNode(
): Promise<SceneNode> {
const parent = graph.getNode(parentId)
const parentLayout = parent?.layoutMode ?? 'NONE'
const { props, bindings } = preparePropsForRender(graph, tree.props, false)
const { props, bindings } = preparePropsForRender(graph, tree.props, false, parentId)
const component = resolveComponent(graph, props)
if (!component) {
const ref = props.component ?? props.componentId ?? props.of
@ -482,7 +477,7 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string):
const parentLayout = parent?.layoutMode ?? 'NONE'
const isText = nodeType === 'TEXT'
const { props, bindings } = preparePropsForRender(graph, tree.props, isText)
const { props, bindings } = preparePropsForRender(graph, tree.props, isText, parentId)
const overrides = propsToOverrides(props, isText, parentLayout)
if (isText) {

View file

@ -0,0 +1,77 @@
import type { SceneGraph } from '@open-pencil/scene-graph'
import { isVariable, resolveVariableId } from './vars'
// Shorthands precede their longhands, matching propsToOverrides precedence.
const PADDING_FIELDS = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']
const SCALAR_PROPS: ReadonlyArray<readonly [string, readonly string[]]> = [
['w', ['width']],
['h', ['height']],
['p', PADDING_FIELDS],
['px', ['paddingLeft', 'paddingRight']],
['py', ['paddingTop', 'paddingBottom']],
['pt', ['paddingTop']],
['pr', ['paddingRight']],
['pb', ['paddingBottom']],
['pl', ['paddingLeft']],
['rounded', ['cornerRadius']],
['roundedTL', ['topLeftRadius']],
['roundedTR', ['topRightRadius']],
['roundedBL', ['bottomLeftRadius']],
['roundedBR', ['bottomRightRadius']],
['strokeWidth', ['strokeWeight']],
['opacity', ['opacity']]
]
/** Resolve numeric props before sizing inference; retain real graph bindings afterward. */
export function prepareScalarBindings(
graph: SceneGraph,
props: Record<string, unknown>,
bindings: Record<string, string>,
isText: boolean,
parentId: string
): void {
const entries = [...SCALAR_PROPS]
const scalarBindings = new Map<string, string>()
if (props.grid) {
// Existing grid authoring gives gap precedence over the axis-specific props.
entries.push(
['columnGap', ['gridColumnGap']],
['rowGap', ['gridRowGap']],
['gap', ['gridColumnGap', 'gridRowGap']]
)
} else {
entries.push(['gap', ['itemSpacing']])
if (props.wrap) entries.push(['rowGap', ['counterAxisSpacing']])
}
if (isText) {
entries.push(
[props.size !== undefined ? 'size' : 'fontSize', ['fontSize']],
['lineHeight', ['lineHeight']],
['letterSpacing', ['letterSpacing']]
)
}
for (const [key, fields] of entries) {
const value = props[key]
if (value === undefined) continue
// A literal longhand must also override a bound shorthand on that edge.
for (const field of fields) scalarBindings.delete(field)
if (!isVariable(value)) continue
const variableId = resolveVariableId(graph, value)
const variable = variableId ? graph.variables.get(variableId) : undefined
if (variable?.type !== 'FLOAT') {
throw new Error(`Expected a FLOAT variable for ${key}: ${value.name}`)
}
const resolved =
graph.resolveNumberVariableForNode(parentId, variable.id) ??
(typeof value.value === 'number' ? value.value : undefined)
if (resolved === undefined || !Number.isFinite(resolved)) {
throw new Error(`Cannot resolve numeric variable for ${key}: ${value.name}`)
}
props[key] = resolved
for (const field of fields) scalarBindings.set(field, variable.id)
}
Object.assign(bindings, Object.fromEntries(scalarBindings))
}

View file

@ -92,9 +92,9 @@ export type StyleProps = {
flex?: 'row' | 'col' | 'column'
flow?: 'auto' | 'ltr' | 'rtl'
dir?: 'auto' | 'ltr' | 'rtl'
gap?: number
gap?: number | DesignVariable
wrap?: boolean
rowGap?: number
rowGap?: number | DesignVariable
justify?: 'start' | 'end' | 'center' | 'between'
justifyContent?: 'start' | 'end' | 'center' | 'between'
items?: 'start' | 'end' | 'center' | 'stretch'
@ -102,8 +102,8 @@ export type StyleProps = {
alignItems?: 'start' | 'end' | 'center' | 'stretch'
grow?: number
w?: number | 'fill' | 'hug'
h?: number | 'fill' | 'hug'
w?: number | 'fill' | 'hug' | DesignVariable
h?: number | 'fill' | 'hug' | DesignVariable
minW?: number
maxW?: number
minH?: number
@ -112,28 +112,28 @@ export type StyleProps = {
x?: number
y?: number
p?: number
px?: number
py?: number
pt?: number
pr?: number
pb?: number
pl?: number
p?: number | DesignVariable
px?: number | DesignVariable
py?: number | DesignVariable
pt?: number | DesignVariable
pr?: number | DesignVariable
pb?: number | DesignVariable
pl?: number | DesignVariable
bg?: PaintProp
fill?: PaintProp
fills?: PaintProp[]
stroke?: PaintProp
strokeWidth?: number
strokeWidth?: number | DesignVariable
strokeAlign?: 'inside' | 'outside' | 'center'
strokeDash?: number[] | boolean
rounded?: number
roundedTL?: number
roundedTR?: number
roundedBL?: number
roundedBR?: number
rounded?: number | DesignVariable
roundedTL?: number | DesignVariable
roundedTR?: number | DesignVariable
roundedBL?: number | DesignVariable
roundedBR?: number | DesignVariable
cornerSmoothing?: number
opacity?: number
opacity?: number | DesignVariable
blendMode?: string
mask?: boolean | 'alpha' | 'luminance' | 'vector'
rotate?: number
@ -143,8 +143,8 @@ export type StyleProps = {
blur?: number
effects?: Effect[]
size?: number
fontSize?: number
size?: number | DesignVariable
fontSize?: number | DesignVariable
font?: string
fontFamily?: string
weight?: number | 'bold' | 'medium' | 'normal'

View file

@ -1,4 +1,4 @@
import type { Color } from '@open-pencil/scene-graph/primitives'
import type { Color, SceneGraph } from '@open-pencil/scene-graph'
const VAR_SYMBOL = Symbol.for('open-pencil.variable')
@ -7,14 +7,23 @@ export type VarDef =
| {
id?: string
name?: string
value?: string | Color
value?: string | Color | number
}
export interface DesignVariable {
[VAR_SYMBOL]: true
id?: string
name: string
value?: string | Color
value?: string | Color | number
}
export function resolveVariableId(graph: SceneGraph, variable: DesignVariable): string | undefined {
if (variable.id && graph.variables.has(variable.id)) return variable.id
if (variable.id && !variable.name) return variable.id
for (const candidate of graph.variables.values()) {
if (candidate.name === variable.name || candidate.id === variable.name) return candidate.id
}
return variable.id
}
export function isVariable(value: unknown): value is DesignVariable {
@ -33,14 +42,9 @@ export function defineVars<T extends Record<string, VarDef>>(
return result
}
export function designVar(
def: string | { id?: string; name?: string; value?: string | Color }
): DesignVariable
export function designVar(idOrName: string, value?: string | Color): DesignVariable
export function designVar(
def: string | { id?: string; name?: string; value?: string | Color },
value?: string | Color
): DesignVariable {
export function designVar(def: VarDef): DesignVariable
export function designVar(idOrName: string, value?: DesignVariable['value']): DesignVariable
export function designVar(def: VarDef, value?: DesignVariable['value']): DesignVariable {
if (typeof def === 'string') {
return {
[VAR_SYMBOL]: true,

View file

@ -0,0 +1,148 @@
import { describe, expect, it } from 'bun:test'
import {
Frame,
Text,
Rectangle,
defineVars,
designVar,
renderTree,
renderJSX
} from '@open-pencil/core/design-jsx'
import { getNodeOrThrow } from '#tests/helpers/assert'
import { addTestColorVariable, makeSceneGraph } from '#tests/helpers/scene'
function setup() {
const graph = makeSceneGraph()
graph.addCollection({
id: 'spacing',
name: 'Spacing',
modes: [
{ modeId: 'comfortable', name: 'Comfortable' },
{ modeId: 'compact', name: 'Compact' }
],
defaultModeId: 'comfortable',
variableIds: []
})
graph.addVariable({
id: 'space',
name: 'Space/medium',
type: 'FLOAT',
collectionId: 'spacing',
valuesByMode: { comfortable: 16, compact: 8 },
description: '',
hiddenFromPublishing: false
})
return { graph, token: designVar('space') }
}
describe('Design JSX scalar variables', () => {
it('binds layout shorthands and computes Hug sizing from their values', async () => {
const { graph, token } = setup()
const result = await renderTree(
graph,
Frame({
flex: 'col',
w: 100,
h: 'hug',
gap: token,
p: token,
rounded: token,
children: [Rectangle({ w: 20, h: 20 }), Rectangle({ w: 20, h: 20 })]
})
)
const node = getNodeOrThrow(graph, result.id)
expect(node.height).toBe(88)
expect(node.itemSpacing).toBe(16)
expect(node.boundVariables).toMatchObject({
itemSpacing: 'space',
paddingTop: 'space',
paddingRight: 'space',
paddingBottom: 'space',
paddingLeft: 'space',
cornerRadius: 'space'
})
})
it('preserves literal longhand precedence over a bound shorthand', async () => {
const { graph, token } = setup()
const result = await renderTree(graph, Frame({ w: 100, h: 100, p: token, px: 4, pt: 2 }))
const node = getNodeOrThrow(graph, result.id)
expect([node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]).toEqual([
2, 4, 16, 4
])
expect(node.boundVariables).toEqual({ paddingBottom: 'space' })
})
it('preserves existing grid gap precedence with scalar bindings', async () => {
const { graph, token } = setup()
const result = await renderTree(
graph,
Frame({ grid: true, columns: 2, w: 100, h: 100, gap: token, columnGap: 3 })
)
const node = getNodeOrThrow(graph, result.id)
expect(node.gridColumnGap).toBe(16)
expect(node.gridRowGap).toBe(16)
expect(node.boundVariables).toEqual({ gridRowGap: 'space', gridColumnGap: 'space' })
})
it('supports numeric fallback types, name lookup, typography, and fixed sizes', async () => {
const { graph } = setup()
const vars = defineVars({ space: { name: 'Space/medium', value: 16 } })
const result = await renderTree(
graph,
Text({
w: vars.space,
size: vars.space,
lineHeight: vars.space,
letterSpacing: vars.space,
children: 'A'
})
)
const node = getNodeOrThrow(graph, result.id)
expect(node.boundVariables).toMatchObject({
width: 'space',
fontSize: 'space',
lineHeight: 'space',
letterSpacing: 'space'
})
expect(node.fontSize).toBe(16)
})
it('binds through JSX strings in the agent authoring path', async () => {
const { graph } = setup()
const [result] = await renderJSX(
graph,
'<Frame w={100} h="hug" flex="col" p={designVar("space", 16)} />'
)
expect(result).toBeDefined()
expect(getNodeOrThrow(graph, result.id).boundVariables.paddingTop).toBe('space')
})
it('inherits the parent collection mode', async () => {
const { graph, token } = setup()
const parent = graph.createNode('FRAME', graph.getPages()[0]?.id ?? '', {
variableModes: { spacing: 'compact' }
})
const result = await renderTree(
graph,
Frame({ w: 100, h: 'hug', flex: 'col', p: token, children: Rectangle({ w: 20, h: 20 }) }),
{ parentId: parent.id }
)
const node = getNodeOrThrow(graph, result.id)
expect(graph.resolveNumberVariableForNode(node.id, 'space')).toBe(8)
expect(node.height).toBe(36)
})
it('rejects missing and nonnumeric variables instead of storing objects in numeric fields', async () => {
const { graph } = setup()
addTestColorVariable(graph, 'color', 'Color')
await expect(renderTree(graph, Frame({ gap: designVar('color') }))).rejects.toThrow(
'Expected a FLOAT variable'
)
await expect(renderTree(graph, Frame({ gap: designVar('missing', 16) }))).rejects.toThrow(
'Expected a FLOAT variable'
)
})
})