From 8983c11f45f2cc0bfefa6044a9f61e85a713e406 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 2 Jun 2026 13:38:29 +0300 Subject: [PATCH] feat(design-jsx): support structured effects --- CHANGELOG.md | 1 + packages/core/src/design-jsx/effects.ts | 77 +++++++++++++++++++ packages/core/src/design-jsx/index.ts | 11 +++ .../core/src/design-jsx/props-overrides.ts | 17 +++- packages/core/src/design-jsx/render.ts | 12 +++ packages/core/src/design-jsx/tree.ts | 3 +- packages/core/src/index.ts | 8 ++ tests/engine/render/jsx/render-tree.test.ts | 40 ++++++++++ 8 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/design-jsx/effects.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c990e2b4..1b14946ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,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 structured design JSX effect helpers for shadows and blur effects. - 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/effects.ts b/packages/core/src/design-jsx/effects.ts new file mode 100644 index 000000000..0036452c1 --- /dev/null +++ b/packages/core/src/design-jsx/effects.ts @@ -0,0 +1,77 @@ +import { parseColor } from '#core/color' +import { TRANSPARENT } from '#core/constants' +import type { BlendMode, Effect } from '#core/scene-graph' +import type { Color, Vector } from '#core/types' + +export type EffectColor = string | Color + +export interface ShadowEffectOptions { + color?: EffectColor + x?: number + y?: number + offset?: Vector + radius?: number + spread?: number + visible?: boolean + blendMode?: BlendMode + showShadowBehindNode?: boolean +} + +export interface BlurEffectOptions { + radius?: number + visible?: boolean +} + +function toColor(color: EffectColor | undefined): Color { + if (color === undefined) return { ...TRANSPARENT } + return typeof color === 'string' ? parseColor(color) : color +} + +function shadowEffect(type: 'DROP_SHADOW' | 'INNER_SHADOW', options: ShadowEffectOptions): Effect { + return { + type, + color: toColor(options.color ?? 'rgba(0, 0, 0, 0.25)'), + offset: options.offset ?? { x: options.x ?? 0, y: options.y ?? 4 }, + radius: options.radius ?? 8, + spread: options.spread ?? 0, + visible: options.visible ?? true, + blendMode: options.blendMode, + showShadowBehindNode: options.showShadowBehindNode + } +} + +function blurEffect( + type: 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR', + radiusOrOptions: number | BlurEffectOptions = 8 +): Effect { + const options = + typeof radiusOrOptions === 'number' ? { radius: radiusOrOptions } : radiusOrOptions + return { + type, + color: { ...TRANSPARENT }, + offset: { x: 0, y: 0 }, + radius: options.radius ?? 8, + spread: 0, + visible: options.visible ?? true + } +} + +export function dropShadow(options: ShadowEffectOptions = {}): Effect { + return shadowEffect('DROP_SHADOW', options) +} + +export function innerShadow(options: ShadowEffectOptions = {}): Effect { + return shadowEffect('INNER_SHADOW', options) +} + +export function layerBlur(radiusOrOptions?: number | BlurEffectOptions): Effect { + return blurEffect('LAYER_BLUR', radiusOrOptions) +} + +export function backgroundBlur(radiusOrOptions?: number | BlurEffectOptions): Effect { + return blurEffect('BACKGROUND_BLUR', radiusOrOptions) +} + +export function foregroundBlur(radiusOrOptions?: number | BlurEffectOptions): Effect { + return blurEffect('FOREGROUND_BLUR', radiusOrOptions) +} diff --git a/packages/core/src/design-jsx/index.ts b/packages/core/src/design-jsx/index.ts index 095955545..dd72c7b55 100644 --- a/packages/core/src/design-jsx/index.ts +++ b/packages/core/src/design-jsx/index.ts @@ -31,6 +31,17 @@ export { export { renderTree, type RenderResult } from './renderer' +export { + backgroundBlur, + dropShadow, + foregroundBlur, + innerShadow, + layerBlur, + type BlurEffectOptions, + type EffectColor, + type ShadowEffectOptions +} from './effects' + export { angularGradient, diamondGradient, diff --git a/packages/core/src/design-jsx/props-overrides.ts b/packages/core/src/design-jsx/props-overrides.ts index 42f4be588..65546ffa7 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 { Fill, GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph' +import type { Effect, Fill, GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph' import type { Color, JsonObject } from '#core/types' const WEIGHT_MAP: Record = { @@ -519,7 +519,22 @@ 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, o: Partial): void { + if (Array.isArray(props.effects)) { + const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect)) + if (effects.length > 0) o.effects = effects + } + 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 diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index 03b185bc1..ea2908cfb 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -3,6 +3,7 @@ import { transform } from 'sucrase' import type { RenderOptions as RenderJSXOptions } from '#core/design-jsx/types' import type { SceneGraph } from '#core/scene-graph' +import { backgroundBlur, dropShadow, foregroundBlur, innerShadow, layerBlur } from './effects' import * as React from './mini-react' import { angularGradient, @@ -83,6 +84,7 @@ const SUPPORTED_PROPS = new Set([ 'overflow', 'shadow', 'blur', + 'effects', 'size', 'fontSize', 'font', @@ -159,6 +161,11 @@ 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 dropShadow = __helpers.dropShadow + const innerShadow = __helpers.innerShadow + const layerBlur = __helpers.layerBlur + const backgroundBlur = __helpers.backgroundBlur + const foregroundBlur = __helpers.foregroundBlur const solid = __helpers.solid const gradient = __helpers.gradient const linearGradient = __helpers.linearGradient @@ -189,6 +196,11 @@ 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', '__helpers', code)(React, { + backgroundBlur, + dropShadow, + foregroundBlur, + innerShadow, + layerBlur, angularGradient, diamondGradient, gradient, diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index 1cc8ea416..08d5fb4a2 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -1,4 +1,4 @@ -import type { Fill } from '#core/scene-graph' +import type { Effect, Fill } from '#core/scene-graph' import type { Color } from '#core/types' import type { DesignVariable } from './vars' @@ -140,6 +140,7 @@ export type StyleProps = { overflow?: 'hidden' | 'visible' shadow?: string blur?: number + effects?: Effect[] size?: number fontSize?: number diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83b7d7595..db06545fc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -309,6 +309,11 @@ export { renderJSX, renderTreeNode, buildComponent, + backgroundBlur, + dropShadow, + foregroundBlur, + innerShadow, + layerBlur, angularGradient, diamondGradient, gradient, @@ -346,6 +351,9 @@ export { type TextProps, type StyleProps, type PaintProp, + type BlurEffectOptions, + type EffectColor, + type ShadowEffectOptions, type GradientPaintOptions, type PaintColor, type PaintStop, diff --git a/tests/engine/render/jsx/render-tree.test.ts b/tests/engine/render/jsx/render-tree.test.ts index 665fdcc8e..744790420 100644 --- a/tests/engine/render/jsx/render-tree.test.ts +++ b/tests/engine/render/jsx/render-tree.test.ts @@ -17,6 +17,9 @@ import { Instance, defineVars, designVar, + dropShadow, + innerShadow, + layerBlur, linearGradient, solid } from '@open-pencil/core' @@ -77,6 +80,43 @@ describe('renderTree', () => { expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR') }) + it('renders structured effect helpers', async () => { + const g = makeSceneGraph() + const result = await renderTree( + g, + Frame({ + name: 'Effects', + w: 200, + h: 100, + effects: [ + dropShadow({ x: 0, y: 8, radius: 16 }), + innerShadow({ color: '#ff000080' }), + layerBlur(4) + ] + }) + ) + const node = getNodeOrThrow(g, result.id) + + expect(node.effects).toHaveLength(3) + expect(node.effects[0]?.type).toBe('DROP_SHADOW') + expect(node.effects[0]?.offset.y).toBe(8) + expect(node.effects[1]?.type).toBe('INNER_SHADOW') + expect(node.effects[2]?.type).toBe('LAYER_BLUR') + }) + + it('renders structured effect helpers from JSX strings', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX( + g, + `` + ) + const node = getNodeOrThrow(g, result.id) + + expect(node.effects).toHaveLength(2) + expect(node.effects[0]?.type).toBe('DROP_SHADOW') + expect(node.effects[1]?.type).toBe('BACKGROUND_BLUR') + }) + it('renders text node with content', async () => { const g = makeSceneGraph() const tree = Text({