feat(design-jsx): support structured paints

This commit is contained in:
Danila Poyarkov 2026-06-02 11:42:29 +03:00
parent 4b65e64c18
commit cc1167eed5
9 changed files with 207 additions and 5 deletions

View file

@ -6,6 +6,7 @@
- Add JSX authoring support for components, component sets, and instances.
- Add design JSX variable helpers so color props can use `designVar()` / `defineVars()` references and emit graph variable bindings.
- Add structured design JSX paint helpers for solid fills, multiple fills, and gradients.
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
- Add `unbind_variable` MCP tool for removing variable bindings.
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.

View file

@ -31,6 +31,19 @@ export {
export { renderTree, type RenderResult } from './renderer'
export {
angularGradient,
diamondGradient,
gradient,
linearGradient,
radialGradient,
solid,
type GradientPaintOptions,
type PaintColor,
type PaintStop,
type SolidPaintOptions
} from './paints'
export { defineVars, designVar, isVariable, type DesignVariable, type VarDef } from './vars'
export { createElement } from './mini-react'

View file

@ -0,0 +1,82 @@
import { colorToFill, parseColor } from '#core/color'
import { TRANSPARENT } from '#core/constants'
import type { BlendMode, Fill, FillType, GradientStop, GradientTransform } from '#core/scene-graph'
import type { Color } from '#core/types'
export type PaintColor = string | Color
export type PaintStop = readonly [PaintColor, number] | { color: PaintColor; position: number }
export interface SolidPaintOptions {
opacity?: number
visible?: boolean
blendMode?: BlendMode
}
export interface GradientPaintOptions extends SolidPaintOptions {
transform?: GradientTransform
}
const DEFAULT_GRADIENT_TRANSFORM: GradientTransform = {
m00: 1,
m01: 0,
m02: 0,
m10: 0,
m11: 1,
m12: 0
}
function toColor(color: PaintColor): Color {
return typeof color === 'string' ? parseColor(color) : color
}
function toStop(stop: PaintStop): GradientStop {
if ('color' in stop) {
return { color: toColor(stop.color), position: stop.position }
}
return { color: toColor(stop[0]), position: stop[1] }
}
export function solid(color: PaintColor, options: SolidPaintOptions = {}): Fill {
const fill = colorToFill(color)
return {
...fill,
opacity: options.opacity ?? fill.opacity,
visible: options.visible ?? true,
blendMode: options.blendMode
}
}
export function gradient(
type: Extract<
FillType,
'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND'
>,
stops: PaintStop[],
options: GradientPaintOptions = {}
): Fill {
return {
type,
color: { ...TRANSPARENT },
opacity: options.opacity ?? 1,
visible: options.visible ?? true,
blendMode: options.blendMode,
gradientStops: stops.map(toStop),
gradientTransform: options.transform ?? DEFAULT_GRADIENT_TRANSFORM
}
}
export function linearGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
return gradient('GRADIENT_LINEAR', stops, options)
}
export function radialGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
return gradient('GRADIENT_RADIAL', stops, options)
}
export function angularGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
return gradient('GRADIENT_ANGULAR', stops, options)
}
export function diamondGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
return gradient('GRADIENT_DIAMOND', stops, options)
}

View file

@ -1,6 +1,6 @@
import { colorToFill, parseColor } from '#core/color'
import { TRANSPARENT } from '#core/constants'
import type { GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
import type { Fill, GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
import type { Color, JsonObject } from '#core/types'
const WEIGHT_MAP: Record<string, number> = {
@ -159,6 +159,24 @@ function applyFillSizing(
}
}
function isFill(value: unknown): value is Fill {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
'color' in value &&
'visible' in value
)
}
function isFillValue(value: unknown): value is string | Color | Fill {
return typeof value === 'string' || isColor(value) || isFill(value)
}
function fillFromValue(value: string | Color | Fill): Fill {
return isFill(value) ? structuredClone(value) : colorToFill(value)
}
function isColor(value: unknown): value is Color {
return (
value !== null &&
@ -171,8 +189,14 @@ function isColor(value: unknown): value is Color {
}
function applyFillOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (Array.isArray(props.fills)) {
const fills = props.fills.filter(isFillValue).map(fillFromValue)
if (fills.length > 0) o.fills = fills
return
}
const bg = props.bg ?? props.fill ?? props.background ?? props.backgroundColor
if (typeof bg === 'string' || isColor(bg)) o.fills = [colorToFill(bg)]
if (isFillValue(bg)) o.fills = [fillFromValue(bg)]
}
function applyStrokeOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {

View file

@ -4,6 +4,14 @@ import type { RenderOptions as RenderJSXOptions } from '#core/design-jsx/types'
import type { SceneGraph } from '#core/scene-graph'
import * as React from './mini-react'
import {
angularGradient,
diamondGradient,
gradient,
linearGradient,
radialGradient,
solid
} from './paints'
import { renderTree, type RenderResult } from './renderer'
import { isTreeNode, resolveToTree, type TreeNode } from './tree'
@ -50,6 +58,7 @@ const SUPPORTED_PROPS = new Set([
'pl',
'bg',
'fill',
'fills',
'background',
'backgroundColor',
'stroke',
@ -150,6 +159,12 @@ 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 solid = __helpers.solid
const gradient = __helpers.gradient
const linearGradient = __helpers.linearGradient
const radialGradient = __helpers.radialGradient
const angularGradient = __helpers.angularGradient
const diamondGradient = __helpers.diamondGradient
const __varSymbol = Symbol.for('open-pencil.variable')
const designVar = (def, value) => typeof def === 'string'
? ({ [__varSymbol]: true, id: def, name: def, value })
@ -173,7 +188,14 @@ export function buildComponent(jsxString: string): React.ComponentType {
}
// eslint-disable-next-line typescript-eslint/no-implied-eval -- sucrase output must be evaluated at runtime
return new Function('React', code)(React) as React.ComponentType
return new Function('React', '__helpers', code)(React, {
angularGradient,
diamondGradient,
gradient,
linearGradient,
radialGradient,
solid
}) as React.ComponentType
}
/**

View file

@ -142,6 +142,15 @@ function preparePropsForRender(
const props = { ...source }
const bindings: Record<string, string> = {}
if (Array.isArray(props.fills)) {
props.fills = props.fills.map((value, index) => {
if (!isVariable(value)) return value
const variableId = resolveVariableId(graph, value)
if (variableId) bindings[`fills/${index}/color`] = variableId
return variableFallback(graph, value) ?? value
})
}
for (const key of ['bg', 'fill', 'background', 'backgroundColor']) {
bindVariableProp(graph, props, bindings, key, 'fills/0/color')
}

View file

@ -1,3 +1,4 @@
import type { Fill } from '#core/scene-graph'
import type { Color } from '#core/types'
import type { DesignVariable } from './vars'
@ -85,7 +86,7 @@ export function node(
return { type, props: rest, children: processed }
}
export type PaintProp = string | Color | DesignVariable
export type PaintProp = string | Color | Fill | DesignVariable
export type StyleProps = {
flex?: 'row' | 'col' | 'column'
@ -121,6 +122,7 @@ export type StyleProps = {
bg?: PaintProp
fill?: PaintProp
fills?: PaintProp[]
stroke?: PaintProp
strokeWidth?: number
strokeAlign?: 'inside' | 'outside' | 'center'

View file

@ -309,6 +309,12 @@ export {
renderJSX,
renderTreeNode,
buildComponent,
angularGradient,
diamondGradient,
gradient,
linearGradient,
radialGradient,
solid,
defineVars,
designVar,
isVariable,
@ -340,6 +346,10 @@ export {
type TextProps,
type StyleProps,
type PaintProp,
type GradientPaintOptions,
type PaintColor,
type PaintStop,
type SolidPaintOptions,
type DesignVariable,
type VarDef,
type RenderResult,

View file

@ -16,7 +16,9 @@ import {
ComponentSet,
Instance,
defineVars,
designVar
designVar,
linearGradient,
solid
} from '@open-pencil/core'
import { expectDefined, getNodeOrThrow, childIdAt } from '#tests/helpers/assert'
@ -38,6 +40,43 @@ describe('renderTree', () => {
expect(expectDefined(node.fills[0], 'first fill').type).toBe('SOLID')
})
it('renders structured fill helpers', async () => {
const g = makeSceneGraph()
const result = await renderTree(
g,
Frame({
name: 'Paints',
w: 200,
h: 100,
fills: [
solid('#112233'),
linearGradient([
['#ffffff', 0],
['rgba(0, 0, 0, 0)', 1]
])
]
})
)
const node = getNodeOrThrow(g, result.id)
expect(node.fills).toHaveLength(2)
expect(node.fills[0]?.type).toBe('SOLID')
expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR')
expect(node.fills[1]?.gradientStops).toHaveLength(2)
})
it('renders structured fill helpers from JSX strings', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame name="Paints" w={200} h={100} fills={[solid('#112233'), linearGradient([['#fff', 0], ['#0000', 1]])]} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.fills).toHaveLength(2)
expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR')
})
it('renders text node with content', async () => {
const g = makeSceneGraph()
const tree = Text({