fix(mcp): improve agent-rendered JSX

This commit is contained in:
Danila Poyarkov 2026-05-19 17:57:43 +03:00
parent fed6fa620a
commit 779e0e493f
13 changed files with 330 additions and 31 deletions

View file

@ -29,6 +29,19 @@ const TEXT_ALIGN_MAP: Record<string, SceneNode['textAlignHorizontal']> = {
justified: 'JUSTIFIED'
}
const TEXT_VERTICAL_ALIGN_MAP: Record<string, SceneNode['textAlignVertical']> = {
top: 'TOP',
center: 'CENTER',
bottom: 'BOTTOM'
}
const TEXT_ALIGN_ALIAS_MAP: Record<string, SceneNode['textAlignHorizontal']> = {
...TEXT_ALIGN_MAP,
left_align: 'LEFT',
center_align: 'CENTER',
right_align: 'RIGHT'
}
const TEXT_AUTO_RESIZE_MAP: Record<string, SceneNode['textAutoResize']> = {
none: 'NONE',
width: 'WIDTH_AND_HEIGHT',
@ -141,13 +154,18 @@ function applyVisualOverrides(props: Record<string, unknown>, o: Partial<SceneNo
if (props.cornerSmoothing !== undefined) o.cornerSmoothing = props.cornerSmoothing as number
if (props.opacity !== undefined) o.opacity = props.opacity as number
if (props.rotate !== undefined) o.rotation = props.rotate as number
applyTransformOverrides(props, o)
if (props.blendMode !== undefined) {
o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode']
}
if (props.overflow === 'hidden') o.clipsContent = true
}
function applyTransformOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const rotation = props.rotate ?? props.rotation
if (rotation !== undefined) o.rotation = rotation as number
}
function applyPaddingOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const p = props.p ?? props.padding
if (typeof p === 'number') {
@ -173,7 +191,14 @@ function applyPaddingOverrides(props: Record<string, unknown>, o: Partial<SceneN
}
const PADDING_KEYS = ['p', 'padding', 'px', 'py', 'pt', 'pr', 'pb', 'pl'] as const
const AUTO_LAYOUT_TRIGGER_KEYS = [...PADDING_KEYS, 'justify', 'items'] as const
const AUTO_LAYOUT_TRIGGER_KEYS = [
...PADDING_KEYS,
'justify',
'justifyContent',
'items',
'align',
'alignItems'
] as const
function hasAutoLayoutTriggerProps(props: Record<string, unknown>): boolean {
return AUTO_LAYOUT_TRIGGER_KEYS.some((k) => props[k] !== undefined)
@ -272,6 +297,20 @@ function applyAutoLayoutSizing(
if (counterDim === 'hug') o.counterAxisSizing = 'HUG'
}
function applyLayoutAlignmentOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>
): void {
const justify = props.justify ?? props.justifyContent
if (justify) {
o.primaryAxisAlign = ALIGN_MAP[justify as string] ?? 'MIN'
}
const items = props.items ?? props.align ?? props.alignItems
if (items) {
o.counterAxisAlign = COUNTER_ALIGN_MAP[items as string] ?? 'MIN'
}
}
function shouldEnableAutoLayout(props: Record<string, unknown>, isText: boolean): boolean {
if (props.flex !== undefined) return true
if (!isText && hasAutoLayoutTriggerProps(props)) return true
@ -311,12 +350,7 @@ function applyLayoutOverrides(
if (props.rowGap !== undefined) o.counterAxisSpacing = props.rowGap as number
}
if (props.justify) {
o.primaryAxisAlign = ALIGN_MAP[props.justify as string] ?? 'MIN'
}
if (props.items) {
o.counterAxisAlign = COUNTER_ALIGN_MAP[props.items as string] ?? 'MIN'
}
applyLayoutAlignmentOverrides(props, o)
applyPaddingOverrides(props, o)
@ -358,8 +392,21 @@ function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<Scen
o.textTruncation = 'ENDING'
}
if (props.textAlign) {
o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT'
applyTextAlignmentOverrides(props, o)
}
function applyTextAlignmentOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>
): void {
const textAlign = props.textAlign ?? props.textAlignHorizontal ?? props.textHorizontalAlignment
if (typeof textAlign === 'string') {
o.textAlignHorizontal = TEXT_ALIGN_ALIAS_MAP[textAlign.toLowerCase()] ?? 'LEFT'
}
const textAlignVertical = props.textAlignVertical ?? props.textVerticalAlignment
if (typeof textAlignVertical === 'string') {
o.textAlignVertical = TEXT_VERTICAL_ALIGN_MAP[textAlignVertical.toLowerCase()] ?? 'TOP'
}
}

View file

@ -4,14 +4,128 @@ import type { SceneGraph } from '#core/scene-graph'
import * as React from './mini-react'
import { renderTree, type RenderResult } from './renderer'
import { resolveToTree } from './tree'
import { isTreeNode, resolveToTree, type TreeNode } from './tree'
/**
* Build a component function from a JSX string using sucrase.
* Works in both Node/Bun and the browser (no native bindings).
*/
const SUPPORTED_PROPS = new Set([
'name',
'key',
'flex',
'flow',
'dir',
'gap',
'wrap',
'rowGap',
'columnGap',
'justify',
'justifyContent',
'items',
'align',
'alignItems',
'grow',
'w',
'h',
'width',
'height',
'minW',
'maxW',
'minH',
'maxH',
'x',
'y',
'top',
'left',
'position',
'p',
'padding',
'px',
'py',
'pt',
'pr',
'pb',
'pl',
'bg',
'fill',
'stroke',
'strokeWidth',
'strokeAlign',
'strokeDash',
'rounded',
'roundedTL',
'roundedTR',
'roundedBL',
'roundedBR',
'cornerRadius',
'cornerSmoothing',
'opacity',
'blendMode',
'rotate',
'rotation',
'overflow',
'shadow',
'blur',
'size',
'fontSize',
'font',
'fontFamily',
'weight',
'fontWeight',
'color',
'text',
'characters',
'textAlign',
'textAlignHorizontal',
'textHorizontalAlignment',
'textAlignVertical',
'textVerticalAlignment',
'textAutoResize',
'lineHeight',
'letterSpacing',
'textDecoration',
'textCase',
'maxLines',
'truncate',
'grid',
'columns',
'rows',
'colStart',
'rowStart',
'col',
'row',
'colSpan',
'rowSpan',
'points',
'pointCount',
'innerRadius',
'label'
])
function stripHtmlComments(jsxString: string): string {
return jsxString.replace(/<!--[\s\S]*?-->/g, '')
}
function unsupportedPropWarnings(tree: TreeNode): string[] {
const warnings: string[] = []
collectUnsupportedPropWarnings(tree, warnings)
return warnings
}
function collectUnsupportedPropWarnings(tree: TreeNode, warnings: string[]): void {
for (const key of Object.keys(tree.props)) {
if (!SUPPORTED_PROPS.has(key)) {
warnings.push(`Unsupported prop "${key}" on <${tree.type}> is ignored.`)
}
}
for (const child of tree.children) {
if (isTreeNode(child)) collectUnsupportedPropWarnings(child, warnings)
}
}
export function buildComponent(jsxString: string): React.ComponentType {
const trimmed = jsxString.trim()
const trimmed = stripHtmlComments(jsxString).trim()
const aliases = `
const __h = React.createElement
@ -63,6 +177,8 @@ export async function renderJSX(
throw new Error('JSX must return a Figma element (Frame, Text, etc)')
}
const warnings = unsupportedPropWarnings(tree)
if (tree.type === '' && tree.children.length > 0) {
const results: RenderResult[] = []
for (const child of tree.children) {
@ -72,10 +188,13 @@ export async function renderJSX(
if (results.length === 0) {
throw new Error('JSX must return a Figma element (Frame, Text, etc)')
}
if (warnings.length > 0) results[0].warnings = warnings
return results
}
return [await renderTree(graph, tree, options)]
const result = await renderTree(graph, tree, options)
if (warnings.length > 0) result.warnings = warnings
return [result]
}
export { renderTree as renderTreeNode }

View file

@ -35,6 +35,7 @@ export interface RenderResult {
name: string
type: NodeType
childIds: string[]
warnings?: string[]
}
export async function renderTree(
@ -102,8 +103,10 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string):
const overrides = propsToOverrides(tree.props, isText, parentLayout)
if (isText) {
const textContent = tree.children.filter((c): c is string => typeof c === 'string').join('')
if (textContent) overrides.text = textContent
const childText = tree.children.filter((c): c is string => typeof c === 'string').join('')
const propText = tree.props.text ?? tree.props.characters
if (childText) overrides.text = childText
else if (typeof propText === 'string') overrides.text = propText
}
const node = graph.createNode(nodeType, parentId, overrides)

View file

@ -89,7 +89,10 @@ export type StyleProps = {
wrap?: boolean
rowGap?: number
justify?: 'start' | 'end' | 'center' | 'between'
justifyContent?: 'start' | 'end' | 'center' | 'between'
items?: 'start' | 'end' | 'center' | 'stretch'
align?: 'start' | 'end' | 'center' | 'stretch'
alignItems?: 'start' | 'end' | 'center' | 'stretch'
grow?: number
w?: number | 'fill' | 'hug'
@ -125,6 +128,7 @@ export type StyleProps = {
opacity?: number
blendMode?: string
rotate?: number
rotation?: number
overflow?: 'hidden' | 'visible'
shadow?: string
blur?: number
@ -136,7 +140,13 @@ export type StyleProps = {
weight?: number | 'bold' | 'medium' | 'normal'
fontWeight?: number | 'bold' | 'medium' | 'normal'
color?: string
text?: string
characters?: string
textAlign?: 'left' | 'center' | 'right' | 'justified'
textAlignHorizontal?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
textHorizontalAlignment?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
textAlignVertical?: 'TOP' | 'CENTER' | 'BOTTOM'
textVerticalAlignment?: 'TOP' | 'CENTER' | 'BOTTOM'
textAutoResize?: 'none' | 'width' | 'height'
}

View file

@ -28,7 +28,21 @@ export function nodeProxyToJSON(
if (n.cornerRadius > 0) obj.cornerRadius = n.cornerRadius
if (!n.visible) obj.visible = false
if (n.text) obj.characters = n.text
if (n.type === 'TEXT') obj.textDirection = n.textDirection
if (n.type === 'TEXT') {
obj.fontFamily = n.fontFamily
obj.fontSize = n.fontSize
obj.fontWeight = n.fontWeight
obj.italic = n.italic
obj.textAlignHorizontal = n.textAlignHorizontal
obj.textAlignVertical = n.textAlignVertical
obj.textAutoResize = n.textAutoResize
obj.textDirection = n.textDirection
if (n.lineHeight != null) obj.lineHeight = n.lineHeight
if (n.letterSpacing !== 0) obj.letterSpacing = n.letterSpacing
if (n.textCase !== 'ORIGINAL') obj.textCase = n.textCase
if (n.textDecoration !== 'NONE') obj.textDecoration = n.textDecoration
if (n.maxLines != null) obj.maxLines = n.maxLines
}
if (n.layoutMode !== 'NONE') {
obj.layoutMode = n.layoutMode
obj.layoutDirection = n.layoutDirection

View file

@ -55,6 +55,7 @@ export const render = defineTool({
name: result.name,
type: result.type,
children: result.childIds,
...(result.warnings ? { warnings: result.warnings } : {}),
...(results.length > 1
? {
siblings: results

View file

@ -22,6 +22,7 @@ export const nodeReplaceWith = defineTool({
id: result.id,
name: result.name,
type: result.type,
...(result.warnings ? { warnings: result.warnings } : {}),
children: results
.slice(1)
.map((child) => ({ id: child.id, name: child.name, type: child.type }))

View file

@ -4,12 +4,12 @@ import * as v from 'valibot'
import { computeAllLayouts } from '@open-pencil/core/layout'
import type { SceneNode } from '@open-pencil/core/scene-graph'
import { fontManager } from '@open-pencil/core/text'
import { CORE_TOOLS, toolsToAI } from '@open-pencil/core/tools'
import type { StepBudget, ToolLogEntry } from '@open-pencil/core/tools'
import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory'
import { getActiveEditorStore } from '@/app/editor/active-store'
import { ensureGraphFonts } from '@/app/editor/fonts'
import type { EditorStore } from '@/app/editor/active-store'
export const MAX_AGENT_STEPS = 50
@ -99,20 +99,7 @@ export function createAITools(store: EditorStore) {
if (def.mutates) {
const pageId = store.state.currentPageId
const pageNode = store.graph.getNode(pageId)
if (pageNode) {
const fontKeys = fontManager.collectFontKeys(store.graph, pageNode.childIds)
const missing = fontKeys.filter(([family]) => !fontManager.isLoaded(family))
if (missing.length > 0) {
const results = await Promise.all(
missing.map(([family, style]) => fontManager.loadFont(family, style))
)
if (results.some((r) => r !== null)) {
for (const [, node] of store.graph.nodes) {
if (node.type === 'TEXT' && node.textPicture) node.textPicture = null
}
}
}
}
if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds)
computeAllLayouts(store.graph, pageId)
store.requestRender()
if (beforeSnapshot) {

View file

@ -4,6 +4,7 @@ import { computeAllLayouts } from '@open-pencil/core/layout'
import { ALL_TOOLS } from '@open-pencil/core/tools'
import type { EditorStore } from '@/app/editor/active-store'
import { ensureGraphFonts } from '@/app/editor/fonts'
type FigmaFactory = () => FigmaAPI
@ -18,6 +19,7 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) {
x: toolArgs.x as number | undefined,
y: toolArgs.y as number | undefined
})
await ensureGraphFonts(store.graph, [result.id])
computeAllLayouts(store.graph, store.state.currentPageId)
store.requestRender()
store.flashNodes([result.id])
@ -46,6 +48,8 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) {
}
if (def.mutates) {
const pageNode = store.graph.getNode(store.state.currentPageId)
if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds)
computeAllLayouts(store.graph, store.state.currentPageId)
store.requestRender()
store.flashNodes(extractNodeIds(result))

View file

@ -1,5 +1,6 @@
import { useLocalStorage } from '@vueuse/core'
import type { SceneGraph } from '@open-pencil/core/scene-graph'
import {
fontManager,
styleToWeight,
@ -113,6 +114,23 @@ export async function listFonts(): Promise<TauriFontFamily[]> {
return []
}
export async function ensureGraphFonts(graph: SceneGraph, nodeIds: string[]): Promise<boolean> {
const fontKeys = fontManager.collectFontKeys(graph, nodeIds)
const missing = fontKeys.filter(([family, style]) => !fontManager.isStyleLoaded(family, style))
if (missing.length === 0) return false
const results = await Promise.all(missing.map(([family, style]) => loadFont(family, style)))
const loaded = results.some((result) => result !== null)
if (loaded) clearTextPictures(graph)
return loaded
}
function clearTextPictures(graph: SceneGraph): void {
for (const [, node] of graph.nodes) {
if (node.type === 'TEXT') node.textPicture = null
}
}
export async function loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
configureTauriFontCache()
if (isTauri()) {

View file

@ -322,4 +322,43 @@ describe('renderJSX (string → scene graph)', () => {
expect(node.x).toBe(100)
expect(node.y).toBe(200)
})
it('accepts rotation as an alias for rotate', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Rectangle name="Rotated" w={50} h={50} rotation={15} />')
const node = getNodeOrThrow(g, result.id)
expect(node.rotation).toBe(15)
})
it('strips HTML comments before JSX parsing', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
'<Frame name="Comments" w={50} h={50}><!-- generated note --><Text color="#000">Ok</Text></Frame>'
)
const node = getNodeOrThrow(g, result.id)
expect(node.name).toBe('Comments')
expect(node.childIds.length).toBe(1)
})
it('warns about unsupported props', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Frame name="Warn" w={50} h={50} mt={8} />')
expect(result.warnings).toEqual(['Unsupported prop "mt" on <frame> is ignored.'])
})
it('accepts CSS-style layout aliases', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
'<Frame name="Aliases" w={200} h={100} flex="row" justifyContent="center" alignItems="center"><Rectangle w={20} h={20} /></Frame>'
)
const node = getNodeOrThrow(g, result.id)
expect(node.primaryAxisAlign).toBe('CENTER')
expect(node.counterAxisAlign).toBe('CENTER')
})
})

View file

@ -61,6 +61,36 @@ describe('text props round-trip', () => {
expect(jsx).toContain('truncate')
})
it('accepts Figma-style text aliases', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
'<Text text="Alias text" fontSize={24} fontFamily="Inter" fontWeight={700} fill="#123456" textHorizontalAlignment="CENTER" textVerticalAlignment="CENTER" />'
)
const n = getNodeOrThrow(g, result.id)
expect(n.text).toBe('Alias text')
expect(n.fontSize).toBe(24)
expect(n.fontFamily).toBe('Inter')
expect(n.fontWeight).toBe(700)
expect(n.textAlignHorizontal).toBe('CENTER')
expect(n.textAlignVertical).toBe('CENTER')
expect(n.fills[0]?.type).toBe('SOLID')
})
it('accepts characters as a text content alias', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text characters="Characters text" color="#000" />')
const n = getNodeOrThrow(g, result.id)
expect(n.text).toBe('Characters text')
})
it('prefers text children over text prop alias', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text text="Prop text" color="#000">Child text</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.text).toBe('Child text')
})
it('defaults omit text props', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000">Plain</Text>')

View file

@ -67,4 +67,30 @@ describe('render', () => {
expect(result.type).toBe('FRAME')
expect(result.children.length).toBeGreaterThan(0)
})
test('returns JSX warnings', async () => {
const { figma } = setupToolTest()
const tool = getTool('render')
const result = (await tool.execute(figma, {
jsx: '<Frame name="Card" w={200} h={100} mt={8} />'
})) as ToolResult
expect(result.warnings).toEqual(['Unsupported prop "mt" on <frame> is ignored.'])
})
test('get_node exposes text style fields', async () => {
const { figma } = setupToolTest()
const render = getTool('render')
const card = (await render.execute(figma, {
jsx: '<Frame name="Card" w={200} h={100}><Text name="Title" size={24} weight={700} font="Inter" color="#111" textAlign="center">Hello</Text></Frame>'
})) as ToolResult
const textId = (card.children as string[])[0]
const getNode = getTool('get_node')
const result = getNode.execute(figma, { id: textId, depth: 0 }) as ToolResult
expect(result.characters).toBe('Hello')
expect(result.fontFamily).toBe('Inter')
expect(result.fontSize).toBe(24)
expect(result.fontWeight).toBe(700)
expect(result.textAlignHorizontal).toBe('CENTER')
})
})