fix(figma-api): validate effects like Figma (#794)

* build(core): import Markdown with unplugin-raw

Core inlined ?raw imports with a hand-written Rolldown plugin that also turned plain .md imports into strings, which nothing in Core uses. unplugin-raw already does this for the Vue SDK and design-jsx; use it here too and drop the unused *.md module declaration.

* refactor(pen): use the scene-graph color parser

pen/src/color.ts duplicated parseColor from @open-pencil/scene-graph/color line for line. Import it instead, which also drops pen's direct culori dependency.

* fix(figma-api): validate effects like Figma

The effects setter stored whatever a script passed, so scripts that Figma rejects ran here and malformed effects reached rendering and .fig export. Validate against Figma's effect shapes with Valibot, recorded from live Figma: strict objects, required shadow fields, radius >= 0, RGBA channels within 0..1, and no shadow fields on blurs. The getter now returns Figma's shape so node.effects = node.effects keeps working.

Closes #786

* fix(figma-api): reject infinite numbers in effects

Figma rejects Infinity in every effect number ("Number must be finite"), but v.number() accepts it, so infinite radii, offsets, and spreads reached the scene graph.

* fix(figma-api): store PASS_THROUGH shadow blend as NORMAL

PASS_THROUGH is a layer blend mode. Live Figma accepts it on drop and inner shadows but reads NORMAL back, so do the same instead of storing it.
This commit is contained in:
Danila Poyarkov 2026-09-30 21:18:20 +04:00 committed by GitHub
parent 8404cee664
commit 5ba5b4aad5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 345 additions and 80 deletions

View file

@ -28,6 +28,7 @@
### Fixed
- Type `parameterConsumptionMap`, `propRefValue`, and `expressionValue` in the Kiwi `NodeChange` codec, which `fig.kiwi` declares but the TypeScript definitions omitted, so reading them no longer needs a cast.
- Reject malformed effects assigned to `node.effects` in the plugin API with an error naming the invalid field, as Figma does, instead of storing them. `node.effects` now reads back in Figma's shape: layer blurs are `LAYER_BLUR` with `blurType`, and blurs no longer carry shadow fields (#786).
- Render fragments (`<>…</>`) nested inside other elements in JSX from the AI and MCP `render` tool, which previously failed with `Unknown element: <>`.
- Judge text contrast in the AI and MCP `describe` tool by its WCAG 2 ratio (4.5:1, or 3:1 for large text), the same ratio the `color-contrast` lint rule computes. It no longer reports passing dark text on mid-tone backgrounds as "dark on dark", now reports low-contrast light text, measures translucent and faded text as it is drawn, skips text whose color is bound to a variable, and says the ratio and the threshold it missed (#735).
- 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).

View file

@ -231,6 +231,7 @@
"@types/opentype.js": "^1.3.10",
"@types/rbush": "^4.0.0",
"typescript": "~5.8.3",
"unplugin-raw": "^0.7.0",
},
},
"packages/design-jsx": {
@ -368,12 +369,8 @@
"packages/pen": {
"name": "@open-pencil/pen",
"version": "0.15.1",
"dependencies": {
"culori": "^4.0.2",
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/culori": "^4.0.1",
"tsdown": "^0.22.14",
"typescript": "~5.8.3",
},

View file

@ -208,6 +208,7 @@
"@types/diff": "^8.0.0",
"@types/opentype.js": "^1.3.10",
"@types/rbush": "^4.0.0",
"typescript": "~5.8.3"
"typescript": "~5.8.3",
"unplugin-raw": "^0.7.0"
}
}

View file

@ -1,6 +1,6 @@
import type { Effect, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import type { Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { normalizeColor } from '@open-pencil/scene-graph/color'
import { copyEffects, copyFills, copyStrokes } from '@open-pencil/scene-graph/copy'
import { copyFills, copyStrokes } from '@open-pencil/scene-graph/copy'
import {
raw,
@ -8,6 +8,7 @@ import {
type NodeProxyInternals,
type ProxyThis
} from '#core/figma-api/accessor-utils'
import { parseFigmaEffects, toFigmaEffect, type FigmaEffect } from '#core/figma-api/effects'
export function installVisualNodeProxyAccessors(
prototype: object,
@ -43,13 +44,11 @@ export function installVisualNodeProxyAccessors(
}
},
effects: {
get(this: ProxyThis): readonly Effect[] {
return Object.freeze(copyEffects(raw(this, internals).effects))
get(this: ProxyThis): readonly FigmaEffect[] {
return Object.freeze(raw(this, internals).effects.map(toFigmaEffect))
},
set(this: ProxyThis, value: readonly Effect[]) {
updateNode(this, internals, {
effects: value.map((effect) => ({ ...effect, color: normalizeColor(effect.color) }))
})
set(this: ProxyThis, value: readonly FigmaEffect[]) {
updateNode(this, internals, { effects: parseFigmaEffects(value) })
}
},
opacity: {

View file

@ -89,3 +89,7 @@ type InstancePropertySurfaceMatch = Expect<
>
const _instancePropertySurfaceMatch: InstancePropertySurfaceMatch = true
// `Effect` here is Figma's plugin-typings union; OpenPencil reads and writes a subset of it.
type EffectShapeMatch = Expect<Extends<FigmaNodeProxy['effects'][number], Effect>>
const _effectShapeMatch: EffectShapeMatch = true

View file

@ -0,0 +1,169 @@
import * as v from 'valibot'
import type { BlendMode, Effect } from '@open-pencil/scene-graph'
import { TRANSPARENT } from '#core/constants'
const BLEND_MODE_KEYS = {
NORMAL: true,
DARKEN: true,
MULTIPLY: true,
COLOR_BURN: true,
LIGHTEN: true,
SCREEN: true,
COLOR_DODGE: true,
OVERLAY: true,
SOFT_LIGHT: true,
HARD_LIGHT: true,
DIFFERENCE: true,
EXCLUSION: true,
HUE: true,
SATURATION: true,
COLOR: true,
LUMINOSITY: true,
PASS_THROUGH: true
} satisfies Record<BlendMode, true>
const BLEND_MODES = Object.keys(BLEND_MODE_KEYS) as BlendMode[]
/** Effect kinds Figma's plugin API accepts that OpenPencil does not model yet. */
const UNSUPPORTED_EFFECT_TYPES = new Set(['NOISE', 'TEXTURE', 'GLASS', 'SHADER'])
// Figma rejects Infinity as well as NaN ("Number must be finite").
const finite = v.pipe(v.number(), v.finite())
const unit = v.pipe(finite, v.minValue(0), v.maxValue(1))
const nonNegative = v.pipe(finite, v.minValue(0))
const color = v.strictObject({ r: unit, g: unit, b: unit, a: unit })
const vector = v.strictObject({ x: finite, y: finite })
// Variable bindings on effects are not supported; Figma reports an empty object.
const boundVariables = v.optional(v.strictObject({}))
const shadowEntries = {
color,
offset: vector,
radius: nonNegative,
spread: v.optional(finite),
visible: v.boolean(),
blendMode: v.picklist(BLEND_MODES),
boundVariables
}
const effectSchema = v.variant('type', [
v.strictObject({
type: v.literal('DROP_SHADOW'),
...shadowEntries,
showShadowBehindNode: v.optional(v.boolean())
}),
v.strictObject({ type: v.literal('INNER_SHADOW'), ...shadowEntries }),
v.strictObject({
type: v.picklist(['LAYER_BLUR', 'BACKGROUND_BLUR']),
radius: nonNegative,
visible: v.boolean(),
blurType: v.optional(v.literal('NORMAL'), 'NORMAL'),
boundVariables
})
])
const effectsSchema = v.array(effectSchema)
/** An effect as Figma's plugin API reads and writes it (`Effect` in `@figma/plugin-typings`). */
export type FigmaEffect = v.InferOutput<typeof effectSchema>
function issuePath(issue: v.BaseIssue<unknown>): string {
return (issue.path ?? [])
.map((item) => (typeof item.key === 'number' ? `[${item.key}]` : `.${String(item.key)}`))
.join('')
}
function unsupportedEffect(value: unknown): string | null {
if (!Array.isArray(value)) return null
for (const [index, effect] of value.entries()) {
const type: unknown = v.is(v.object({ type: v.unknown() }), effect) ? effect.type : undefined
if (typeof type === 'string' && UNSUPPORTED_EFFECT_TYPES.has(type)) {
return `${type} effects are not supported at [${index}].type`
}
if (v.is(v.object({ blurType: v.literal('PROGRESSIVE') }), effect)) {
return `Progressive blur is not supported at [${index}].blurType`
}
}
return null
}
/**
* Check `value` against Figma's effect shapes and convert it to scene effects.
* Throws with the first problem, as Figma's `effects` setter does.
*/
export function parseFigmaEffects(value: unknown): Effect[] {
const unsupported = unsupportedEffect(value)
if (unsupported) throw new Error(`Property "effects" failed validation: ${unsupported}`)
const result = v.safeParse(effectsSchema, value)
if (!result.success) {
const [issue] = result.issues
throw new Error(
`Property "effects" failed validation: ${issue.message} at ${issuePath(issue) || 'effects'}`
)
}
return result.output.map(toSceneEffect)
}
function toSceneEffect(effect: FigmaEffect): Effect {
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
return {
type: effect.type,
color: { ...effect.color },
offset: { ...effect.offset },
radius: effect.radius,
spread: effect.spread ?? 0,
visible: effect.visible,
// Figma accepts PASS_THROUGH on shadows but stores NORMAL; it is a layer blend mode.
blendMode: effect.blendMode === 'PASS_THROUGH' ? 'NORMAL' : effect.blendMode,
...(effect.type === 'DROP_SHADOW' && effect.showShadowBehindNode !== undefined
? { showShadowBehindNode: effect.showShadowBehindNode }
: {})
}
}
return {
type: effect.type,
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },
radius: effect.radius,
spread: 0,
visible: effect.visible
}
}
/** A scene effect in the shape Figma's `effects` getter returns. */
export function toFigmaEffect(effect: Effect): FigmaEffect {
if (
effect.type === 'LAYER_BLUR' ||
effect.type === 'FOREGROUND_BLUR' ||
effect.type === 'BACKGROUND_BLUR'
) {
return {
// `.fig` files call a layer blur FOREGROUND_BLUR; the plugin API calls it LAYER_BLUR.
type: effect.type === 'BACKGROUND_BLUR' ? 'BACKGROUND_BLUR' : 'LAYER_BLUR',
visible: effect.visible,
radius: effect.radius,
boundVariables: {},
blurType: 'NORMAL'
}
}
const shadow = {
visible: effect.visible,
radius: effect.radius,
boundVariables: {},
color: { ...effect.color },
offset: { ...effect.offset },
spread: effect.spread,
blendMode: effect.blendMode ?? 'NORMAL'
}
return effect.type === 'DROP_SHADOW'
? {
type: 'DROP_SHADOW',
...shadow,
// The renderer draws the shadow behind the node unless this is `false`.
showShadowBehindNode: effect.showShadowBehindNode ?? true
}
: { type: 'INNER_SHADOW', ...shadow }
}

View file

@ -50,6 +50,7 @@ import {
const noop = () => undefined
export { FigmaNodeProxy } from './proxy'
export type { FigmaEffect } from './effects'
export type {
FigmaBooleanOperationNode,
FigmaComponentNode,

View file

@ -5,7 +5,6 @@ import type {
NodeType,
Fill,
Stroke,
Effect,
LayoutMode
} from '@open-pencil/scene-graph'
import {
@ -18,6 +17,7 @@ import type { OkHCLColor, OkHCLPayload } from '@open-pencil/scene-graph/color'
import type { Rect } from '@open-pencil/scene-graph/primitives'
import { assertNodeEditable } from '#core/editor/capabilities'
import type { FigmaEffect } from '#core/figma-api/effects'
import { installBasicNodeProxyAccessors } from './accessors/basic'
import { installLayoutNodeProxyAccessors } from './accessors/layout'
@ -78,7 +78,7 @@ export class FigmaNodeProxy {
declare fills: readonly Fill[]
declare strokes: readonly Stroke[]
declare effects: readonly Effect[]
declare effects: readonly FigmaEffect[]
declare opacity: number
declare visible: boolean
declare locked: boolean

View file

@ -10,11 +10,6 @@ interface Window {
queryLocalFonts?(): Promise<FontData[]>
}
declare module '*.md' {
const content: string
export default content
}
declare module '*?raw' {
const content: string
export default content

View file

@ -1,9 +1,9 @@
import * as v from 'valibot'
import type { Effect } from '@open-pencil/scene-graph'
import { parseColor } from '@open-pencil/scene-graph/color'
import { DEFAULT_SHADOW_COLOR, TRANSPARENT } from '#core/constants'
import { DEFAULT_SHADOW_COLOR } from '#core/constants'
import type { FigmaEffect } from '#core/figma-api/effects'
import { toolNumber, nodeIdInput } from '#core/tools/input'
import { defineTool, nodeNotFound } from '#core/tools/schema'
@ -32,18 +32,23 @@ export const setEffects = defineTool({
const node = figma.getNodeById(args.id)
if (!node) return nodeNotFound(args.id)
const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR'
let color = { ...DEFAULT_SHADOW_COLOR }
if (isBlur) color = { ...TRANSPARENT }
else if (args.color) color = parseColor(args.color)
const effect: Effect = {
type: args.type as Effect['type'],
visible: true,
radius: args.radius,
color,
offset: { x: isBlur ? 0 : args.offset_x, y: isBlur ? 0 : args.offset_y },
spread: isBlur ? 0 : args.spread
}
const effect: FigmaEffect =
args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR'
? {
type: args.type === 'BACKGROUND_BLUR' ? 'BACKGROUND_BLUR' : 'LAYER_BLUR',
radius: args.radius,
visible: true,
blurType: 'NORMAL'
}
: {
type: args.type,
color: args.color ? parseColor(args.color) : { ...DEFAULT_SHADOW_COLOR },
offset: { x: args.offset_x, y: args.offset_y },
radius: args.radius,
spread: args.spread,
visible: true,
blendMode: 'NORMAL'
}
node.effects = [...node.effects, effect]
return { id: args.id, effects: node.effects.length }

View file

@ -0,0 +1,132 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { SceneGraph } from '@open-pencil/scene-graph'
// Accepted shapes, read-back values, and rejections were recorded by running the
// same assignments against a rectangle in live Figma through figma-use.
const color = { r: 0, g: 0, b: 0, a: 0.25 }
const shadow = {
type: 'DROP_SHADOW',
color,
offset: { x: 0, y: 4 },
radius: 8,
visible: true,
blendMode: 'NORMAL'
} as const
function createRectangle(graph = new SceneGraph()) {
return new FigmaAPI(graph).createRectangle()
}
function assign(value: unknown) {
const rect = createRectangle()
// Scripts are untyped; the setter must check what it receives at runtime.
Reflect.set(rect, 'effects', value)
return rect
}
describe('node.effects', () => {
test('reads a drop shadow back with Figma defaults', () => {
expect(assign([shadow]).effects).toEqual([
{
type: 'DROP_SHADOW',
visible: true,
radius: 8,
boundVariables: {},
color,
offset: { x: 0, y: 4 },
spread: 0,
blendMode: 'NORMAL',
showShadowBehindNode: true
}
])
})
test('keeps spread and showShadowBehindNode', () => {
const [effect] = assign([{ ...shadow, spread: 2, showShadowBehindNode: false }]).effects
expect(effect).toMatchObject({ spread: 2, showShadowBehindNode: false })
})
test('stores PASS_THROUGH on shadows as NORMAL', () => {
for (const type of ['DROP_SHADOW', 'INNER_SHADOW'] as const) {
const [effect] = assign([{ ...shadow, type, blendMode: 'PASS_THROUGH' }]).effects
expect(effect).toMatchObject({ type, blendMode: 'NORMAL' })
}
})
test('reads blurs back with blurType and without shadow fields', () => {
expect(assign([{ type: 'LAYER_BLUR', radius: 4, visible: true }]).effects).toEqual([
{ type: 'LAYER_BLUR', visible: true, radius: 4, boundVariables: {}, blurType: 'NORMAL' }
])
expect(
assign([{ type: 'BACKGROUND_BLUR', blurType: 'NORMAL', radius: 4, visible: true }]).effects
).toEqual([
{ type: 'BACKGROUND_BLUR', visible: true, radius: 4, boundVariables: {}, blurType: 'NORMAL' }
])
})
test('reads a .fig foreground blur as a layer blur', () => {
const graph = new SceneGraph()
const rect = createRectangle(graph)
graph.updateNode(rect.id, {
effects: [
{
type: 'FOREGROUND_BLUR',
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 6,
spread: 0,
visible: true
}
]
})
expect(rect.effects[0]).toMatchObject({ type: 'LAYER_BLUR', radius: 6 })
})
test('accepts its own effects back', () => {
const rect = assign([shadow, { type: 'LAYER_BLUR', radius: 4, visible: true }])
const before = rect.effects
rect.effects = rect.effects
expect(rect.effects).toEqual(before)
})
test.each([
['missing required fields', [{ type: 'DROP_SHADOW', blur: 12 }]],
['a negative radius', [{ ...shadow, radius: -5 }]],
['a NaN radius', [{ ...shadow, radius: Number.NaN }]],
['an infinite radius', [{ ...shadow, radius: Number.POSITIVE_INFINITY }]],
['an infinite offset', [{ ...shadow, offset: { x: Number.POSITIVE_INFINITY, y: 0 } }]],
['an infinite spread', [{ ...shadow, spread: Number.NEGATIVE_INFINITY }]],
['an infinite blur radius', [{ type: 'LAYER_BLUR', radius: Number.POSITIVE_INFINITY, visible: true }]],
['an unknown type', [{ type: 'NOT_AN_EFFECT' }]],
['a foreground blur', [{ type: 'FOREGROUND_BLUR', radius: 4, visible: true }]],
['an unknown key', [{ ...shadow, foo: 1 }]],
['a shadow without blendMode', [{ ...shadow, blendMode: undefined }]],
['an unknown blendMode', [{ ...shadow, blendMode: 'NOPE' }]],
['showShadowBehindNode on an inner shadow', [{ ...shadow, type: 'INNER_SHADOW', showShadowBehindNode: true }]],
['shadow fields on a blur', [{ type: 'LAYER_BLUR', radius: 4, visible: true, color, offset: { x: 0, y: 0 }, spread: 0 }]],
['a color without alpha', [{ ...shadow, color: { r: 0, g: 0, b: 0 } }]],
['a color channel above 1', [{ ...shadow, color: { r: 2, g: 0, b: 0, a: 1 } }]],
['an incomplete offset', [{ ...shadow, offset: { x: 0 } }]],
['a single effect instead of an array', shadow]
])('rejects %s without changing the node', (_name, value) => {
const rect = assign([shadow])
const before = rect.effects
expect(() => Reflect.set(rect, 'effects', value)).toThrow('Property "effects" failed validation')
expect(rect.effects).toEqual(before)
})
test('names the invalid field', () => {
expect(() => assign([{ ...shadow, radius: -5 }])).toThrow('at [0].radius')
expect(() => assign([shadow, { ...shadow, foo: 1 }])).toThrow('[1]')
})
test('reports Figma effects OpenPencil does not model yet', () => {
expect(() => assign([{ type: 'NOISE' }])).toThrow('NOISE effects are not supported')
expect(() =>
assign([{ type: 'LAYER_BLUR', blurType: 'PROGRESSIVE', radius: 4, visible: true }])
).toThrow('Progressive blur is not supported')
})
})

View file

@ -1,32 +1,16 @@
import { readFileSync } from 'node:fs'
import { defineConfig } from 'tsdown'
import type { Rolldown } from 'tsdown'
import raw from 'unplugin-raw/rolldown'
const packageJSON = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as {
dependencies?: Record<string, string>
}
function rawText(): Rolldown.Plugin {
return {
name: 'raw-text',
load(id) {
if (id.endsWith('?raw')) {
const path = id.slice(0, -'?raw'.length)
return `export default ${JSON.stringify(readFileSync(path, 'utf8'))}`
}
},
transform(code, id) {
if (id.endsWith('.md')) {
return { code: `export default ${JSON.stringify(code)}`, map: null }
}
}
}
}
export default defineConfig({
entry: ['src/**/*.ts', '!src/**/*.d.ts'],
plugins: [rawText()],
// Prompts and the authoring reference import Markdown as text.
plugins: [raw()],
unbundle: true,
platform: 'neutral',
format: ['esm'],

View file

@ -38,12 +38,8 @@
"peerDependencies": {
"@open-pencil/scene-graph": "workspace:*"
},
"dependencies": {
"culori": "^4.0.2"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/culori": "^4.0.1",
"tsdown": "^0.22.14",
"typescript": "~5.8.3"
}

View file

@ -1,20 +0,0 @@
import { converter, parse } from 'culori'
import { BLACK } from '@open-pencil/scene-graph/constants'
import type { Color } from '@open-pencil/scene-graph/primitives'
const rgbConverter = converter('rgb')
export function parseColor(input: string): Color {
const parsedColor = parse(input)
const rgbColor = parsedColor ? rgbConverter(parsedColor) : null
return rgbColor
? {
r: rgbColor.r,
g: rgbColor.g,
b: rgbColor.b,
a: parsedColor?.alpha ?? 1
}
: structuredClone(BLACK)
}

View file

@ -20,11 +20,10 @@ import type {
VariableType,
VariableValue
} from '@open-pencil/scene-graph'
import { parseColor } from '@open-pencil/scene-graph/color'
import { BLACK } from '@open-pencil/scene-graph/constants'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import { parseColor } from './color'
export interface PenDocument {
version: string
children: PenNode[]

View file

@ -186,7 +186,8 @@ heavy('CLI tool operations via eval', () => {
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
visible: true,
blendMode: 'NORMAL'
}]
return { count: f.effects.length, type: f.effects[0].type }
`)

View file

@ -9,6 +9,7 @@
"*/*/src/**/*.ts",
"*/*/tests/**/*.ts",
"../src/global.d.ts",
"../src/markdown.d.ts",
"../packages/core/src/global.d.ts",
"../packages/vue/src/global.d.ts"
]