From 98e0ba95638b65aa4bb5712405d4c5b7ec2cd6de Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 13 Sep 2026 19:06:20 +0300 Subject: [PATCH] feat: bind Design JSX scalar props to variables --- CHANGELOG.md | 1 + packages/core/src/design-jsx/renderer.ts | 23 ++- .../core/src/design-jsx/scalar-bindings.ts | 77 +++++++++ packages/core/src/design-jsx/tree.ts | 40 ++--- packages/core/src/design-jsx/vars.ts | 26 +-- .../render/jsx/scalar-variables.test.ts | 148 ++++++++++++++++++ 6 files changed, 270 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/design-jsx/scalar-bindings.ts create mode 100644 tests/engine/render/jsx/scalar-variables.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc780e19..726d52611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index 70da8ef36..1059a28c7 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -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> = { frame: 'FRAME', @@ -93,17 +94,8 @@ 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 + 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, - isText: boolean + isText: boolean, + parentId: string ): PreparedProps { const props = { ...source } const bindings: Record = {} @@ -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 { 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) { diff --git a/packages/core/src/design-jsx/scalar-bindings.ts b/packages/core/src/design-jsx/scalar-bindings.ts new file mode 100644 index 000000000..4434f29f9 --- /dev/null +++ b/packages/core/src/design-jsx/scalar-bindings.ts @@ -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 = [ + ['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, + bindings: Record, + isText: boolean, + parentId: string +): void { + const entries = [...SCALAR_PROPS] + const scalarBindings = new Map() + 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)) +} diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index 8554c5622..cadc5e708 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -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' diff --git a/packages/core/src/design-jsx/vars.ts b/packages/core/src/design-jsx/vars.ts index 543281d63..38b15fa40 100644 --- a/packages/core/src/design-jsx/vars.ts +++ b/packages/core/src/design-jsx/vars.ts @@ -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>( 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, diff --git a/tests/engine/render/jsx/scalar-variables.test.ts b/tests/engine/render/jsx/scalar-variables.test.ts new file mode 100644 index 000000000..7ee3c6ed8 --- /dev/null +++ b/tests/engine/render/jsx/scalar-variables.test.ts @@ -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, + '' + ) + 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' + ) + }) +})