feat(design-jsx): export every property the renderer accepts (#814)

* feat(design-jsx): export every property the renderer accepts

JSX export wrote only part of a layer: one solid fill, one stroke
without its alignment, shadows as repeated attributes, background blurs
as layer blurs, and nothing for hidden children, constraints, size
limits, absolute positioning, vertical text alignment, masks, or
variable bindings. Rendering an export lost those properties, and JSX
diffs could not see changes to them.

The export now writes them, using paint and effect helper calls when a
shorthand cannot express a value exactly, and leaves out values the
renderer would infer, so ordinary output stays as it was. Prop values
can now hold objects, arrays, and helper calls, printed through
@open-pencil/codegen's builders, which gain a call expression. The
language gains `visible`, `locked`, `constraints` (Figma's constraints
object with lowercase values, as `blendMode` uses), `italic`,
`strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now
applies `strokeAlign`, `strokeDash`, and the size limits, which it
accepted but ignored. Per-corner radii are written even when the
uniform radius is 0. A round-trip test renders each case's export and
checks the fields and that exporting again changes nothing.

* docs(fig): name the saved-glyph fixture by its repository path

The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere.

* fix(design-jsx): export the node-level dash pattern

A node's own dashPattern was not written, so a node dashed at node level
came back solid and the DOM/CSS export chose a solid border. It now
round-trips as a separate dashPattern prop; strokeDash stays the
stroke-local dash.
This commit is contained in:
Danila Poyarkov 2026-10-03 15:40:21 +04:00 committed by GitHub
parent 749d7baec9
commit 67dbd7156f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1119 additions and 179 deletions

View file

@ -16,6 +16,7 @@
### Added
- See where the built-in AI chat is working: while it replies, a cursor whose outlined label shows a sparkle and a callsign such as *Fern* marks the layers it edits. In a shared room, collaborators see each other's agents in the color of the person running them.
- Hide, lock, and constrain layers in design JSX with `visible={false}`, `locked`, and `constraints={{ horizontal, vertical }}`, set italic text with `italic`, and describe strokes fully with `strokes`, `strokeWeights`, `strokeCap`, `strokeJoin`, and the node-level `dashPattern`. JSX export now writes these together with stacked, gradient, and image fills, every effect, absolutely positioned children, size limits, vertical text alignment, masks, and variable bindings, so rendering exported JSX reproduces them and `diff_jsx` reports changes to them.
- Jump between pages from the command palette: it lists the pages you visited recently in the tab, **Go to page…** lists every page, and typing a page name finds it.
- Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought.
- Preview designs progressively on the canvas as direct AI providers stream JSX, without saving partial designs or adding intermediate undo steps. A preview stays with its page: it hides while you view another page and returns when you come back.
@ -37,6 +38,8 @@
### Fixed
- Export layers with two shadows as one `effects` prop instead of repeating the `shadow` attribute, background blurs as `backgroundBlur` instead of a layer blur, hidden children with `visible={false}` instead of leaving them out, and per-corner radii even when the uniform radius is 0.
- Apply `strokeAlign`, `strokeDash`, `minH`, and `maxH` in design JSX, which were accepted but ignored, and make `minW` and `maxW` set the layer's minimum and maximum width rather than only clamping its initial width.
- Render the canvas with the Vue SDK's `CanvasRoot` and `CanvasSurface`; CanvasKit never started there and the canvas stayed blank.
- Keep the view centered on what you were looking at when zooming to 100% or another fixed level, instead of jumping elsewhere whenever the zoom changes.
- Draw collaborators' names on their cursors with proper letter spacing and fallback fonts, and end long names with an ellipsis.

View file

@ -114,6 +114,14 @@ export function object(entries: (readonly [string, SyntaxNode])[]): SyntaxNode {
export const array = (elements: SyntaxNode[]): SyntaxNode => ({ type: 'ArrayExpression', elements })
/** A call of a function by name, such as a helper inside an exported prop value. */
export const call = (callee: string, args: SyntaxNode[]): SyntaxNode => ({
type: 'CallExpression',
callee: identifier(callee),
arguments: args,
optional: false
})
export function stringUnionType(values: string[]): SyntaxNode {
const literals = values.map((value) => ({ type: 'TSLiteralType', literal: string(value) }))
return literals.length === 1 && literals[0]

View file

@ -1,10 +1,11 @@
import { describe, expect, it } from 'bun:test'
import { describe, expect, it, test } from 'bun:test'
import { pick } from 'es-toolkit'
import { renderJSX } from '@open-pencil/core/design-jsx'
import { sceneNodeToJSX } from '@open-pencil/design-jsx'
import { SceneGraph } from '@open-pencil/scene-graph'
import { SceneGraph, type Fill, type SceneNode, type Stroke } from '@open-pencil/scene-graph'
import { getNodeOrThrow } from '../helpers/assert'
import { expectDefined, getNodeOrThrow } from '../helpers/assert'
describe('attribute string round-trip', () => {
it.each([
@ -40,3 +41,357 @@ describe('text content round-trip', () => {
expect(getNodeOrThrow(g, result.id).text).toBe(text)
})
})
const RED = { r: 1, g: 0, b: 0, a: 1 }
const BLUE = { r: 0, g: 0, b: 1, a: 1 }
/** Alphas that survive the two-digit hex of exported colors exactly. */
const SHADE = { r: 0, g: 0, b: 0, a: 0.2 }
function solidFill(color = RED, fields: Partial<Fill> = {}): Fill {
return { type: 'SOLID', color, opacity: color.a, visible: true, ...fields }
}
function stroke(fields: Partial<Stroke> = {}): Stroke {
return { color: BLUE, weight: 1, opacity: 1, visible: true, align: 'INSIDE', ...fields }
}
interface RoundTripCase {
name: string
/** Builds the exported root under `pageId` and returns the name of the node to compare. */
build(graph: SceneGraph, pageId: string): { rootId: string; target: string }
fields: (keyof SceneNode)[]
}
function frame(graph: SceneGraph, parentId: string, fields: Partial<SceneNode> = {}) {
return graph.createNode('FRAME', parentId, { name: 'Root', width: 300, height: 200, ...fields })
}
function child(
graph: SceneGraph,
parentId: string,
type: SceneNode['type'],
fields: Partial<SceneNode>
) {
return graph.createNode(type, parentId, { name: 'Target', width: 40, height: 30, ...fields })
}
/** A leaf under a plain frame, compared on `fields`. */
function leafCase(
name: string,
type: SceneNode['type'],
fields: Partial<SceneNode>,
compared = Object.keys(fields) as (keyof SceneNode)[]
): RoundTripCase {
return {
name,
build(graph, pageId) {
const root = frame(graph, pageId)
child(graph, root.id, type, fields)
return { rootId: root.id, target: 'Target' }
},
fields: compared
}
}
const CASES: RoundTripCase[] = [
leafCase('hidden layer', 'RECTANGLE', { visible: false }),
leafCase('locked layer', 'RECTANGLE', { locked: true }),
leafCase('constraints', 'RECTANGLE', {
horizontalConstraint: 'MAX',
verticalConstraint: 'STRETCH'
}),
leafCase('scale and center constraints', 'RECTANGLE', {
horizontalConstraint: 'SCALE',
verticalConstraint: 'CENTER'
}),
leafCase('size limits', 'FRAME', {
minWidth: 20,
maxWidth: 400,
minHeight: 10,
maxHeight: 300
}),
leafCase('position and rotation', 'RECTANGLE', { x: 12, y: 34, rotation: 15 }),
leafCase('opacity and blend mode', 'RECTANGLE', { opacity: 0.5, blendMode: 'MULTIPLY' }),
// Figma leaves the uniform radius at 0 when corners differ.
leafCase('independent corners and smoothing', 'RECTANGLE', {
cornerRadius: 0,
independentCorners: true,
topLeftRadius: 4,
topRightRadius: 8,
bottomRightRadius: 12,
bottomLeftRadius: 16,
cornerSmoothing: 0.6
}),
leafCase('single solid fill', 'RECTANGLE', { fills: [solidFill()] }),
leafCase('stacked, hidden, and blended fills', 'RECTANGLE', {
fills: [
solidFill(RED, { visible: false }),
solidFill(BLUE, { opacity: 0.5, blendMode: 'SCREEN' })
]
}),
leafCase('gradient fill with transform', 'RECTANGLE', {
fills: [
{
type: 'GRADIENT_LINEAR',
color: { r: 0, g: 0, b: 0, a: 0 },
opacity: 1,
visible: true,
gradientStops: [
{ color: RED, position: 0 },
{ color: BLUE, position: 1 }
],
gradientTransform: { m00: 0, m01: 1, m02: 0, m10: -1, m11: 0, m12: 1 }
}
]
}),
leafCase('gradient fading to transparent', 'RECTANGLE', {
fills: [
{
type: 'GRADIENT_RADIAL',
color: { r: 0, g: 0, b: 0, a: 0 },
opacity: 1,
visible: true,
gradientStops: [
{ color: SHADE, position: 0 },
{ color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 }
],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
]
}),
leafCase('image fill', 'RECTANGLE', {
fills: [
{
type: 'IMAGE',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
imageHash: 'abc123',
imageScaleMode: 'FIT'
}
]
}),
leafCase('single stroke with every option', 'RECTANGLE', {
strokes: [
stroke({ weight: 3, align: 'OUTSIDE', dashPattern: [4, 2], cap: 'ROUND', join: 'BEVEL' })
]
}),
leafCase('node-level stroke cap and join under a stroke without its own', 'RECTANGLE', {
strokes: [stroke({ weight: 2 })],
strokeCap: 'ROUND',
strokeJoin: 'BEVEL'
}),
leafCase('stroke cap that differs from the node cap', 'RECTANGLE', {
strokes: [stroke({ cap: 'SQUARE' })],
strokeCap: 'ROUND'
}),
leafCase('node-level dash pattern under a stroke without its own', 'RECTANGLE', {
strokes: [stroke()],
dashPattern: [6, 3]
}),
leafCase('stroke opacity that differs from its colour alpha', 'RECTANGLE', {
strokes: [stroke({ opacity: 0.5 })]
}),
leafCase('fill opacity that differs from its colour alpha', 'RECTANGLE', {
fills: [solidFill(RED, { opacity: 0.5 })]
}),
leafCase('size limits without a fixed width', 'FRAME', {
layoutMode: 'VERTICAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
minWidth: 40,
maxWidth: 400
}),
leafCase('several strokes, one hidden', 'RECTANGLE', {
strokes: [stroke({ weight: 2 }), stroke({ color: RED, align: 'CENTER', visible: false })]
}),
leafCase('per-side stroke weights', 'RECTANGLE', {
strokes: [stroke()],
independentStrokeWeights: true,
borderTopWeight: 0,
borderRightWeight: 1,
borderBottomWeight: 2,
borderLeftWeight: 0
}),
leafCase('drop shadow and layer blur shorthands', 'RECTANGLE', {
effects: [
{
type: 'DROP_SHADOW',
color: SHADE,
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
},
{
type: 'LAYER_BLUR',
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 4,
spread: 0,
visible: true
}
]
}),
leafCase('inner shadow, spread, hidden shadow, background blur', 'RECTANGLE', {
effects: [
{
type: 'INNER_SHADOW',
color: SHADE,
offset: { x: 1, y: 2 },
radius: 3,
spread: 0,
visible: true
},
{
type: 'DROP_SHADOW',
color: SHADE,
offset: { x: 0, y: 8 },
radius: 16,
spread: 4,
visible: false
},
{
type: 'BACKGROUND_BLUR',
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 12,
spread: 0,
visible: true
}
]
}),
leafCase('mask', 'RECTANGLE', { isMask: true, maskType: 'LUMINANCE' }),
leafCase('clipping', 'FRAME', { clipsContent: true }),
leafCase('star geometry', 'STAR', { pointCount: 7, starInnerRadius: 0.5 }),
leafCase('text style', 'TEXT', {
text: 'Hello',
fontSize: 20,
fontWeight: 700,
italic: true,
textAlignHorizontal: 'CENTER',
textAlignVertical: 'BOTTOM',
textAutoResize: 'NONE',
letterSpacing: 1,
lineHeight: 28,
textCase: 'UPPER',
textDecoration: 'UNDERLINE'
}),
leafCase('text that wraps at a fixed width', 'TEXT', {
text: 'Wrapped',
textAutoResize: 'HEIGHT'
}),
leafCase('text that hugs its content', 'TEXT', {
text: 'Hug',
textAutoResize: 'WIDTH_AND_HEIGHT'
}),
leafCase('truncated text', 'TEXT', {
text: 'Clipped',
textAutoResize: 'TRUNCATE',
textTruncation: 'ENDING'
}),
{
name: 'absolute child of an auto-layout frame',
build(graph, pageId) {
const root = frame(graph, pageId, { layoutMode: 'VERTICAL', itemSpacing: 8 })
child(graph, root.id, 'RECTANGLE', { name: 'Flow' })
child(graph, root.id, 'RECTANGLE', {
layoutPositioning: 'ABSOLUTE',
x: 10,
y: 20,
horizontalConstraint: 'MAX'
})
return { rootId: root.id, target: 'Target' }
},
fields: ['layoutPositioning', 'x', 'y', 'horizontalConstraint']
},
{
name: 'auto layout',
build(graph, pageId) {
const root = frame(graph, pageId, {
layoutMode: 'HORIZONTAL',
itemSpacing: 12,
paddingTop: 4,
paddingRight: 8,
paddingBottom: 4,
paddingLeft: 8,
primaryAxisAlign: 'SPACE_BETWEEN',
counterAxisAlign: 'CENTER',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
child(graph, root.id, 'RECTANGLE', { name: 'A' })
return { rootId: root.id, target: 'Root' }
},
fields: [
'layoutMode',
'itemSpacing',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'primaryAxisAlign',
'counterAxisAlign',
'width',
'height'
]
},
{
name: 'variable bindings',
build(graph, pageId) {
const collection = graph.createCollection('Tokens')
const brand = graph.createVariable('Brand', 'COLOR', collection.id, RED)
const space = graph.createVariable('Space', 'FLOAT', collection.id, 16)
const root = frame(graph, pageId, {
layoutMode: 'VERTICAL',
itemSpacing: 16,
fills: [solidFill()]
})
graph.bindVariable(root.id, 'fills/0/color', brand.id)
graph.bindVariable(root.id, 'itemSpacing', space.id)
return { rootId: root.id, target: 'Root' }
},
fields: ['boundVariables']
}
]
function findByName(graph: SceneGraph, rootId: string, name: string): SceneNode {
const root = expectDefined(graph.getNode(rootId), 'rendered root')
if (root.name === name) return root
for (const childId of root.childIds) {
const found = graph.getNode(childId)?.name === name ? graph.getNode(childId) : undefined
if (found) return found
}
throw new Error(`No node named ${name} under ${root.name}`)
}
test('size limits are limits, not a width', async () => {
const graph = new SceneGraph()
const [defaultWidth] = await renderJSX(graph, '<Frame h={10} />')
const [limited] = await renderJSX(graph, '<Frame h={10} maxW={400} />')
const width = (id: string | undefined) => graph.getNode(expectDefined(id, 'frame'))?.width
expect(graph.getNode(expectDefined(limited, 'frame').id)?.maxWidth).toBe(400)
expect(width(limited?.id)).toBe(width(defaultWidth?.id))
})
describe('design JSX export round trip', () => {
test.each(CASES.map((testCase) => [testCase.name, testCase] as const))(
'%s',
async (_, testCase) => {
const graph = new SceneGraph()
const source = expectDefined(graph.getPages()[0], 'source page')
const target = graph.addPage('Rendered')
const { rootId, target: targetName } = testCase.build(graph, source.id)
const jsx = sceneNodeToJSX(rootId, graph)
const [rendered] = await renderJSX(graph, jsx, { parentId: target.id })
const renderedRoot = expectDefined(rendered, 'render result').id
const original = findByName(graph, rootId, targetName)
const copy = findByName(graph, renderedRoot, targetName)
expect(pick(copy, testCase.fields)).toEqual(pick(original, testCase.fields))
// Everything the export wrote comes back, so exporting the copy changes nothing.
expect(sceneNodeToJSX(renderedRoot, graph)).toBe(jsx)
}
)
})

View file

@ -1,37 +1,10 @@
import type {
SceneGraph,
SceneNode,
Fill,
Stroke,
Effect,
Color,
GridTrack
} from '@open-pencil/scene-graph'
import type { SceneGraph, SceneNode, Effect, Color, GridTrack } from '@open-pencil/scene-graph'
import { colorToHex8 } from '@open-pencil/scene-graph/color'
export function formatColor(color: Color, opacity = 1): string {
return colorToHex8(color, opacity)
}
export function solidFillColor(fills: Fill[]): string | null {
const visible = fills.filter((f) => f.visible && f.type === 'SOLID')
if (visible.length !== 1) return null
return formatColor(visible[0].color, visible[0].opacity)
}
export function solidStroke(
strokes: Stroke[]
): { color: string; weight: number; dash: number[] | null } | null {
const visible = strokes.filter((s) => s.visible)
if (visible.length !== 1) return null
const s = visible[0]
return {
color: formatColor(s.color, s.opacity),
weight: s.weight,
dash: s.dashPattern && s.dashPattern.length > 0 ? [...s.dashPattern] : null
}
}
export function formatShadow(e: Effect): string | null {
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null
return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}`
@ -76,15 +49,16 @@ export interface CornerRadii {
}
export function collectCornerRadii(node: SceneNode): CornerRadii | null {
if (node.cornerRadius <= 0) return null
if (node.independentCorners) {
return {
const corners = {
tl: node.topLeftRadius,
tr: node.topRightRadius,
br: node.bottomRightRadius,
bl: node.bottomLeftRadius
}
return Object.values(corners).some((radius) => radius > 0) ? corners : null
}
if (node.cornerRadius <= 0) return null
const r = node.cornerRadius
return { tl: r, tr: r, br: r, bl: r }
}

View file

@ -2,20 +2,18 @@ import { jsx, type SyntaxNode } from '@open-pencil/codegen'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { collectProps, NODE_TYPE_TO_TAG, type JSXProp } from './props'
import { valueSyntax } from './value'
function propValue(value: JSXProp[1]): SyntaxNode | null {
if (value === true) return null
if (typeof value === 'string') return jsx.stringValue(value)
if (Array.isArray(value)) {
return jsx.container({
type: 'ArrayExpression',
elements: value.map((item) => jsx.literal(item))
})
}
return jsx.container(jsx.literal(value))
return jsx.container(valueSyntax(value))
}
/** A prop as a JSX attribute: `true` prints the bare name, other values as typed literals. */
/**
* A prop as a JSX attribute: `true` prints the bare name, strings as attribute strings, and
* other values as expressions, with paint and effect helpers as calls.
*/
function propAttribute([name, value]: JSXProp): SyntaxNode {
return jsx.attribute(name, propValue(value))
}
@ -27,9 +25,9 @@ function nodeToJSX(node: SceneNode, graph: SceneGraph, depth: number): SyntaxNod
if (node.type === 'TEXT') {
return jsx.element(tag, attributes, node.text ? [jsx.text(node.text)] : [], depth, true)
}
// Hidden children export with `visible={false}` rather than disappearing.
const children = graph
.getChildren(node.id)
.filter((child) => child.visible)
.flatMap((child) => nodeToJSX(child, graph, depth + 1) ?? [])
return jsx.element(tag, attributes, children, depth)
}

View file

@ -0,0 +1,222 @@
import type { Effect, Fill, GradientTransform, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { formatColor, formatShadow } from './helpers'
import { helperCall, plainValue, type HelperCall, type JSXProp, type JSXValue } from './value'
const GRADIENT_HELPERS: Partial<Record<Fill['type'], string>> = {
GRADIENT_LINEAR: 'linearGradient',
GRADIENT_RADIAL: 'radialGradient',
GRADIENT_ANGULAR: 'angularGradient',
GRADIENT_DIAMOND: 'diamondGradient'
}
const IDENTITY_TRANSFORM: GradientTransform = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
function isIdentity(transform: GradientTransform | undefined): boolean {
if (!transform) return true
return (Object.keys(IDENTITY_TRANSFORM) as (keyof GradientTransform)[]).every(
(key) => transform[key] === IDENTITY_TRANSFORM[key]
)
}
/** Paints and effects imported from Figma often spell out the default blend mode. */
function blendMode(mode: Fill['blendMode']): Fill['blendMode'] {
return mode === 'NORMAL' || mode === 'PASS_THROUGH' ? undefined : mode
}
type Options = Record<string, JSXValue | undefined>
/** Helper options with defaults left out, or `undefined` when none differ. */
function options(entries: Options): Options | undefined {
return Object.values(entries).some((value) => value !== undefined) ? entries : undefined
}
function fillValue(fill: Fill): JSXValue {
const visible = fill.visible ? undefined : false
if (fill.type === 'SOLID') {
const color = formatColor(fill.color, fill.color.a)
const opacity = fill.opacity === fill.color.a ? undefined : fill.opacity
return helperCall(
'solid',
color,
options({ opacity, visible, blendMode: blendMode(fill.blendMode) })
)
}
const helper = GRADIENT_HELPERS[fill.type]
if (helper && fill.gradientStops) {
const stops = fill.gradientStops.map((stop) => [
formatColor(stop.color, stop.color.a),
stop.position
])
return helperCall(
helper,
stops,
options({
opacity: fill.opacity === 1 ? undefined : fill.opacity,
visible,
blendMode: blendMode(fill.blendMode),
transform: isIdentity(fill.gradientTransform)
? undefined
: plainValue(fill.gradientTransform)
})
)
}
// Image, pattern, and other paints have no helper; they render back from their fields.
return plainValue(fill)
}
/** A single visible solid paint fits the `bg` or text `color` shorthand. */
function solidShorthand(fills: Fill[]): string | null {
if (fills.length !== 1) return null
const [fill] = fills
if (fill.type !== 'SOLID' || !fill.visible || blendMode(fill.blendMode)) return null
// The shorthand has one alpha; a fill whose opacity differs from its colour's needs `solid`.
if (fill.opacity !== fill.color.a) return null
return formatColor(fill.color, fill.color.a)
}
export function collectFillProps(node: SceneNode, props: JSXProp[]): void {
if (node.fills.length === 0) return
const shorthand = solidShorthand(node.fills)
if (shorthand) props.push([node.type === 'TEXT' ? 'color' : 'bg', shorthand])
else props.push(['fills', node.fills.map(fillValue)])
}
interface StrokeValue {
[key: string]: JSXValue | undefined
color: string
/** Only when it differs from the colour's alpha, which the colour already carries. */
opacity?: number
weight?: number
align?: string
dash?: number[]
cap?: string
join?: string
visible?: false
}
function strokeValue(stroke: Stroke): StrokeValue {
return {
color: formatColor(stroke.color, stroke.color.a),
opacity: stroke.opacity === stroke.color.a ? undefined : stroke.opacity,
weight: stroke.weight === 1 ? undefined : stroke.weight,
align: stroke.align === 'INSIDE' ? undefined : stroke.align.toLowerCase(),
dash: stroke.dashPattern && stroke.dashPattern.length > 0 ? [...stroke.dashPattern] : undefined,
cap: stroke.cap?.toLowerCase(),
join: stroke.join?.toLowerCase(),
visible: stroke.visible ? undefined : false
}
}
const STROKE_SHORTHAND: Record<string, string> = {
color: 'stroke',
weight: 'strokeWidth',
align: 'strokeAlign',
dash: 'strokeDash'
}
/** Node-level cap and join apply to strokes that do not set their own. */
const DEFAULT_STROKE_CAP = 'NONE'
const DEFAULT_STROKE_JOIN = 'MITER'
/**
* The shorthand props describe one stroke, and `strokeCap`/`strokeJoin` also set the node's
* cap and join; they fit only when the stroke agrees with the node on both.
*/
function fitsStrokeShorthand(node: SceneNode, stroke: StrokeValue): boolean {
if (stroke.visible !== undefined || stroke.opacity !== undefined) return false
const nodeCap = node.strokeCap.toLowerCase()
const nodeJoin = node.strokeJoin.toLowerCase()
return (stroke.cap ?? nodeCap) === nodeCap && (stroke.join ?? nodeJoin) === nodeJoin
}
export function collectStrokeProps(node: SceneNode, props: JSXProp[]): void {
const strokes = node.strokes.map(strokeValue)
const [only] = strokes
const shorthand = strokes.length === 1 && fitsStrokeShorthand(node, only)
if (shorthand) {
for (const [key, value] of Object.entries(only)) {
if (value !== undefined && key !== 'cap' && key !== 'join') {
props.push([STROKE_SHORTHAND[key], value])
}
}
} else if (strokes.length > 0) {
props.push(['strokes', strokes])
}
if (node.strokeCap !== DEFAULT_STROKE_CAP) props.push(['strokeCap', node.strokeCap.toLowerCase()])
if (node.strokeJoin !== DEFAULT_STROKE_JOIN) {
props.push(['strokeJoin', node.strokeJoin.toLowerCase()])
}
if (node.dashPattern.length > 0) props.push(['dashPattern', [...node.dashPattern]])
if (node.independentStrokeWeights) {
props.push([
'strokeWeights',
{
top: node.borderTopWeight,
right: node.borderRightWeight,
bottom: node.borderBottomWeight,
left: node.borderLeftWeight
}
])
}
}
const EFFECT_HELPERS: Record<Effect['type'], string> = {
DROP_SHADOW: 'dropShadow',
INNER_SHADOW: 'innerShadow',
LAYER_BLUR: 'layerBlur',
BACKGROUND_BLUR: 'backgroundBlur',
FOREGROUND_BLUR: 'foregroundBlur'
}
function effectValue(effect: Effect): HelperCall {
const visible = effect.visible ? undefined : false
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
return helperCall(
EFFECT_HELPERS[effect.type],
options({
x: effect.offset.x,
y: effect.offset.y,
radius: effect.radius,
spread: effect.spread === 0 ? undefined : effect.spread,
color: formatColor(effect.color, effect.color.a),
visible,
blendMode: blendMode(effect.blendMode),
showShadowBehindNode: effect.showShadowBehindNode
})
)
}
return helperCall(
EFFECT_HELPERS[effect.type],
visible === undefined ? effect.radius : { radius: effect.radius, visible }
)
}
function plainDropShadow(effect: Effect): string | null {
const plain =
effect.type === 'DROP_SHADOW' &&
effect.spread === 0 &&
!blendMode(effect.blendMode) &&
effect.showShadowBehindNode === undefined
return plain ? formatShadow(effect) : null
}
/**
* `shadow` and `blur` describe a plain drop shadow followed by a layer blur, each optional,
* in the order the renderer appends them; anything else needs `effects`.
*/
function effectShorthand(effects: Effect[]): JSXProp[] | null {
if (effects.some((effect) => !effect.visible)) return null
const shadow = effects.length > 0 ? plainDropShadow(effects[0]) : null
const rest = shadow ? effects.slice(1) : effects
if (rest.length > 1 || rest.some((effect) => effect.type !== 'LAYER_BLUR')) return null
const props: JSXProp[] = []
if (shadow) props.push(['shadow', shadow])
for (const blur of rest) props.push(['blur', blur.radius])
return props
}
export function collectEffectProps(node: SceneNode, props: JSXProp[]): void {
if (node.effects.length === 0) return
props.push(...(effectShorthand(node.effects) ?? [['effects', node.effects.map(effectValue)]]))
}

View file

@ -6,15 +6,14 @@ import {
collectCornerRadii,
collectPadding,
emitPadding,
formatShadow,
formatTracks,
getNodeContext,
solidFillColor,
solidStroke
getNodeContext
} from './helpers'
import { collectEffectProps, collectFillProps, collectStrokeProps } from './paint'
import { collectStateProps } from './state'
import type { JSXProp } from './value'
/** A prop name and its value: a string, number, boolean, or number list. */
export type JSXProp = [name: string, value: string | number | boolean | number[]]
export type { JSXProp } from './value'
/** The element that represents each node type; other types are not exported. */
export const NODE_TYPE_TO_TAG: Partial<Record<NodeType, string>> = {
@ -125,16 +124,8 @@ function collectCornerRadiiProps(node: SceneNode, props: JSXProp[]): void {
}
function collectAppearanceProps(node: SceneNode, props: JSXProp[]): void {
const bg = solidFillColor(node.fills)
if (bg) props.push(['bg', bg])
const stroke = solidStroke(node.strokes)
if (stroke) {
props.push(['stroke', stroke.color])
if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight])
if (stroke.dash) props.push(['strokeDash', stroke.dash])
}
collectFillProps(node, props)
collectStrokeProps(node, props)
collectCornerRadiiProps(node, props)
if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing])
@ -144,16 +135,7 @@ function collectAppearanceProps(node: SceneNode, props: JSXProp[]): void {
props.push(['blendMode', node.blendMode.toLowerCase()])
}
if (node.clipsContent) props.push(['overflow', 'hidden'])
for (const effect of node.effects) {
if (!effect.visible) continue
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
const shadow = formatShadow(effect)
if (shadow) props.push(['shadow', shadow])
} else if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') {
props.push(['blur', effect.radius])
}
}
collectEffectProps(node, props)
}
function collectPositionProps(
@ -161,7 +143,12 @@ function collectPositionProps(
ctx: ReturnType<typeof getNodeContext>,
props: JSXProp[]
): void {
if (ctx.parentIsAutoLayout || ctx.parentIsGrid) return
if (ctx.parentIsAutoLayout || ctx.parentIsGrid) {
// Absolute children of a layout keep their position; the rest are placed by it.
if (node.layoutPositioning !== 'ABSOLUTE') return
props.push(['position', 'absolute'], ['x', node.x], ['y', node.y])
return
}
if (node.x !== 0) props.push(['x', node.x])
if (node.y !== 0) props.push(['y', node.y])
}
@ -225,9 +212,13 @@ function collectTextNodeProps(node: SceneNode, props: JSXProp[]): void {
else props.push(['weight', node.fontWeight])
}
if (direction === 'RTL') props.push(['dir', 'rtl'])
if (node.italic) props.push(['italic', true])
if (node.textAlignHorizontal !== 'LEFT') {
props.push(['textAlign', node.textAlignHorizontal.toLowerCase()])
}
if (node.textAlignVertical !== 'TOP') {
props.push(['textAlignVertical', node.textAlignVertical.toLowerCase()])
}
if (node.lineHeight != null) props.push(['lineHeight', node.lineHeight])
if (node.letterSpacing !== 0) props.push(['letterSpacing', node.letterSpacing])
if (node.textDecoration !== 'NONE')
@ -235,11 +226,26 @@ function collectTextNodeProps(node: SceneNode, props: JSXProp[]): void {
if (node.textCase !== 'ORIGINAL') props.push(['textCase', node.textCase.toLowerCase()])
if (node.maxLines != null) props.push(['maxLines', node.maxLines])
if (node.textTruncation === 'ENDING' && node.maxLines == null) props.push(['truncate', true])
const textColor = solidFillColor(node.fills)
if (textColor) {
const bgIdx = props.findIndex(([k]) => k === 'bg')
if (bgIdx !== -1) props.splice(bgIdx, 1)
props.push(['color', textColor])
}
const TEXT_AUTO_RESIZE: Record<SceneNode['textAutoResize'], string> = {
NONE: 'none',
WIDTH_AND_HEIGHT: 'width',
HEIGHT: 'height',
TRUNCATE: 'truncate'
}
/** The renderer infers auto-resize from the width props; say so only when it differs. */
function collectTextAutoResizeProp(
node: SceneNode,
ctx: ReturnType<typeof getNodeContext>,
props: JSXProp[]
): void {
const hasWidth = props.some(([key]) => key === 'w')
const grows = ctx.parentIsAutoLayout && node.layoutGrow > 0
const inferred = hasWidth || grows ? 'HEIGHT' : 'WIDTH_AND_HEIGHT'
if (node.textAutoResize !== inferred) {
props.push(['textAutoResize', TEXT_AUTO_RESIZE[node.textAutoResize]])
}
}
@ -266,8 +272,12 @@ export function collectProps(node: SceneNode, graph: SceneGraph): JSXProp[] {
if (ctx.isFlex) collectFlexAlignmentProps(node, props)
if (ctx.isAutoLayout) collectAutoLayoutPaddingProps(node, props)
collectAppearanceProps(node, props)
if (node.type === 'TEXT') collectTextNodeProps(node, props)
if (node.type === 'TEXT') {
collectTextNodeProps(node, props)
collectTextAutoResizeProp(node, ctx, props)
}
collectShapeNodeProps(node, props)
collectStateProps(node, ctx, graph, props)
return props
}

View file

@ -0,0 +1,65 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { getNodeContext } from './helpers'
import { helperCall, type JSXProp, type JSXValue } from './value'
/** Constraints apply where a parent does not lay its children out. */
function hasConstraints(node: SceneNode, ctx: ReturnType<typeof getNodeContext>): boolean {
return !ctx.parentIsAutoLayout || node.layoutPositioning === 'ABSOLUTE'
}
function collectConstraintProps(
node: SceneNode,
ctx: ReturnType<typeof getNodeContext>,
props: JSXProp[]
): void {
if (!hasConstraints(node, ctx)) return
const horizontal = node.horizontalConstraint === 'MIN' ? undefined : node.horizontalConstraint
const vertical = node.verticalConstraint === 'MIN' ? undefined : node.verticalConstraint
if (!horizontal && !vertical) return
props.push([
'constraints',
{ horizontal: horizontal?.toLowerCase(), vertical: vertical?.toLowerCase() }
])
}
function collectSizeLimitProps(node: SceneNode, props: JSXProp[]): void {
if (node.minWidth != null) props.push(['minW', node.minWidth])
if (node.maxWidth != null) props.push(['maxW', node.maxWidth])
if (node.minHeight != null) props.push(['minH', node.minHeight])
if (node.maxHeight != null) props.push(['maxH', node.maxHeight])
}
/**
* Variables are named when the name is unique in the document, which is how `designVar`
* resolves them, and referenced by ID otherwise.
*/
function variableRef(graph: SceneGraph, variableId: string): JSXValue {
const variable = graph.variables.get(variableId)
if (!variable) return variableId
const sameName = [...graph.variables.values()].filter((v) => v.name === variable.name)
return sameName.length === 1 ? helperCall('designVar', variable.name) : variableId
}
function collectBindingProps(node: SceneNode, graph: SceneGraph, props: JSXProp[]): void {
const bindings = Object.entries(node.boundVariables).toSorted(([a], [b]) => a.localeCompare(b))
if (bindings.length === 0) return
props.push([
'bind',
Object.fromEntries(bindings.map(([field, id]) => [field, variableRef(graph, id)]))
])
}
export function collectStateProps(
node: SceneNode,
ctx: ReturnType<typeof getNodeContext>,
graph: SceneGraph,
props: JSXProp[]
): void {
collectConstraintProps(node, ctx, props)
collectSizeLimitProps(node, props)
if (node.isMask) props.push(['mask', node.maskType.toLowerCase()])
if (!node.visible) props.push(['visible', false])
if (node.locked) props.push(['locked', true])
collectBindingProps(node, graph, props)
}

View file

@ -0,0 +1,68 @@
import { es, jsx, type SyntaxNode } from '@open-pencil/codegen'
/** A paint, effect, or variable helper call inside a prop value, such as `solid("#FF0000")`. */
export class HelperCall {
constructor(
readonly helper: string,
readonly args: JSXValue[]
) {}
}
/** A prop value: plain data, possibly holding helper calls. Undefined object fields are left out. */
export type JSXValue =
| string
| number
| boolean
| null
| HelperCall
| JSXValue[]
| { [key: string]: JSXValue | undefined }
/** A prop name and its value. */
export type JSXProp = [name: string, value: JSXValue]
/** A call with trailing undefined arguments dropped, so defaults stay implicit. */
export function helperCall(helper: string, ...args: (JSXValue | undefined)[]): HelperCall {
const last = args.findLastIndex((arg) => arg !== undefined)
return new HelperCall(
helper,
args.slice(0, last + 1).map((arg) => arg ?? null)
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Plain scene data, such as a paint with no helper, as a prop value. Throws on values JSX
* cannot write back, such as binary data.
*/
export function plainValue(value: unknown): JSXValue {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
if (typeof value === 'number' && Number.isFinite(value)) return value
if (Array.isArray(value)) return value.map(plainValue)
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) =>
item === undefined ? [] : [[key, plainValue(item)]]
)
)
}
throw new TypeError(`Cannot write a ${typeof value} value as JSX`)
}
/** The expression for a value, printed through the codegen builders. */
export function valueSyntax(value: JSXValue): SyntaxNode {
if (value === null) return { type: 'Literal', value: null }
if (value instanceof HelperCall) return es.call(value.helper, value.args.map(valueSyntax))
if (Array.isArray(value)) return es.array(value.map(valueSyntax))
if (typeof value === 'object') {
return es.object(
Object.entries(value).flatMap(([key, item]) =>
item === undefined ? [] : [[key, valueSyntax(item)] as const]
)
)
}
return jsx.literal(value)
}

View file

@ -0,0 +1,51 @@
import type { Effect, SceneNode } from '@open-pencil/scene-graph'
import { parseColor } from '@open-pencil/scene-graph/color'
import { TRANSPARENT } from '@open-pencil/scene-graph/constants'
function isEffect(value: unknown): value is Effect {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
'radius' in value &&
'visible' in value
)
}
function shadowShorthand(value: string): Effect | null {
const parts = value.split(/\s+/)
if (parts.length < 4) return null
return {
type: 'DROP_SHADOW',
color: parseColor(parts.slice(3).join(' ')),
offset: { x: Number.parseFloat(parts[0]), y: Number.parseFloat(parts[1]) },
radius: Number.parseFloat(parts[2]),
spread: 0,
visible: true
}
}
/** `effects` takes structured effects; `shadow` and `blur` append one drop shadow or layer blur. */
export function applyEffectOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (Array.isArray(props.effects)) {
const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect))
if (effects.length > 0) o.effects = effects
}
const shadow = typeof props.shadow === 'string' ? shadowShorthand(props.shadow) : null
if (shadow) o.effects = [...(o.effects ?? []), shadow]
if (typeof props.blur === 'number') {
o.effects = [
...(o.effects ?? []),
{
type: 'LAYER_BLUR',
radius: props.blur,
visible: true,
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },
spread: 0
}
]
}
}

View file

@ -0,0 +1,65 @@
import type { ConstraintType, SceneNode } from '@open-pencil/scene-graph'
const CONSTRAINT_VALUES: Record<string, ConstraintType> = {
min: 'MIN',
center: 'CENTER',
max: 'MAX',
stretch: 'STRETCH',
scale: 'SCALE'
}
const MASK_TYPES: Record<string, SceneNode['maskType']> = {
alpha: 'ALPHA',
luminance: 'LUMINANCE',
vector: 'VECTOR'
}
function constraintValue(value: unknown): ConstraintType | undefined {
return typeof value === 'string' ? CONSTRAINT_VALUES[value.toLowerCase()] : undefined
}
/** The `constraints` prop, shaped like Figma's `constraints` object. */
interface ConstraintsValue {
horizontal?: unknown
vertical?: unknown
}
function isConstraintsValue(value: unknown): value is ConstraintsValue {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function applyConstraints(value: unknown, o: Partial<SceneNode>): void {
if (!isConstraintsValue(value)) return
const h = constraintValue(value.horizontal)
const v = constraintValue(value.vertical)
if (h) o.horizontalConstraint = h
if (v) o.verticalConstraint = v
}
function sizeLimit(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
/**
* Visibility, locking, mask, constraint, and size-limit props. Constraints follow Figma's
* `constraints` object with its values in lowercase, as `blendMode` is.
*/
export function applyStateOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (typeof props.visible === 'boolean') o.visible = props.visible
if (typeof props.locked === 'boolean') o.locked = props.locked
applyConstraints(props.constraints, o)
const minWidth = sizeLimit(props.minW)
const maxWidth = sizeLimit(props.maxW)
const minHeight = sizeLimit(props.minH)
const maxHeight = sizeLimit(props.maxH)
if (minWidth !== undefined) o.minWidth = minWidth
if (maxWidth !== undefined) o.maxWidth = maxWidth
if (minHeight !== undefined) o.minHeight = minHeight
if (maxHeight !== undefined) o.maxHeight = maxHeight
if (props.mask) {
o.isMask = true
o.maskType = (typeof props.mask === 'string' && MASK_TYPES[props.mask]) || 'ALPHA'
}
}

View file

@ -0,0 +1,129 @@
import type { SceneNode, Stroke, StrokeCap, StrokeJoin } from '@open-pencil/scene-graph'
import { parseColor } from '@open-pencil/scene-graph/color'
import type { Color } from '@open-pencil/scene-graph/primitives'
const ALIGN_VALUES: Record<string, Stroke['align']> = {
inside: 'INSIDE',
center: 'CENTER',
outside: 'OUTSIDE'
}
const CAP_VALUES: Record<string, StrokeCap> = {
none: 'NONE',
round: 'ROUND',
square: 'SQUARE',
arrow_lines: 'ARROW_LINES',
arrow_equilateral: 'ARROW_EQUILATERAL'
}
const JOIN_VALUES: Record<string, StrokeJoin> = {
miter: 'MITER',
bevel: 'BEVEL',
round: 'ROUND'
}
/** One entry of the `strokes` prop; enums are lowercase, as `blendMode` is. */
export interface StrokeValue {
color: string | Color
/** Defaults to the colour's alpha. */
opacity?: number
weight?: number
align?: string
dash?: number[]
cap?: string
join?: string
visible?: boolean
}
function isColor(value: unknown): value is Color {
return value !== null && typeof value === 'object' && 'r' in value && 'a' in value
}
function isStrokeValue(value: unknown): value is StrokeValue {
if (value === null || typeof value !== 'object' || !('color' in value)) return false
return typeof value.color === 'string' || isColor(value.color)
}
function enumValue<T>(values: Record<string, T>, value: unknown): T | undefined {
return typeof value === 'string' ? values[value.toLowerCase()] : undefined
}
function dashValue(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined
const dash = value.filter((item): item is number => typeof item === 'number')
return dash.length > 0 ? dash : undefined
}
function toStroke(value: StrokeValue): Stroke {
const color = typeof value.color === 'string' ? parseColor(value.color) : value.color
const stroke: Stroke = {
color,
opacity: typeof value.opacity === 'number' ? value.opacity : color.a,
visible: value.visible ?? true,
weight: value.weight ?? 1,
align: enumValue(ALIGN_VALUES, value.align) ?? 'INSIDE'
}
const dash = dashValue(value.dash)
if (dash) stroke.dashPattern = dash
const cap = enumValue(CAP_VALUES, value.cap)
if (cap) stroke.cap = cap
const join = enumValue(JOIN_VALUES, value.join)
if (join) stroke.join = join
return stroke
}
/** `strokeWeights` names each side; a side it leaves out has no stroke. */
interface StrokeWeights {
top?: unknown
right?: unknown
bottom?: unknown
left?: unknown
}
function isStrokeWeights(value: unknown): value is StrokeWeights {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function sideWeight(value: unknown): number {
return typeof value === 'number' ? value : 0
}
function applyStrokeWeights(value: unknown, o: Partial<SceneNode>): void {
if (!isStrokeWeights(value)) return
o.independentStrokeWeights = true
o.borderTopWeight = sideWeight(value.top)
o.borderRightWeight = sideWeight(value.right)
o.borderBottomWeight = sideWeight(value.bottom)
o.borderLeftWeight = sideWeight(value.left)
}
/** `strokes` takes structured strokes; `stroke` and its sibling props describe a single one. */
export function applyStrokeOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
applyStrokeWeights(props.strokeWeights, o)
// Node-level cap and join, which strokes without their own use.
const nodeCap = enumValue(CAP_VALUES, props.strokeCap)
if (nodeCap) o.strokeCap = nodeCap
const nodeJoin = enumValue(JOIN_VALUES, props.strokeJoin)
if (nodeJoin) o.strokeJoin = nodeJoin
// The node's own dash pattern, separate from the stroke-local dash `strokeDash` sets.
const nodeDash = dashValue(props.dashPattern)
if (nodeDash) o.dashPattern = nodeDash
if (Array.isArray(props.strokes)) {
const strokes = props.strokes.filter(isStrokeValue).map(toStroke)
if (strokes.length > 0) o.strokes = strokes
return
}
const color = props.stroke ?? props.border ?? props.borderColor
if (typeof color !== 'string' && !isColor(color)) return
o.strokes = [
toStroke({
color,
weight:
(props.strokeWidth as number | undefined) ?? (props.borderWidth as number | undefined),
align: props.strokeAlign as string | undefined,
dash: props.strokeDash as number[] | undefined
})
]
}

View file

@ -1,17 +1,11 @@
/* eslint-disable max-lines -- JSX property conversion stays together to preserve shared coercion rules */
import type {
Effect,
Fill,
GridTrack,
LayoutMode,
SceneNode,
Stroke
} from '@open-pencil/scene-graph'
import { colorToFill, parseColor } from '@open-pencil/scene-graph/color'
import { TRANSPARENT } from '@open-pencil/scene-graph/constants'
import type { Fill, GridTrack, LayoutMode, SceneNode } from '@open-pencil/scene-graph'
import { colorToFill } from '@open-pencil/scene-graph/color'
import type { Color, JSONObject } from '@open-pencil/scene-graph/primitives'
import { applyEffectOverrides } from './overrides/effects'
import { applyStateOverrides } from './overrides/state'
import { applyStrokeOverrides } from './overrides/strokes'
const WEIGHT_MAP: Record<string, number> = {
normal: 400,
medium: 500,
@ -55,7 +49,8 @@ const TEXT_ALIGN_ALIAS_MAP: Record<string, SceneNode['textAlignHorizontal']> = {
const TEXT_AUTO_RESIZE_MAP: Record<string, SceneNode['textAutoResize']> = {
none: 'NONE',
width: 'WIDTH_AND_HEIGHT',
height: 'HEIGHT'
height: 'HEIGHT',
truncate: 'TRUNCATE'
}
const DIRECTION_MAP: Record<string, SceneNode['textDirection']> = {
@ -69,17 +64,6 @@ function parseDirection(value: unknown): SceneNode['textDirection'] | undefined
return DIRECTION_MAP[value.toLowerCase()] ?? 'AUTO'
}
function parseStroke(value: string | Color, width: number): Stroke {
const color = typeof value === 'string' ? parseColor(value) : value
return {
color,
opacity: color.a,
visible: true,
weight: width,
align: 'INSIDE'
}
}
function numberFromPx(value: unknown): number | undefined {
if (typeof value === 'number') return value
if (typeof value !== 'string') return undefined
@ -208,14 +192,6 @@ function applyFillOverride(props: Record<string, unknown>, o: Partial<SceneNode>
if (isFillValue(bg)) o.fills = [fillFromValue(bg)]
}
function applyStrokeOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const stroke = props.stroke ?? props.border ?? props.borderColor
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)]
}
function applyCornerOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const rounded = props.rounded ?? props.cornerRadius ?? props.borderRadius
if (typeof rounded === 'number') o.cornerRadius = rounded
@ -238,7 +214,7 @@ function applyCornerOverrides(props: Record<string, unknown>, o: Partial<SceneNo
function applyVisualOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
applyFillOverride(props, o)
applyStrokeOverride(props, o)
applyStrokeOverrides(props, o)
applyCornerOverrides(props, o)
if (props.opacity !== undefined) o.opacity = props.opacity as number
@ -247,14 +223,6 @@ 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 {
@ -451,9 +419,6 @@ function applyLayoutOverrides(
applyPaddingOverrides(props, o)
if (props.grow !== undefined) o.layoutGrow = props.grow as number
if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number)
if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number)
}
function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
@ -464,6 +429,8 @@ function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<Scen
if (typeof fontFamily === 'string') o.fontFamily = fontFamily
const weight = props.weight ?? props.fontWeight
if (typeof props.italic === 'boolean') o.italic = props.italic
if (typeof weight === 'number') {
o.fontWeight = weight
} else if (typeof weight === 'string') {
@ -536,57 +503,10 @@ function applyTextOverrides(
applyTextAutoResize(props, o, parentLayout)
}
function isEffect(value: unknown): value is Effect {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
'radius' in value &&
'visible' in value
)
}
function applyShapeAndEffectOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (Array.isArray(props.effects)) {
const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect))
if (effects.length > 0) o.effects = effects
}
function applyShapeOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (props.points !== undefined) o.pointCount = props.points as number
if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number
if (props.pointCount !== undefined) o.pointCount = props.pointCount as number
if (typeof props.shadow === 'string') {
const parts = props.shadow.split(/\s+/)
if (parts.length >= 4) {
const c = parseColor(parts.slice(3).join(' '))
o.effects = [
...(o.effects ?? []),
{
type: 'DROP_SHADOW',
color: c,
offset: { x: Number.parseFloat(parts[0]), y: Number.parseFloat(parts[1]) },
radius: Number.parseFloat(parts[2]),
spread: 0,
visible: true
}
]
}
}
if (typeof props.blur === 'number') {
o.effects = [
...(o.effects ?? []),
{
type: 'LAYER_BLUR',
radius: props.blur,
visible: true,
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },
spread: 0
}
]
}
}
export function propsToOverrides(
@ -603,7 +523,9 @@ export function propsToOverrides(
applyVisualOverrides(props, o)
applyLayoutOverrides(props, o, w, h, isText, parentLayout)
if (isText) applyTextOverrides(props, o, parentLayout)
applyShapeAndEffectOverrides(props, o)
applyShapeOverrides(props, o)
applyEffectOverrides(props, o)
applyStateOverrides(props, o)
return o
}

View file

@ -12,14 +12,16 @@ This reference describes scene creation, not React DOM output. Use the `render`
- For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow.
- `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`.
- `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately.
- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it.
- Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed.
## Paint, text, and artwork
- `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`.
- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`.
- `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct.
- `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning.
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
- `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible.
- Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work.

View file

@ -60,6 +60,7 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
'top',
'left',
'position',
'constraints',
'p',
'padding',
'px',
@ -80,6 +81,11 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
'borderWidth',
'strokeAlign',
'strokeDash',
'strokeCap',
'strokeJoin',
'dashPattern',
'strokes',
'strokeWeights',
'rounded',
'borderRadius',
'roundedTL',
@ -93,6 +99,9 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
'rotate',
'rotation',
'overflow',
'mask',
'visible',
'locked',
'shadow',
'blur',
'effects',
@ -102,6 +111,7 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
'fontFamily',
'weight',
'fontWeight',
'italic',
'color',
'text',
'characters',

View file

@ -137,6 +137,7 @@ export type StyleProps = {
strokeWidth?: number | DesignVariable
strokeAlign?: 'inside' | 'outside' | 'center'
strokeDash?: number[] | boolean
dashPattern?: number[]
rounded?: number | DesignVariable
roundedTL?: number | DesignVariable
roundedTR?: number | DesignVariable

View file

@ -221,7 +221,7 @@ describe('sceneNodeToJSX', () => {
expect(jsx).toContain('strokeWidth={2}')
})
test('hidden children are excluded', () => {
test('hidden children are kept and marked hidden', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Parent',
@ -239,9 +239,14 @@ describe('sceneNodeToJSX', () => {
height: 50,
visible: false
})
const jsx = sceneNodeToJSX(frame.id, graph)
expect(jsx).toContain('Visible')
expect(jsx).not.toContain('Hidden')
expect(sceneNodeToJSX(frame.id, graph)).toBe(
[
'<Frame name="Parent" w={100} h={100}>',
' <Rectangle name="Visible" w={50} h={50} />',
' <Rectangle name="Hidden" w={50} h={50} visible={false} />',
'</Frame>'
].join('\n')
)
})
test('empty text node renders self-closing', () => {
@ -301,6 +306,54 @@ describe('sceneNodeToJSX', () => {
expect(jsx).toContain('blur={4}')
})
test('effects: two shadows become one effects prop, not duplicate attributes', () => {
const graph = makeGraph()
const shadow = {
type: 'DROP_SHADOW' as const,
color: { r: 0, g: 0, b: 0, a: 0.2 },
offset: { x: 0, y: 2 },
radius: 4,
spread: 0,
visible: true
}
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Card',
width: 10,
height: 10,
effects: [shadow, { ...shadow, offset: { x: 0, y: 8 }, radius: 16 }]
})
expect(sceneNodeToJSX(node.id, graph)).toBe(
[
'<Rectangle name="Card" w={10} h={10} effects={[',
' dropShadow({ x: 0, y: 2, radius: 4, color: "#00000033" }),',
' dropShadow({ x: 0, y: 8, radius: 16, color: "#00000033" })',
']} />'
].join('\n')
)
})
test('effects: a background blur is not exported as a layer blur', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Glass',
width: 10,
height: 10,
effects: [
{
type: 'BACKGROUND_BLUR',
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 12,
spread: 0,
visible: true
}
]
})
expect(sceneNodeToJSX(node.id, graph)).toBe(
'<Rectangle name="Glass" w={10} h={10} effects={[backgroundBlur(12)]} />'
)
})
test('grid layout frame', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {

View file

@ -28,7 +28,7 @@ openpencil export design.fig -f jsx # OpenPencil format
openpencil export design.fig -f jsx --style tailwind # Tailwind classes
```
Exported OpenPencil JSX can be edited and rendered back into the document. Check supported features and actual rendering before claiming round-trip fidelity. See [CLI exports](./cli/exporting).
Exported OpenPencil JSX can be edited and rendered back into the document. The export writes hidden and locked layers, constraints, size limits, stacked, gradient, and image fills, strokes, effects, masks, and variable bindings, so rendering an export reproduces them and `diff_jsx` shows changes to any of them. Rich text with mixed styles, vector paths, layout grids, shared styles, and component property definitions are not written yet. Instances are written as frames with their content, so exported JSX stands on its own. See [CLI exports](./cli/exporting).
## Visual Diffing

View file

@ -14,14 +14,16 @@ This reference describes scene creation, not React DOM output. Use the `render`
- For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow.
- `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`.
- `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately.
- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it.
- Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed.
## Paint, text, and artwork
- `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`.
- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`.
- `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct.
- `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning.
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
- `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible.
- Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work.
@ -70,4 +72,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a
**Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.

View file

@ -1,6 +1,6 @@
# Styled saved glyph fixture
[`tests/fixtures/styled-saved-glyphs.json`](../../../../tests/fixtures/styled-saved-glyphs.json), which the saved-glyph and occurrence-text canvas specs load, is a reduced SceneGraph conversion of a temporary Figma-authored text node containing `office affine` at 40 px Inter Regular. The first word has a red solid fill at 50% opacity; the second has a blue solid fill. The node was captured from Figma's Save local copy canvas payload, then the temporary page was removed.
`tests/fixtures/styled-saved-glyphs.json`, which the saved-glyph and occurrence-text canvas specs load, is a reduced SceneGraph conversion of a temporary Figma-authored text node containing `office affine` at 40 px Inter Regular. The first word has a red solid fill at 50% opacity; the second has a blue solid fill. The node was captured from Figma's Save local copy canvas payload, then the temporary page was removed.
Only typography, dimensions, fills, style runs, and saved glyph outlines/character indices are retained. This is not a full document or font binary. It contains 13 glyphs for 13 characters and **does not prove ligature coverage**.

View file

@ -14,14 +14,16 @@ This reference describes scene creation, not React DOM output. Use the `render`
- For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow.
- `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`.
- `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately.
- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it.
- Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed.
## Paint, text, and artwork
- `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`.
- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`.
- `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct.
- `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning.
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
- `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible.
- Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work.
@ -70,4 +72,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a
**Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.