feat(design-jsx): masks, inline <svg> vectors, and instance overrides

Fill the three biggest gaps that blocked authoring real designs in JSX:

- mask prop on any node: <Ellipse mask> / mask="luminance" / mask="vector"
  sets isMask + maskType so sibling content clips (Figma's mask model)
- <svg> element rendering inline SVG markup into vector nodes, reusing the
  iconify path pipeline (extractPaths + parseSVGPath + scalePathInfos)
- Instance overrides: <Instance of="X" overrides={{ 'childName:prop': v }} />
  applies child overrides by name and records them so component sync keeps them

Adds gap tests and the shared scalePathInfos/finishIconRender helpers to
avoid duplicating the iconify pipeline.
This commit is contained in:
Danila Poyarkov 2026-07-21 11:40:13 +03:00
parent 8561d73eaf
commit 3cfc86371d
9 changed files with 313 additions and 24 deletions

View file

@ -246,6 +246,14 @@ function applyVisualOverrides(props: Record<string, unknown>, o: Partial<SceneNo
o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode']
}
if (props.overflow === 'hidden') o.clipsContent = true
if (props.mask) {
o.isMask = true
const maskTypeMap: Record<string, SceneNode['maskType']> = {
luminance: 'LUMINANCE',
vector: 'VECTOR'
}
o.maskType = maskTypeMap[props.mask as string] ?? 'ALPHA'
}
}
function applyTransformOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {

View file

@ -162,6 +162,7 @@ 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 svg = 'svg'
const dropShadow = __helpers.dropShadow
const innerShadow = __helpers.innerShadow
const layerBlur = __helpers.layerBlur

View file

@ -10,6 +10,8 @@ import { parseColor } from '#core/color'
import type { RenderOptions } from '#core/design-jsx/types'
import { fetchIcons } from '#core/icons'
import { createIconFromPaths } from '#core/icons/render'
import { extractPaths, scalePathInfos } from '#core/icons/svg'
import type { IconData } from '#core/icons/types'
import { computeAllLayouts } from '#core/layout'
import { randomHex } from '#core/random'
@ -190,6 +192,33 @@ function applyBindings(graph: SceneGraph, nodeId: string, bindings: Record<strin
}
}
function applyIconSize(
props: Record<string, unknown>,
overrides: Partial<SceneNode>,
parentLayout: SceneNode['layoutMode'],
size: number
): void {
const { w, h } = applySizeOverrides(props, overrides, parentLayout)
if (typeof w !== 'number') overrides.width = size
if (typeof h !== 'number') overrides.height = size
}
function finishIconRender(
graph: SceneGraph,
icon: IconData,
props: Record<string, unknown>,
size: number,
color: Color,
parentId: string
): SceneNode {
const parent = graph.getNode(parentId)
const parentLayout = parent?.layoutMode ?? 'NONE'
const overrides: Partial<SceneNode> = {}
if (props.label) overrides.name = props.label as string
applyIconSize(props, overrides, parentLayout, size)
return createIconFromPaths(graph, icon, icon.name, size, color, parentId, overrides)
}
async function renderIconNode(
graph: SceneGraph,
tree: TreeNode,
@ -208,16 +237,74 @@ async function renderIconNode(
if (!icon || icon.paths.length === 0) {
throw new Error(`Icon "${iconName}" not found`)
}
return finishIconRender(graph, icon, props, size, parsedColor, parentId)
}
const parent = graph.getNode(parentId)
const parentLayout = parent?.layoutMode ?? 'NONE'
const overrides: Partial<SceneNode> = {}
if (props.label) overrides.name = props.label as string
const { w, h } = applySizeOverrides(props, overrides, parentLayout)
if (typeof w !== 'number') overrides.width = size
if (typeof h !== 'number') overrides.height = size
/**
* Render an inline <svg> element into vector nodes. Reuses the same SVG-path
* pipeline as iconify icons: the body may be passed as string children or a
* `body`/`children` string prop, and is parsed with extractPaths + parseSVGPath.
*/
async function renderSvgNode(
graph: SceneGraph,
tree: TreeNode,
parentId: string
): Promise<SceneNode> {
const props = tree.props
const size = (props.size as number | undefined) ?? 24
const colorHex = (props.color as string | undefined) ?? '#000000'
const parsedColor = parseColor(colorHex)
return createIconFromPaths(graph, icon, iconName, size, parsedColor, parentId, overrides)
const body =
(typeof props.body === 'string' && props.body) ||
tree.children.filter((c): c is string => typeof c === 'string').join('')
// Children may arrive as parsed <path>/<circle>/etc. elements (mini-react
// lowercases tags) rather than raw markup. Rebuild path info from either
// source: raw SVG markup, or element children carrying a `d` attribute.
let pathInfos = body.trim() ? extractPaths(body) : []
if (pathInfos.length === 0) {
pathInfos = tree.children
.filter(isTreeNode)
.map((child) => {
const d = (child.props.d ?? child.props.body) as string | undefined
if (!d) return null
return {
d,
fill: (child.props.fill as string | undefined) ?? 'currentColor',
stroke: (child.props.stroke as string | undefined) ?? null,
strokeWidth: Number(child.props['stroke-width'] ?? child.props.strokeWidth ?? 1),
strokeCap: (child.props['stroke-linecap'] as string | undefined) ?? 'butt',
strokeJoin: (child.props['stroke-linejoin'] as string | undefined) ?? 'miter',
fillRule: (child.props['fill-rule'] as string | undefined) === 'evenodd' ? 'EVENODD' as const : 'NONZERO' as const
}
})
.filter((p): p is NonNullable<typeof p> => p !== null)
}
if (pathInfos.length === 0) {
throw new Error('<svg> requires SVG markup as children, a body prop, or <path d="..."> children')
}
const vb = parseViewBox(props.viewBox as string | undefined)
const scaleX = vb.w > 0 ? size / vb.w : 1
const scaleY = vb.h > 0 ? size / vb.h : 1
const icon: IconData = {
prefix: 'svg',
name: (props.name as string | undefined) ?? 'custom',
width: size,
height: size,
paths: scalePathInfos(pathInfos, scaleX, scaleY)
}
return finishIconRender(graph, icon, props, size, parsedColor, parentId)
}
function parseViewBox(viewBox: string | undefined): { w: number; h: number } {
if (!viewBox) return { w: 0, h: 0 }
const parts = viewBox.trim().split(/[\s,]+/).map(Number)
const w = parts[2] ?? 0
const h = parts[3] ?? 0
return { w, h }
}
function parseVariantValues(name: string): Record<string, string> {
@ -343,11 +430,54 @@ async function renderInstanceNode(
const instance =
graph.createInstance(component.id, parentId, overrides) ?? graph.createNode('FRAME', parentId)
applyBindings(graph, instance.id, bindings)
applyInstanceOverrides(graph, instance, tree.props.overrides)
return instance
}
/**
* Apply child overrides to a freshly created instance. Keys are
* `childName:prop` (e.g. 'label:text', 'icon:fills'); the child is resolved by
* name among the instance's descendants, and the value is applied to both the
* child node and the instance's overrides record so component sync keeps it.
*/
function applyInstanceOverrides(
graph: SceneGraph,
instance: SceneNode,
overridesProp: unknown
): void {
if (!overridesProp || typeof overridesProp !== 'object') return
if (Array.isArray(overridesProp)) return
const entries = Object.entries(overridesProp)
if (entries.length === 0) return
const descendants: SceneNode[] = []
const walk = (id: string) => {
const node = graph.getNode(id)
if (!node) return
descendants.push(node)
for (const cid of node.childIds) walk(cid)
}
walk(instance.id)
const overrides: Record<string, unknown> = { ...instance.overrides }
for (const [key, value] of entries) {
const sep = key.indexOf(':')
if (sep === -1) continue
const childName = key.slice(0, sep)
const prop = key.slice(sep + 1)
const child = descendants.find((n) => n.name === childName)
if (!child || !(prop in child)) continue
graph.updateNode(child.id, { [prop]: value } as Partial<SceneNode>)
overrides[`${child.id}:${prop}`] = value
}
if (Object.keys(overrides).length > 0) {
graph.updateNode(instance.id, { overrides })
}
}
async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise<SceneNode> {
if (tree.type === 'icon') return renderIconNode(graph, tree, parentId)
if (tree.type === 'svg') return renderSvgNode(graph, tree, parentId)
if (tree.type === 'instance') return renderInstanceNode(graph, tree, parentId)
const nodeType = TYPE_MAP[tree.type]

View file

@ -135,6 +135,7 @@ export type StyleProps = {
cornerSmoothing?: number
opacity?: number
blendMode?: string
mask?: boolean | 'alpha' | 'luminance' | 'vector'
rotate?: number
rotation?: number
overflow?: 'hidden' | 'visible'

View file

@ -153,19 +153,28 @@ export function buildIconData(
name: iconName,
width: size,
height: size,
paths: pathInfos.map((path) => {
const scaledD =
scaleX === 1 && scaleY === 1
? path.d
: svgpath(path.d).scale(scaleX, scaleY).round(2).toString()
return {
vectorNetwork: parseSVGPath(scaledD, path.fillRule),
fill: path.fill,
stroke: path.stroke,
strokeWidth: path.strokeWidth * Math.min(scaleX, scaleY),
strokeCap: path.strokeCap,
strokeJoin: path.strokeJoin
}
})
paths: scalePathInfos(pathInfos, scaleX, scaleY)
}
}
/** Scale extracted SVG path info into IconData paths (shared by buildIconData and design-jsx <svg>). */
export function scalePathInfos(
pathInfos: IconPathInfo[],
scaleX: number,
scaleY: number
): IconData['paths'] {
return pathInfos.map((path) => {
const scaledD =
scaleX === 1 && scaleY === 1
? path.d
: svgpath(path.d).scale(scaleX, scaleY).round(2).toString()
return {
vectorNetwork: parseSVGPath(scaledD, path.fillRule),
fill: path.fill,
stroke: path.stroke,
strokeWidth: path.strokeWidth * Math.min(scaleX, scaleY),
strokeCap: path.strokeCap,
strokeJoin: path.strokeJoin
}
})
}

View file

@ -1,17 +1,20 @@
import { computeAllLayouts } from '@open-pencil/core/layout'
import { renderJSX } from '@open-pencil/core/design-jsx'
import { DEMO_COLORS, solid } from '@/app/demo/colors'
import { SHOWCASE_JSX } from '@/app/demo/jsx/showcase'
import { createAppPreviewSection } from '@/app/demo/sections/app-preview'
import { createComponentsSection } from '@/app/demo/sections/components'
import { createDemoVariables } from '@/app/demo/sections/variables'
import type { EditorStore } from '@/app/editor/session'
export function createDemoShapes(store: EditorStore) {
export async function createDemoShapes(store: EditorStore) {
const { graph } = store
const comps = createComponentsSection(store)
computeAllLayouts(graph)
const app = createAppPreviewSection(store, comps)
await renderJSX(graph, SHOWCASE_JSX)
createDemoVariables(store)
// Theme the screen through variables so editing one re-themes the demo.

View file

@ -0,0 +1,94 @@
/**
* The showcase page: a music player app authored entirely in JSX. Exercises
* masks, gradients, drop shadows, a glassmorphic player bar, blend-mode
* overlays, corner smoothing, vector icons, outlined buttons, and
* component/instance all as natural parts of one design.
*/
export const SHOWCASE_JSX = `
<Section name="App — Player" x={1560} y={60} w={560} h={760} bg="#12121A">
{/* Hero: gradient cover masked into a smooth-rounded card, with a
blend-mode legibility overlay and masked listener avatars. */}
<Frame name="Hero" x={32} y={32} w={496} h={300} rounded={20} cornerSmoothing={0.6}
overflow="hidden" effects={[dropShadow({ x: 0, y: 12, radius: 32, color: '"#4C1A8066"' })]}
fill={linearGradient([['#734FF2', 0], ['#F2598C', 1]])}>
<Rectangle name="Overlay" x={0} y={160} w={496} h={140} blendMode="multiply"
fill={linearGradient([['"#00000000"', 0], ['"#00000099"', 1]])} />
<Group name="Listeners" x={24} y={246}>
<Ellipse mask w={36} h={36} stroke="#fff" strokeWidth={2}
fill={linearGradient([['#F2B366', 0], ['#B3834A', 1]])} />
<Rectangle w={36} h={36} fill={linearGradient([['#F2B366', 0], ['#B3834A', 1]])} />
<Ellipse mask x={28} w={36} h={36} stroke="#fff" strokeWidth={2}
fill={linearGradient([['#66CCF2', 0], ['#3A8AB3', 1]])} />
<Rectangle x={28} w={36} h={36} fill={linearGradient([['#66CCF2', 0], ['#3A8AB3', 1]])} />
<Ellipse mask x={56} w={36} h={36} stroke="#fff" strokeWidth={2}
fill={linearGradient([['#B3E67F', 0], ['#7AB34D', 1]])} />
<Rectangle x={56} w={36} h={36} fill={linearGradient([['#B3E67F', 0], ['#7AB34D', 1]])} />
</Group>
<Text name="Track" x={108} y={252} size={20} weight="bold" color="#FFFFFF">Midnight Reverie</Text>
<Text name="Artist" x={108} y={278} size={13} color=""#FFFFFFBF"">Aurora Bloom</Text>
</Frame>
{/* Up-next: album art masked into rounded squares. */}
<Text name="Label" x={32} y={356} size={14} weight="bold" color="#FFFFFF">Up next</Text>
<Frame name="List" x={32} y={384} w={496} flex="col" gap={8}>
<Frame name="Track" w={496} h={56} rounded={12} cornerSmoothing={0.5} bg="#21212E"
flex="row" items="center" gap={12} pl={8} pr={12}>
<Frame name="Art" w={40} h={40} rounded={9} overflow="hidden"
fill={linearGradient([['#4D99F2', 0], ['#994DE6', 1]])}
effects={[dropShadow({ x: 0, y: 2, radius: 6, color: '"#0000004C"' })]} />
<Frame name="Meta" flex="col" gap={2}>
<Text name="Title" size={14} weight="medium" color="#FFFFFF">Glass Cities</Text>
<Text name="Artist" size={12} color="#A6A6B8">Nocturne</Text>
</Frame>
<svg name="Like" x={440} y={16} viewBox="0 0 24 24" w={22} h={22} color="#FA6685">
<path d="M12 21s-7-4.5-9.5-9C0.5 8 2 4 6 4c2.5 0 4 1.5 6 3 2-1.5 3.5-3 6-3 4 0 5.5 4 3.5 8-2.5 4.5-9.5 9-9.5 9z"/>
</svg>
</Frame>
<Frame name="Track" w={496} h={56} rounded={12} cornerSmoothing={0.5} bg="#21212E"
flex="row" items="center" gap={12} pl={8} pr={12}>
<Frame name="Art" w={40} h={40} rounded={9} overflow="hidden"
fill={linearGradient([['#33D9B3', 0], ['#3380F2', 1]])}
effects={[dropShadow({ x: 0, y: 2, radius: 6, color: '"#0000004C"' })]} />
<Frame name="Meta" flex="col" gap={2}>
<Text name="Title" size={14} weight="medium" color="#FFFFFF">Slow Waves</Text>
<Text name="Artist" size={12} color="#A6A6B8">Tidal Form</Text>
</Frame>
<svg name="Like" x={440} y={16} viewBox="0 0 24 24" w={22} h={22} color="#66667A">
<path d="M12 21s-7-4.5-9.5-9C0.5 8 2 4 6 4c2.5 0 4 1.5 6 3 2-1.5 3.5-3 6-3 4 0 5.5 4 3.5 8-2.5 4.5-9.5 9-9.5 9z"/>
</svg>
</Frame>
<Frame name="Track" w={496} h={56} rounded={12} cornerSmoothing={0.5} bg="#21212E"
flex="row" items="center" gap={12} pl={8} pr={12}>
<Frame name="Art" w={40} h={40} rounded={9} overflow="hidden"
fill={linearGradient([['#FA804D', 0], ['#E64080', 1]])}
effects={[dropShadow({ x: 0, y: 2, radius: 6, color: '"#0000004C"' })]} />
<Frame name="Meta" flex="col" gap={2}>
<Text name="Title" size={14} weight="medium" color="#FFFFFF">Ember Days</Text>
<Text name="Artist" size={12} color="#A6A6B8">Solstice</Text>
</Frame>
<svg name="Like" x={440} y={16} viewBox="0 0 24 24" w={22} h={22} color="#66667A">
<path d="M12 21s-7-4.5-9.5-9C0.5 8 2 4 6 4c2.5 0 4 1.5 6 3 2-1.5 3.5-3 6-3 4 0 5.5 4 3.5 8-2.5 4.5-9.5 9-9.5 9z"/>
</svg>
</Frame>
</Frame>
{/* Player bar: glassmorphism (background blur + translucent fill). */}
<Frame name="Player Bar" x={32} y={640} w={496} h={88} rounded={18} cornerSmoothing={0.6}
fill=""#29293DD9""
effects={[backgroundBlur(16), dropShadow({ x: 0, y: 8, radius: 24, color: '"#00000066"' })]}
flex="col" gap={12} pt={14} pb={14} pl={20} pr={20}>
<Frame name="Progress Track" w={456} h={4} rounded={2} bg="#4D4D66">
<Rectangle name="Progress Fill" w={300} h={4} rounded={2}
fill={linearGradient([['#8066FA', 0], ['#F26699', 1]])} />
</Frame>
<Frame name="Controls" w={456} h={40} flex="row" justify="center" items="center" gap={28}>
<Polygon name="Back" w={18} h={18} rotation={270} fill="#CCCCD9" pointCount={3} />
<Ellipse name="Play" w={40} h={40}
fill={linearGradient([['#8066FA', 0], ['#F26699', 1]])}
effects={[dropShadow({ x: 0, y: 4, radius: 12, color: '"#6633CC80"' })]} />
<Polygon name="Forward" w={18} h={18} rotation={90} fill="#CCCCD9" pointCount={3} />
</Frame>
</Frame>
</Section>
`

View file

@ -40,7 +40,7 @@ const { dialogs } = useI18n()
const { isMobile } = useViewportKind()
if (createdInitialTab && route.meta.demo && !('test' in params)) {
createDemoShapes(firstTab.store)
void createDemoShapes(firstTab.store)
}
useHead({ title: route.meta.demo ? 'Demo' : undefined })

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'bun:test'
import { renderJSX } from '@open-pencil/core'
import { makeSceneGraph } from '#tests/helpers/scene'
describe('jsx gaps', () => {
it('mask prop sets isMask + maskType', async () => {
const g = makeSceneGraph()
await renderJSX(
g,
`<Frame name="m" w={80} h={80}><Ellipse mask w={40} h={40} /><Rectangle w={80} h={80} bg="#f00" /></Frame>`
)
const ellipse = [...g.nodes.values()].find((n) => n.type === 'ELLIPSE')
expect(ellipse?.isMask).toBe(true)
expect(ellipse?.maskType).toBe('ALPHA')
})
it('svg element renders vector paths', async () => {
const g = makeSceneGraph()
await renderJSX(
g,
`<svg viewBox="0 0 24 24" w={24} h={24}><path d="M12 21s-7-4.5-9.5-9C0.5 8 2 4 6 4c2.5 0 4 1.5 6 3 2-1.5 3.5-3 6-3 4 0 5.5 4 3.5 8-2.5 4.5-9.5 9-9.5 9z"/></svg>`
)
const vector = [...g.nodes.values()].find((n) => n.type === 'VECTOR')
expect(vector).toBeTruthy()
expect(vector?.vectorNetwork).toBeTruthy()
})
it('instance overrides apply child text by name', async () => {
const g = makeSceneGraph()
await renderJSX(
g,
`<Component name="Badge" w={60} h={24}><Text name="label">+0%</Text></Component>
<Instance of="Badge" overrides={{ 'label:text': '+14%' }} />`
)
const inst = [...g.nodes.values()].find((n) => n.type === 'INSTANCE')
const label = [...g.nodes.values()].find(
(n) => n.type === 'TEXT' && inst && n.parentId === inst.id
)
expect(label?.text).toBe('+14%')
})
})