diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d133b990..f89222931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/design-jsx/effects.ts b/packages/core/src/design-jsx/effects.ts index 1f94635af..ba5b06ef2 100644 --- a/packages/core/src/design-jsx/effects.ts +++ b/packages/core/src/design-jsx/effects.ts @@ -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 } diff --git a/packages/core/src/design-jsx/helpers.ts b/packages/core/src/design-jsx/helpers.ts new file mode 100644 index 000000000..22de562f3 --- /dev/null +++ b/packages/core/src/design-jsx/helpers.ts @@ -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 = Record + +const SHADOW_OPTIONS = { + color: true, + x: true, + y: true, + offset: true, + radius: true, + spread: true, + visible: true, + blendMode: true, + showShadowBehindNode: true +} satisfies OptionKeys + +const BLUR_OPTIONS = { + radius: true, + visible: true +} satisfies OptionKeys + +const SOLID_OPTIONS = { + opacity: true, + visible: true, + blendMode: true +} satisfies OptionKeys + +const GRADIENT_OPTIONS = { + ...SOLID_OPTIONS, + transform: true +} satisfies OptionKeys + +/** Option names people reach for that the helpers spell as Figma's effects do. */ +const FIGMA_OPTION_NAMES: Record = { 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( + 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) + } +} diff --git a/packages/core/src/design-jsx/reference/authoring.md b/packages/core/src/design-jsx/reference/authoring.md index 3b421fe26..5fda82eda 100644 --- a/packages/core/src/design-jsx/reference/authoring.md +++ b/packages/core/src/design-jsx/reference/authoring.md @@ -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. diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index 4c3d6b2c4..5c1c7cfc7 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -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 { - 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[] = [] diff --git a/packages/docs/reference/design-authoring.md b/packages/docs/reference/design-authoring.md index 36395a9f6..d4c24f353 100644 --- a/packages/docs/reference/design-authoring.md +++ b/packages/docs/reference/design-authoring.md @@ -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. diff --git a/skills/open-pencil/references/design-authoring.md b/skills/open-pencil/references/design-authoring.md index 36395a9f6..d4c24f353 100644 --- a/skills/open-pencil/references/design-authoring.md +++ b/skills/open-pencil/references/design-authoring.md @@ -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. diff --git a/tests/engine/render/jsx/render-tree.test.ts b/tests/engine/render/jsx/render-tree.test.ts index 879cc2ce2..e7ca45db6 100644 --- a/tests/engine/render/jsx/render-tree.test.ts +++ b/tests/engine/render/jsx/render-tree.test.ts @@ -567,6 +567,57 @@ describe('renderJSX (string → scene graph)', () => { expect(result.warnings).toEqual(['Unsupported prop "mt" on is ignored.']) }) + it('points blur in effect helpers at radius, the name Figma uses', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX( + g, + `` + ) + 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, + `` + ) + + 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, + `` + ) + + 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, + `` + ) + + expect(result.warnings).toBeUndefined() + }) + it('accepts CSS-style layout aliases', async () => { const g = makeSceneGraph() const [result] = await renderJSX(