* refactor(design-jsx): extract design JSX into its own package Design JSX elements, helpers, schema, reference, and JSX export only need the scene graph, yet lived in Core, so every consumer of the authoring API pulled in the renderer, layout, and file formats. @open-pencil/design-jsx now owns them and depends only on scene-graph. The renderer takes icon lookup, SVG conversion, vector creation, and layout as DesignJSXServices; Core binds its own and exports the bound renderJSX and renderTree from @open-pencil/core/design-jsx. * feat(design-jsx): export the JSX runtime for TSX authoring The package already had a JSX runtime, but nothing exported it, so design trees could only be written as function calls or JSX strings. Export `./jsx-runtime` and `./jsx-dev-runtime` so `jsxImportSource` works, and make `Fragment` produce the same empty-type node as `<>` in `renderJSX` strings. * fix(design-jsx): render fragments nested in other elements A fragment builds a node with an empty type, which only renderJSX expanded, and only at the root. Nested fragments and fragments passed to renderTree failed with 'Unknown element: <>'. Inline fragment children when trees are built, and share root expansion between renderTree and renderJSX.
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
|
|
|
import { renderJSX, renderTree } from '@open-pencil/core/design-jsx'
|
|
import { Frame, Text, Rectangle, defineVars, designVar } from '@open-pencil/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'
|
|
)
|
|
})
|
|
})
|