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:
parent
8561d73eaf
commit
3cfc86371d
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
94
src/app/demo/jsx/showcase.ts
Normal file
94
src/app/demo/jsx/showcase.ts
Normal 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>
|
||||
`
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
43
tests/engine/render/jsx/gaps.test.ts
Normal file
43
tests/engine/render/jsx/gaps.test.ts
Normal 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%')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue