From cc1167eed546ca82ffe1f1bbd2ce0db8370c7f68 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 2 Jun 2026 11:42:29 +0300 Subject: [PATCH] feat(design-jsx): support structured paints --- CHANGELOG.md | 1 + packages/core/src/design-jsx/index.ts | 13 +++ packages/core/src/design-jsx/paints.ts | 82 +++++++++++++++++++ .../core/src/design-jsx/props-overrides.ts | 28 ++++++- packages/core/src/design-jsx/render.ts | 24 +++++- packages/core/src/design-jsx/renderer.ts | 9 ++ packages/core/src/design-jsx/tree.ts | 4 +- packages/core/src/index.ts | 10 +++ tests/engine/render/jsx/render-tree.test.ts | 41 +++++++++- 9 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/design-jsx/paints.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1700df0e6..3c990e2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/design-jsx/index.ts b/packages/core/src/design-jsx/index.ts index cc9d82b14..095955545 100644 --- a/packages/core/src/design-jsx/index.ts +++ b/packages/core/src/design-jsx/index.ts @@ -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' diff --git a/packages/core/src/design-jsx/paints.ts b/packages/core/src/design-jsx/paints.ts new file mode 100644 index 000000000..e98449ef2 --- /dev/null +++ b/packages/core/src/design-jsx/paints.ts @@ -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) +} diff --git a/packages/core/src/design-jsx/props-overrides.ts b/packages/core/src/design-jsx/props-overrides.ts index 3e08a3a21..42f4be588 100644 --- a/packages/core/src/design-jsx/props-overrides.ts +++ b/packages/core/src/design-jsx/props-overrides.ts @@ -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 = { @@ -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, o: Partial): 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, o: Partial): void { diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index 7b36abf0d..03b185bc1 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -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 } /** diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index aac30340e..8b0362c28 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -142,6 +142,15 @@ function preparePropsForRender( const props = { ...source } const bindings: Record = {} + 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') } diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index 3fa93539c..1cc8ea416 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -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' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e39ffa9b4..83b7d7595 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/tests/engine/render/jsx/render-tree.test.ts b/tests/engine/render/jsx/render-tree.test.ts index 106c9606f..665fdcc8e 100644 --- a/tests/engine/render/jsx/render-tree.test.ts +++ b/tests/engine/render/jsx/render-tree.test.ts @@ -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, + `` + ) + 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({