From 4b65e64c1836798ebf76ee4d1d428df70e2d8342 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 2 Jun 2026 11:36:13 +0300 Subject: [PATCH] feat(design-jsx): support variable refs --- CHANGELOG.md | 1 + packages/core/src/design-jsx/index.ts | 3 + .../core/src/design-jsx/props-overrides.ts | 23 +++- packages/core/src/design-jsx/render.ts | 8 ++ packages/core/src/design-jsx/renderer.ts | 125 ++++++++++++++++-- packages/core/src/design-jsx/tree.ts | 15 ++- packages/core/src/design-jsx/vars.ts | 59 +++++++++ packages/core/src/index.ts | 6 + tests/engine/render/jsx/render-tree.test.ts | 61 ++++++++- 9 files changed, 278 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/design-jsx/vars.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 635532f5e..1700df0e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/design-jsx/index.ts b/packages/core/src/design-jsx/index.ts index 4f0b62e94..cc9d82b14 100644 --- a/packages/core/src/design-jsx/index.ts +++ b/packages/core/src/design-jsx/index.ts @@ -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' diff --git a/packages/core/src/design-jsx/props-overrides.ts b/packages/core/src/design-jsx/props-overrides.ts index 4a1a3f70d..3e08a3a21 100644 --- a/packages/core/src/design-jsx/props-overrides.ts +++ b/packages/core/src/design-jsx/props-overrides.ts @@ -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 = { 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, o: Partial): 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, o: Partial): 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, o: Partial 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'>, diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index e5b683c86..aac30340e 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -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> = { frame: 'FRAME', @@ -78,6 +80,106 @@ export async function renderTree( } } +interface PreparedProps { + props: Record + bindings: Record +} + +function isObjectRecord(value: unknown): value is Record { + 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, + bindings: Record, + 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, + bindings: Record, + 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, + isText: boolean +): PreparedProps { + const props = { ...source } + const bindings: Record = {} + + 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): 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 { 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(` 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 { @@ -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 diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index 300e3424d..3fa93539c 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -1,3 +1,7 @@ +import type { Color } from '#core/types' + +import type { DesignVariable } from './vars' + export interface TreeNode { type: string props: Record @@ -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 [key: string]: unknown } diff --git a/packages/core/src/design-jsx/vars.ts b/packages/core/src/design-jsx/vars.ts new file mode 100644 index 000000000..4bc5ded03 --- /dev/null +++ b/packages/core/src/design-jsx/vars.ts @@ -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>( + 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 + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 663975e82..e39ffa9b4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/tests/engine/render/jsx/render-tree.test.ts b/tests/engine/render/jsx/render-tree.test.ts index 239f9f243..106c9606f 100644 --- a/tests/engine/render/jsx/render-tree.test.ts +++ b/tests/engine/render/jsx/render-tree.test.ts @@ -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, + `` + ) + 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(