feat(design-jsx): support variable refs

This commit is contained in:
Danila Poyarkov 2026-06-02 11:36:13 +03:00
parent 7303a40964
commit 4b65e64c18
9 changed files with 278 additions and 23 deletions

View file

@ -5,6 +5,7 @@
### Changed
- Add JSX authoring support for components, component sets, and instances.
- Add design JSX variable helpers so color props can use `designVar()` / `defineVars()` references and emit graph variable bindings.
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
- Add `unbind_variable` MCP tool for removing variable bindings.
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.

View file

@ -23,6 +23,7 @@ export {
type BaseProps,
type TextProps,
type StyleProps,
type PaintProp,
isTreeNode,
node,
resolveToTree
@ -30,6 +31,8 @@ export {
export { renderTree, type RenderResult } from './renderer'
export { defineVars, designVar, isVariable, type DesignVariable, type VarDef } from './vars'
export { createElement } from './mini-react'
export { renderJSX, renderTreeNode, buildComponent } from './render'

View file

@ -1,7 +1,7 @@
import { colorToFill, parseColor } from '#core/color'
import { TRANSPARENT } from '#core/constants'
import type { GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
import type { JsonObject } from '#core/types'
import type { Color, JsonObject } from '#core/types'
const WEIGHT_MAP: Record<string, number> = {
normal: 400,
@ -60,8 +60,8 @@ function parseDirection(value: unknown): SceneNode['textDirection'] | undefined
return DIRECTION_MAP[value.toLowerCase()] ?? 'AUTO'
}
function parseStroke(value: string, width: number): Stroke {
const color = parseColor(value)
function parseStroke(value: string | Color, width: number): Stroke {
const color = typeof value === 'string' ? parseColor(value) : value
return {
color,
opacity: color.a,
@ -159,14 +159,25 @@ function applyFillSizing(
}
}
function isColor(value: unknown): value is Color {
return (
value !== null &&
typeof value === 'object' &&
'r' in value &&
'g' in value &&
'b' in value &&
'a' in value
)
}
function applyFillOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const bg = props.bg ?? props.fill ?? props.background ?? props.backgroundColor
if (typeof bg === 'string') o.fills = [colorToFill(bg)]
if (typeof bg === 'string' || isColor(bg)) o.fills = [colorToFill(bg)]
}
function applyStrokeOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const stroke = props.stroke ?? props.border ?? props.borderColor
if (typeof stroke !== 'string') return
if (typeof stroke !== 'string' && !isColor(stroke)) return
const strokeWidth =
(props.strokeWidth as number | undefined) ?? (props.borderWidth as number | undefined) ?? 1
o.strokes = [parseStroke(stroke, strokeWidth)]
@ -418,7 +429,7 @@ function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<Scen
o.fontWeight = WEIGHT_MAP[weight] ?? 400
}
if (typeof props.color === 'string') {
if (typeof props.color === 'string' || isColor(props.color)) {
o.fills = [colorToFill(props.color)]
}

View file

@ -112,6 +112,7 @@ const SUPPORTED_PROPS = new Set([
'innerRadius',
'label',
'style',
'bind',
'component',
'componentId',
'of'
@ -149,6 +150,13 @@ export function buildComponent(jsxString: string): React.ComponentType {
const Group = 'group', Section = 'section', View = 'frame', Rect = 'rectangle'
const Component = 'component', ComponentSet = 'component-set', Instance = 'instance'
const Icon = 'icon'
const __varSymbol = Symbol.for('open-pencil.variable')
const designVar = (def, value) => typeof def === 'string'
? ({ [__varSymbol]: true, id: def, name: def, value })
: ({ [__varSymbol]: true, id: def.id, name: def.name ?? def.id ?? '', value: def.value })
const defineVars = (vars) => Object.fromEntries(
Object.entries(vars).map(([key, def]) => [key, designVar(def)])
)
`
const opts = {
transforms: ['typescript', 'jsx'] as Array<'typescript' | 'jsx'>,

View file

@ -10,10 +10,12 @@ import type {
SceneNode,
NodeType
} from '#core/scene-graph'
import type { Color } from '#core/types'
import { applySizeOverrides, propsToOverrides } from './props-overrides'
import { isTreeNode } from './tree'
import type { TreeNode } from './tree'
import { isVariable, type DesignVariable } from './vars'
const TYPE_MAP: Partial<Record<string, NodeType>> = {
frame: 'FRAME',
@ -78,6 +80,106 @@ export async function renderTree(
}
}
interface PreparedProps {
props: Record<string, unknown>
bindings: Record<string, string>
}
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
const variableId = resolveVariableId(graph, variable)
return variableId ? graph.resolveColorVariable(variableId) : undefined
}
function bindVariableProp(
graph: SceneGraph,
props: Record<string, unknown>,
bindings: Record<string, string>,
key: string,
field: string
): void {
const value = props[key]
if (!isVariable(value)) return
const variableId = resolveVariableId(graph, value)
if (variableId) bindings[field] = variableId
const fallback = variableFallback(graph, value)
if (fallback !== undefined) props[key] = fallback
}
function bindStyleVariableProp(
graph: SceneGraph,
style: Record<string, unknown>,
bindings: Record<string, string>,
key: string,
field: string
): void {
const value = style[key]
if (!isVariable(value)) return
const variableId = resolveVariableId(graph, value)
if (variableId) bindings[field] = variableId
const fallback = variableFallback(graph, value)
if (fallback !== undefined) style[key] = fallback
}
function preparePropsForRender(
graph: SceneGraph,
source: Record<string, unknown>,
isText: boolean
): PreparedProps {
const props = { ...source }
const bindings: Record<string, string> = {}
for (const key of ['bg', 'fill', 'background', 'backgroundColor']) {
bindVariableProp(graph, props, bindings, key, 'fills/0/color')
}
if (isText) bindVariableProp(graph, props, bindings, 'color', 'fills/0/color')
for (const key of ['stroke', 'border', 'borderColor']) {
bindVariableProp(graph, props, bindings, key, 'strokes/0/color')
}
if (isObjectRecord(props.style)) {
const style = { ...props.style }
for (const key of ['background', 'backgroundColor']) {
bindStyleVariableProp(graph, style, bindings, key, 'fills/0/color')
}
if (isText) bindStyleVariableProp(graph, style, bindings, 'color', 'fills/0/color')
bindStyleVariableProp(graph, style, bindings, 'borderColor', 'strokes/0/color')
props.style = style
}
if (isObjectRecord(props.bind)) {
for (const [field, value] of Object.entries(props.bind)) {
if (isVariable(value)) {
const variableId = resolveVariableId(graph, value)
if (variableId) bindings[field] = variableId
} else if (typeof value === 'string') {
bindings[field] = value
}
}
}
return { props, bindings }
}
function applyBindings(graph: SceneGraph, nodeId: string, bindings: Record<string, string>): void {
for (const [field, variableId] of Object.entries(bindings)) {
graph.bindVariable(nodeId, field, variableId)
}
}
async function renderIconNode(
graph: SceneGraph,
tree: TreeNode,
@ -220,16 +322,18 @@ async function renderInstanceNode(
): Promise<SceneNode> {
const parent = graph.getNode(parentId)
const parentLayout = parent?.layoutMode ?? 'NONE'
const component = resolveComponent(graph, tree.props)
const { props, bindings } = preparePropsForRender(graph, tree.props, false)
const component = resolveComponent(graph, props)
if (!component) {
const ref = tree.props.component ?? tree.props.componentId ?? tree.props.of
const ref = props.component ?? props.componentId ?? props.of
const label = typeof ref === 'string' || typeof ref === 'number' ? String(ref) : ''
throw new Error(`<Instance> component not found: ${label}`)
}
const overrides = propsToOverrides(tree.props, false, parentLayout)
return (
const overrides = propsToOverrides(props, false, parentLayout)
const instance =
graph.createInstance(component.id, parentId, overrides) ?? graph.createNode('FRAME', parentId)
)
applyBindings(graph, instance.id, bindings)
return instance
}
async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise<SceneNode> {
@ -243,22 +347,19 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string):
const parentLayout = parent?.layoutMode ?? 'NONE'
const isText = nodeType === 'TEXT'
const overrides = propsToOverrides(tree.props, isText, parentLayout)
const { props, bindings } = preparePropsForRender(graph, tree.props, isText)
const overrides = propsToOverrides(props, isText, parentLayout)
if (isText) {
const childText = tree.children.filter((c): c is string => typeof c === 'string').join('')
const propText =
tree.props.text ??
tree.props.characters ??
tree.props.content ??
tree.props.label ??
tree.props.value ??
tree.props.title
props.text ?? props.characters ?? props.content ?? props.label ?? props.value ?? props.title
if (childText) overrides.text = childText
else if (typeof propText === 'string') overrides.text = propText
}
const node = graph.createNode(nodeType, parentId, overrides)
applyBindings(graph, node.id, bindings)
for (const child of tree.children) {
if (typeof child === 'string') continue

View file

@ -1,3 +1,7 @@
import type { Color } from '#core/types'
import type { DesignVariable } from './vars'
export interface TreeNode {
type: string
props: Record<string, unknown>
@ -81,6 +85,8 @@ export function node(
return { type, props: rest, children: processed }
}
export type PaintProp = string | Color | DesignVariable
export type StyleProps = {
flex?: 'row' | 'col' | 'column'
flow?: 'auto' | 'ltr' | 'rtl'
@ -113,9 +119,9 @@ export type StyleProps = {
pb?: number
pl?: number
bg?: string
fill?: string
stroke?: string
bg?: PaintProp
fill?: PaintProp
stroke?: PaintProp
strokeWidth?: number
strokeAlign?: 'inside' | 'outside' | 'center'
strokeDash?: number[] | boolean
@ -139,7 +145,7 @@ export type StyleProps = {
fontFamily?: string
weight?: number | 'bold' | 'medium' | 'normal'
fontWeight?: number | 'bold' | 'medium' | 'normal'
color?: string
color?: PaintProp
text?: string
characters?: string
textAlign?: 'left' | 'center' | 'right' | 'justified'
@ -154,6 +160,7 @@ export type BaseProps = StyleProps & {
name?: string
key?: string | number
children?: unknown
bind?: Record<string, unknown>
[key: string]: unknown
}

View file

@ -0,0 +1,59 @@
import type { Color } from '#core/types'
const VAR_SYMBOL = Symbol.for('open-pencil.variable')
export type VarDef =
| string
| {
id?: string
name?: string
value?: string | Color
}
export interface DesignVariable {
[VAR_SYMBOL]: true
id?: string
name: string
value?: string | Color
}
export function isVariable(value: unknown): value is DesignVariable {
return typeof value === 'object' && value !== null && VAR_SYMBOL in value
}
export function defineVars<T extends Record<string, VarDef>>(
vars: T
): { [K in keyof T]: DesignVariable } {
const result = {} as { [K in keyof T]: DesignVariable }
for (const [key, def] of Object.entries(vars)) {
result[key as keyof T] = designVar(def)
}
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 {
if (typeof def === 'string') {
return {
[VAR_SYMBOL]: true,
id: def,
name: def,
value
}
}
return {
[VAR_SYMBOL]: true,
id: def.id,
name: def.name ?? def.id ?? '',
value: def.value
}
}

View file

@ -309,6 +309,9 @@ export {
renderJSX,
renderTreeNode,
buildComponent,
defineVars,
designVar,
isVariable,
Frame,
Text,
Rectangle,
@ -336,6 +339,9 @@ export {
type BaseProps,
type TextProps,
type StyleProps,
type PaintProp,
type DesignVariable,
type VarDef,
type RenderResult,
sceneNodeToJSX,
selectionToJSX,

View file

@ -14,7 +14,9 @@ import {
Section,
Component,
ComponentSet,
Instance
Instance,
defineVars,
designVar
} from '@open-pencil/core'
import { expectDefined, getNodeOrThrow, childIdAt } from '#tests/helpers/assert'
@ -79,6 +81,63 @@ describe('renderTree', () => {
expect(heading.fills.length).toBe(1)
})
it('binds variable refs used as style values', async () => {
const g = makeSceneGraph()
g.addCollection({
id: 'colors',
name: 'Colors',
modes: [{ modeId: 'light', name: 'Light' }],
defaultModeId: 'light',
variableIds: []
})
g.addVariable({
id: 'var-bg',
name: 'Background',
type: 'COLOR',
collectionId: 'colors',
valuesByMode: { light: { r: 1, g: 0, b: 0, a: 1 } },
description: '',
hiddenFromPublishing: false
})
const vars = defineVars({ bg: { id: 'var-bg', name: 'Background' } })
const result = await renderTree(g, Frame({ name: 'Bound', w: 100, h: 100, fill: vars.bg }))
const node = getNodeOrThrow(g, result.id)
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
expect(node.fills[0]?.type).toBe('SOLID')
})
it('supports explicit variable bindings for arbitrary paths', async () => {
const g = makeSceneGraph()
const variable = designVar('var-shadow', '#000000')
const result = await renderTree(
g,
Frame({
name: 'Bound explicit',
w: 100,
h: 100,
bind: { 'effects/0/color': variable, 'fills/0/color': 'var-bg' }
})
)
const node = getNodeOrThrow(g, result.id)
expect(node.boundVariables['effects/0/color']).toBe('var-shadow')
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
})
it('binds variables from JSX strings', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame name="Bound JSX" w={100} h={100} fill={designVar('var-bg', '#ffffff')} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
expect(node.fills[0]?.type).toBe('SOLID')
})
it('renders components and instances', async () => {
const g = makeSceneGraph()
const component = await renderTree(