fix(design-jsx): warn about unsupported paint and effect helper options (#762)

* fix(design-jsx): accept blur in effect helpers and warn about unknown options

dropShadow({ blur: 12 }) silently used the default radius, although the shadow shorthand and the blur prop both call that value the blur, and any misspelled option was dropped without a word. The shadow and blur helpers now take blur as the radius, and renderJSX reports any other option they ignore, next to the existing unsupported-prop warnings.

Fixes #736

* refactor(design-jsx): check options for every paint and effect helper

The option check covered only effect helpers, and its key lists repeated
the option types by hand, so a new option could turn into a false
warning. One wrapper in `design-jsx/helpers.ts` now checks every paint
and effect helper that evaluated JSX can call, with key lists typed
against their option interfaces. Only plain objects are checked, and
repeated warnings are collapsed once. The authoring reference documents
the `blur` alias and the warnings.

* docs(design-jsx): scope helper option warnings to rendered JSX

* fix(design-jsx): name effect radius as Figma does and hint at it for blur

Accepting both `radius` and `blur` gave effect helpers two names for one
value, and when both were set `blur` was dropped without a warning.
Figma's effects only have `radius`, so the helpers take `radius` alone
and `blur` now warns with a pointer to it. The default radius is named
once instead of repeated.

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
mrhard9090 2026-09-30 08:59:45 +03:00 committed by GitHub
parent 00361313d1
commit 4faf20bab8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 177 additions and 31 deletions

View file

@ -25,6 +25,7 @@
### Fixed
- Warn about options the paint and effect helpers ignore when rendering JSX instead of dropping them silently, and point `blur` in effect helpers at `radius`, the name Figma uses (#736).
- Size groups and boolean operations made through the AI and MCP `group_nodes` and `boolean_*` tools to what they contain, as the editor's commands already do, instead of a default 100 × 100 box or the first operand's box (#738).
- Keep a layer where it is drawn when it moves into or out of a rotated or flipped parent, instead of shifting it and leaving it at its old angle (#737).
- Size auto-width text from `.pen` files to its content in CLI exports, instead of a 10000px placeholder that stretched hugging frames in HTML and Storybook output, and keep narrow widths a `.pen` file sets explicitly instead of widening multi-character text.

View file

@ -23,6 +23,9 @@ export interface BlurEffectOptions {
visible?: boolean
}
/** Radius of a shadow or blur that does not set one. */
const DEFAULT_EFFECT_RADIUS = 8
function toColor(color: EffectColor | undefined): Color {
if (color === undefined) return { ...TRANSPARENT }
return typeof color === 'string' ? parseColor(color) : color
@ -33,7 +36,7 @@ function shadowEffect(type: 'DROP_SHADOW' | 'INNER_SHADOW', options: ShadowEffec
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,
radius: options.radius ?? DEFAULT_EFFECT_RADIUS,
spread: options.spread ?? 0,
visible: options.visible ?? true,
blendMode: options.blendMode,
@ -43,7 +46,7 @@ function shadowEffect(type: 'DROP_SHADOW' | 'INNER_SHADOW', options: ShadowEffec
function blurEffect(
type: 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR',
radiusOrOptions: number | BlurEffectOptions = 8
radiusOrOptions: number | BlurEffectOptions = {}
): Effect {
const options =
typeof radiusOrOptions === 'number' ? { radius: radiusOrOptions } : radiusOrOptions
@ -51,7 +54,7 @@ function blurEffect(
type,
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },
radius: options.radius ?? 8,
radius: options.radius ?? DEFAULT_EFFECT_RADIUS,
spread: 0,
visible: options.visible ?? true
}

View file

@ -0,0 +1,105 @@
import { difference } from 'es-toolkit/array'
import { isPlainObject } from 'es-toolkit/predicate'
import {
backgroundBlur,
dropShadow,
foregroundBlur,
innerShadow,
layerBlur,
type BlurEffectOptions,
type ShadowEffectOptions
} from './effects'
import {
angularGradient,
diamondGradient,
gradient,
linearGradient,
radialGradient,
solid,
type GradientPaintOptions,
type SolidPaintOptions
} from './paints'
/** Every option a helper reads; `satisfies` keeps the list equal to the options type. */
type OptionKeys<Options> = Record<keyof Options, true>
const SHADOW_OPTIONS = {
color: true,
x: true,
y: true,
offset: true,
radius: true,
spread: true,
visible: true,
blendMode: true,
showShadowBehindNode: true
} satisfies OptionKeys<ShadowEffectOptions>
const BLUR_OPTIONS = {
radius: true,
visible: true
} satisfies OptionKeys<BlurEffectOptions>
const SOLID_OPTIONS = {
opacity: true,
visible: true,
blendMode: true
} satisfies OptionKeys<SolidPaintOptions>
const GRADIENT_OPTIONS = {
...SOLID_OPTIONS,
transform: true
} satisfies OptionKeys<GradientPaintOptions>
/** Option names people reach for that the helpers spell as Figma's effects do. */
const FIGMA_OPTION_NAMES: Record<string, string> = { blur: 'radius' }
function unsupportedOptionWarning(name: string, key: string, known: string[]): string {
const figmaName = FIGMA_OPTION_NAMES[key]
const hint =
figmaName && known.includes(figmaName)
? `Use "${figmaName}", the name Figma uses.`
: `Supported options: ${known.join(', ')}.`
return `Unsupported option "${key}" in ${name}() is ignored. ${hint}`
}
/**
* Wrap a helper so each option it ignores adds a warning instead of vanishing, which is
* what happens to a misspelled option. `optionsAt` is the argument holding the options.
*/
function checked<Args extends unknown[], Result>(
warnings: string[],
name: string,
helper: (...args: Args) => Result,
options: object,
optionsAt: number
): (...args: Args) => Result {
const known = Object.keys(options)
return (...args) => {
const passed = args[optionsAt]
if (isPlainObject(passed)) {
for (const key of difference(Object.keys(passed), known)) {
warnings.push(unsupportedOptionWarning(name, key, known))
}
}
return helper(...args)
}
}
/** The paint and effect helpers evaluated Design JSX can call, reporting ignored options. */
export function designJSXHelpers(warnings: string[]) {
return {
dropShadow: checked(warnings, 'dropShadow', dropShadow, SHADOW_OPTIONS, 0),
innerShadow: checked(warnings, 'innerShadow', innerShadow, SHADOW_OPTIONS, 0),
layerBlur: checked(warnings, 'layerBlur', layerBlur, BLUR_OPTIONS, 0),
backgroundBlur: checked(warnings, 'backgroundBlur', backgroundBlur, BLUR_OPTIONS, 0),
foregroundBlur: checked(warnings, 'foregroundBlur', foregroundBlur, BLUR_OPTIONS, 0),
solid: checked(warnings, 'solid', solid, SOLID_OPTIONS, 1),
gradient: checked(warnings, 'gradient', gradient, GRADIENT_OPTIONS, 2),
linearGradient: checked(warnings, 'linearGradient', linearGradient, GRADIENT_OPTIONS, 1),
radialGradient: checked(warnings, 'radialGradient', radialGradient, GRADIENT_OPTIONS, 1),
angularGradient: checked(warnings, 'angularGradient', angularGradient, GRADIENT_OPTIONS, 1),
diamondGradient: checked(warnings, 'diamondGradient', diamondGradient, GRADIENT_OPTIONS, 1)
}
}

View file

@ -18,7 +18,7 @@ This reference describes scene creation, not React DOM output. Use the `render`
- `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`.
- `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.
- `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.
- `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

@ -1,3 +1,4 @@
import { uniq } from 'es-toolkit/array'
import { transform } from 'sucrase'
import type { SceneGraph } from '@open-pencil/scene-graph'
@ -5,16 +6,8 @@ import type { SceneGraph } from '@open-pencil/scene-graph'
import { DESIGN_JSX_SUPPORTED_PROPERTIES } from '#core/design-jsx/schema'
import type { RenderOptions as RenderJSXOptions } from '#core/design-jsx/types'
import { backgroundBlur, dropShadow, foregroundBlur, innerShadow, layerBlur } from './effects'
import { designJSXHelpers } from './helpers'
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'
@ -52,7 +45,7 @@ function collectUnsupportedPropWarnings(tree: TreeNode, warnings: string[]): voi
}
}
export function buildComponent(jsxString: string): React.ComponentType {
export function buildComponent(jsxString: string, warnings: string[] = []): React.ComponentType {
const trimmed = stripHTMLComments(jsxString).trim()
const aliases = `
@ -98,19 +91,10 @@ 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,
linearGradient,
radialGradient,
solid
}) as React.ComponentType
return new Function('React', '__helpers', code)(
React,
designJSXHelpers(warnings)
) as React.ComponentType
}
/**
@ -122,7 +106,8 @@ export async function renderJSX(
jsxString: string,
options?: RenderJSXOptions
): Promise<RenderResult[]> {
const Component = buildComponent(jsxString)
const helperWarnings: string[] = []
const Component = buildComponent(jsxString, helperWarnings)
const element = React.createElement(Component, null)
const tree = resolveToTree(element)
@ -130,7 +115,8 @@ export async function renderJSX(
throw new Error('JSX must return a Figma element (Frame, Text, etc)')
}
const warnings = unsupportedPropWarnings(tree)
// A helper called in a loop reports each ignored option once.
const warnings = uniq([...unsupportedPropWarnings(tree), ...helperWarnings])
if (tree.type === '' && tree.children.length > 0) {
const results: RenderResult[] = []

View file

@ -20,7 +20,7 @@ This reference describes scene creation, not React DOM output. Use the `render`
- `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`.
- `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.
- `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.
- `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

@ -20,7 +20,7 @@ This reference describes scene creation, not React DOM output. Use the `render`
- `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`.
- `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.
- `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.
- `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

@ -567,6 +567,57 @@ describe('renderJSX (string → scene graph)', () => {
expect(result.warnings).toEqual(['Unsupported prop "mt" on <frame> is ignored.'])
})
it('points blur in effect helpers at radius, the name Figma uses', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} effects={[dropShadow({ x: 4, y: 4, blur: 12 }), layerBlur({ blur: 3 })]} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.effects.map((effect) => effect.radius)).toEqual([8, 8])
expect(result.warnings).toEqual([
'Unsupported option "blur" in dropShadow() is ignored. Use "radius", the name Figma uses.',
'Unsupported option "blur" in layerBlur() is ignored. Use "radius", the name Figma uses.'
])
})
it('warns about effect helper options it ignores', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} effects={[dropShadow({ colour: '#FF0000' }), dropShadow({ colour: '#00FF00' }), backgroundBlur({ amount: 4 })]} />`
)
expect(result.warnings).toEqual([
'Unsupported option "colour" in dropShadow() is ignored. Supported options: color, x, y, offset, radius, spread, visible, blendMode, showShadowBehindNode.',
'Unsupported option "amount" in backgroundBlur() is ignored. Supported options: radius, visible.'
])
})
it('warns about paint helper options it ignores', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} fills={[solid('#FF0000', { opactiy: 0.5 }), linearGradient([['#000', 0], ['#FFF', 1]], { angle: 90 })]} />`
)
expect(result.warnings).toEqual([
'Unsupported option "opactiy" in solid() is ignored. Supported options: opacity, visible, blendMode.',
'Unsupported option "angle" in linearGradient() is ignored. Supported options: opacity, visible, blendMode, transform.'
])
})
it('only checks options objects passed to helpers', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} effects={[layerBlur(4)]} fills={[solid('#FF0000')]} />`
)
expect(result.warnings).toBeUndefined()
})
it('accepts CSS-style layout aliases', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(