Unify tool definitions: define once, adapt for AI/CLI/MCP
Move tool logic to @open-pencil/core/tools/schema.ts as framework-agnostic ToolDef objects. AI adapter generates valibot schemas + Vercel AI tool() wrappers automatically. 26 tools (was 10): create_shape, render (JSX), set_fill, set_stroke, set_effects, set_layout, set_constraints, update_node, delete, clone, rename, reparent, group/ungroup, find_nodes, get_node, get_page_tree, get_selection, select, list_pages, switch_page, list_variables, list_collections, create_component, create_instance, eval. src/ai/tools.ts: 269→28 lines (adapter only). Tests for all 3 interfaces: - 26 core tool tests (FigmaAPI directly) - 11 AI adapter tests (valibot + Vercel AI SDK tool()) - 12 CLI integration tests (eval command on .fig fixture) 323 total, all passing.
This commit is contained in:
parent
484c3b6a9a
commit
77509d8979
11
AGENTS.md
11
AGENTS.md
|
|
@ -39,6 +39,17 @@ The root app (`src/`) is the Tauri/Vite desktop editor. Its `src/engine/` files
|
|||
- Don't hand-roll `console.log` formatting — use the helpers from `packages/cli/src/format.ts` which re-exports agentfmt with project-specific adapters (`nodeToData`, `nodeDetails`, `nodeToTreeNode`, `nodeToListItem`)
|
||||
- Every command supports `--json` for machine-readable output
|
||||
|
||||
## Tools (AI / MCP / CLI)
|
||||
|
||||
- Tool operations are defined once in `packages/core/src/tools/schema.ts` as framework-agnostic `ToolDef` objects
|
||||
- Each tool has: name, description, typed params, and an `execute(figma: FigmaAPI, args)` function
|
||||
- `defineTool()` gives type-safe params in the execute body; the array `ALL_TOOLS` erases the generics for adapters
|
||||
- AI adapter (`packages/core/src/tools/ai-adapter.ts`): `toolsToAI()` converts ToolDefs → valibot schemas + Vercel AI `tool()` wrappers
|
||||
- `src/ai/tools.ts` is just a thin wire: creates FigmaAPI from editor store, calls `toolsToAI()`
|
||||
- CLI commands (`packages/cli/src/commands/`) are **not** generated from ToolDefs — they have custom agentfmt formatting, tree walking, pagination. The `eval` command is the CLI's access to all ToolDef operations via FigmaAPI.
|
||||
- To add a new tool: add a `defineTool()` in `schema.ts`, add to `ALL_TOOLS` array — it's instantly available in AI chat, and via `eval` in CLI
|
||||
- `FigmaAPI` (`packages/core/src/figma-api.ts`) is the execution target for all tools — Figma Plugin API compatible, uses Symbols for hidden internals
|
||||
|
||||
## Code conventions
|
||||
|
||||
- `@/` import alias for app cross-directory imports, relative imports within core
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export {
|
|||
} from './scene-graph'
|
||||
|
||||
export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api'
|
||||
export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
|
||||
export type { ToolDef, ParamDef, ParamType } from './tools'
|
||||
export { SkiaRenderer, type RenderOverlays } from './renderer'
|
||||
export { computeLayout, computeAllLayouts } from './layout'
|
||||
export { getCanvasKit, type CanvasKitOptions } from './canvaskit'
|
||||
|
|
|
|||
77
packages/core/src/tools/ai-adapter.ts
Normal file
77
packages/core/src/tools/ai-adapter.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Adapter: tool definitions → Vercel AI SDK `tool()` objects.
|
||||
*
|
||||
* Converts ParamDef types to valibot schemas and wraps execute
|
||||
* functions with FigmaAPI instantiation.
|
||||
*/
|
||||
|
||||
import type { ToolDef, ParamDef, ParamType } from './schema'
|
||||
import type { FigmaAPI } from '../figma-api'
|
||||
|
||||
export interface AIAdapterOptions {
|
||||
getFigma: () => FigmaAPI
|
||||
onBeforeExecute?: () => void
|
||||
onAfterExecute?: () => void
|
||||
}
|
||||
|
||||
export function toolsToAI(
|
||||
tools: ToolDef[],
|
||||
options: AIAdapterOptions,
|
||||
deps: {
|
||||
v: typeof import('valibot')
|
||||
valibotSchema: (schema: any) => any
|
||||
tool: (opts: any) => any
|
||||
}
|
||||
): Record<string, any> {
|
||||
const { v, valibotSchema, tool } = deps
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const def of tools) {
|
||||
const shape: Record<string, unknown> = {}
|
||||
for (const [key, param] of Object.entries(def.params)) {
|
||||
shape[key] = paramToValibot(v, param)
|
||||
}
|
||||
|
||||
result[def.name] = tool({
|
||||
description: def.description,
|
||||
inputSchema: valibotSchema(v.object(shape as any)),
|
||||
execute: async (args: Record<string, unknown>) => {
|
||||
options.onBeforeExecute?.()
|
||||
try {
|
||||
return await def.execute(options.getFigma(), args as any)
|
||||
} finally {
|
||||
options.onAfterExecute?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function paramToValibot(v: typeof import('valibot'), param: ParamDef): unknown {
|
||||
const typeMap: Record<ParamType, () => unknown> = {
|
||||
string: () => (param.enum ? v.picklist(param.enum as [string, ...string[]]) : v.string()),
|
||||
number: () => {
|
||||
const pipes: unknown[] = [v.number()]
|
||||
if (param.min !== undefined) pipes.push(v.minValue(param.min))
|
||||
if (param.max !== undefined) pipes.push(v.maxValue(param.max))
|
||||
return pipes.length > 1 ? v.pipe(...(pipes as [any, any, ...any[]])) : v.number()
|
||||
},
|
||||
boolean: () => v.boolean(),
|
||||
color: () => v.pipe(v.string(), v.description('Color value (hex like #ff0000 or #ff000080)')),
|
||||
'string[]': () => v.pipe(v.array(v.string()), v.minLength(1))
|
||||
}
|
||||
|
||||
let schema = typeMap[param.type]()
|
||||
|
||||
if (param.description && param.type !== 'color') {
|
||||
schema = v.pipe(schema as any, v.description(param.description))
|
||||
}
|
||||
|
||||
if (!param.required) {
|
||||
schema = v.optional(schema as any, param.default as any)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
3
packages/core/src/tools/index.ts
Normal file
3
packages/core/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ALL_TOOLS, defineTool } from './schema'
|
||||
export type { ToolDef, ParamDef, ParamType } from './schema'
|
||||
export { toolsToAI } from './ai-adapter'
|
||||
666
packages/core/src/tools/schema.ts
Normal file
666
packages/core/src/tools/schema.ts
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
/**
|
||||
* Tool definition schema.
|
||||
*
|
||||
* Each tool is defined once with typed params and an execute function
|
||||
* that operates on FigmaAPI. Adapters for AI chat (valibot), CLI (citty),
|
||||
* and MCP (JSON Schema) are generated from these definitions.
|
||||
*/
|
||||
|
||||
import { parseColor } from '../color'
|
||||
import type { FigmaAPI, FigmaNodeProxy } from '../figma-api'
|
||||
|
||||
export type ParamType = 'string' | 'number' | 'boolean' | 'color' | 'string[]'
|
||||
|
||||
export interface ParamDef {
|
||||
type: ParamType
|
||||
description: string
|
||||
required?: boolean
|
||||
default?: unknown
|
||||
enum?: string[]
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
name: string
|
||||
description: string
|
||||
params: Record<string, ParamDef>
|
||||
execute: (figma: FigmaAPI, args: Record<string, any>) => unknown
|
||||
}
|
||||
|
||||
type ResolvedType<T extends ParamType> = T extends 'string'
|
||||
? string
|
||||
: T extends 'number'
|
||||
? number
|
||||
: T extends 'boolean'
|
||||
? boolean
|
||||
: T extends 'color'
|
||||
? string
|
||||
: T extends 'string[]'
|
||||
? string[]
|
||||
: never
|
||||
|
||||
type ResolvedParams<P extends Record<string, ParamDef>> = {
|
||||
[K in keyof P as P[K]['required'] extends true ? K : never]: ResolvedType<P[K]['type']>
|
||||
} & {
|
||||
[K in keyof P as P[K]['required'] extends true ? never : K]?: ResolvedType<P[K]['type']>
|
||||
}
|
||||
|
||||
export function defineTool<P extends Record<string, ParamDef>>(def: {
|
||||
name: string
|
||||
description: string
|
||||
params: P
|
||||
execute: (figma: FigmaAPI, args: ResolvedParams<P>) => unknown
|
||||
}): ToolDef {
|
||||
return def as unknown as ToolDef
|
||||
}
|
||||
|
||||
function nodeToResult(node: FigmaNodeProxy): Record<string, unknown> {
|
||||
return node.toJSON()
|
||||
}
|
||||
|
||||
function nodeSummary(node: FigmaNodeProxy): { id: string; name: string; type: string } {
|
||||
return { id: node.id, name: node.name, type: node.type }
|
||||
}
|
||||
|
||||
// ─── Read tools ───────────────────────────────────────────────
|
||||
|
||||
export const getSelection = defineTool({
|
||||
name: 'get_selection',
|
||||
description: 'Get details about currently selected nodes.',
|
||||
params: {},
|
||||
execute: (figma) => {
|
||||
const sel = figma.currentPage.selection
|
||||
return { selection: sel.map(nodeToResult) }
|
||||
}
|
||||
})
|
||||
|
||||
export const getPageTree = defineTool({
|
||||
name: 'get_page_tree',
|
||||
description:
|
||||
'Get the node tree of the current page. Returns all nodes with hierarchy, types, positions, and sizes.',
|
||||
params: {},
|
||||
execute: (figma) => {
|
||||
const page = figma.currentPage
|
||||
return {
|
||||
page: page.name,
|
||||
children: page.children.map(nodeToResult)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const getNode = defineTool({
|
||||
name: 'get_node',
|
||||
description: 'Get detailed properties of a node by ID.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID', required: true }
|
||||
},
|
||||
execute: (figma, { id }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
return nodeToResult(node)
|
||||
}
|
||||
})
|
||||
|
||||
export const findNodes = defineTool({
|
||||
name: 'find_nodes',
|
||||
description: 'Find nodes by name pattern and/or type.',
|
||||
params: {
|
||||
name: { type: 'string', description: 'Name substring to match (case-insensitive)' },
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Node type filter',
|
||||
enum: [
|
||||
'FRAME',
|
||||
'RECTANGLE',
|
||||
'ELLIPSE',
|
||||
'TEXT',
|
||||
'LINE',
|
||||
'STAR',
|
||||
'POLYGON',
|
||||
'SECTION',
|
||||
'GROUP',
|
||||
'COMPONENT',
|
||||
'INSTANCE',
|
||||
'VECTOR'
|
||||
]
|
||||
}
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const page = figma.currentPage
|
||||
const matches = page.findAll((node) => {
|
||||
if (args.type && node.type !== args.type) return false
|
||||
if (args.name && !node.name.toLowerCase().includes(args.name.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
return { count: matches.length, nodes: matches.map(nodeSummary) }
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Create tools ─────────────────────────────────────────────
|
||||
|
||||
export const createShape = defineTool({
|
||||
name: 'create_shape',
|
||||
description:
|
||||
'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.',
|
||||
params: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Node type',
|
||||
required: true,
|
||||
enum: ['FRAME', 'RECTANGLE', 'ELLIPSE', 'TEXT', 'LINE', 'STAR', 'POLYGON', 'SECTION']
|
||||
},
|
||||
x: { type: 'number', description: 'X position', required: true },
|
||||
y: { type: 'number', description: 'Y position', required: true },
|
||||
width: { type: 'number', description: 'Width in pixels', required: true, min: 1 },
|
||||
height: { type: 'number', description: 'Height in pixels', required: true, min: 1 },
|
||||
name: { type: 'string', description: 'Node name shown in layers panel' },
|
||||
parent_id: { type: 'string', description: 'Parent node ID to nest inside' }
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const parentId = args.parent_id
|
||||
const parent = parentId ? figma.getNodeById(parentId) : null
|
||||
const createMap: Record<string, () => FigmaNodeProxy> = {
|
||||
FRAME: () => figma.createFrame(),
|
||||
RECTANGLE: () => figma.createRectangle(),
|
||||
ELLIPSE: () => figma.createEllipse(),
|
||||
TEXT: () => figma.createText(),
|
||||
LINE: () => figma.createLine(),
|
||||
STAR: () => figma.createStar(),
|
||||
POLYGON: () => figma.createPolygon(),
|
||||
SECTION: () => figma.createSection()
|
||||
}
|
||||
const node = createMap[args.type]!()
|
||||
node.x = args.x
|
||||
node.y = args.y
|
||||
node.resize(args.width, args.height)
|
||||
if (args.name) node.name = args.name
|
||||
if (parent) parent.appendChild(node)
|
||||
return nodeSummary(node)
|
||||
}
|
||||
})
|
||||
|
||||
export const render = defineTool({
|
||||
name: 'render',
|
||||
description:
|
||||
'Render JSX to design nodes. Primary creation tool — creates entire component trees in one call. Example: <Frame name="Card" w={320} h="hug" flex="col" gap={16} p={24} bg="#FFF" rounded={16}><Text size={18} weight="bold">Title</Text></Frame>',
|
||||
params: {
|
||||
jsx: { type: 'string', description: 'JSX string to render', required: true },
|
||||
x: { type: 'number', description: 'X position of the root node' },
|
||||
y: { type: 'number', description: 'Y position of the root node' },
|
||||
parent_id: { type: 'string', description: 'Parent node ID to render into' }
|
||||
},
|
||||
execute: async (figma, args) => {
|
||||
const { renderJsx } = await import('../render/render-jsx')
|
||||
const result = await renderJsx(figma.graph, args.jsx, {
|
||||
parentId: args.parent_id ?? figma.currentPageId,
|
||||
x: args.x,
|
||||
y: args.y
|
||||
})
|
||||
return { id: result.id, name: result.name, type: result.type, children: result.childIds }
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Modify tools ─────────────────────────────────────────────
|
||||
|
||||
export const setFill = defineTool({
|
||||
name: 'set_fill',
|
||||
description: 'Set the fill color of a node. Accepts hex (#ff0000) or named color.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID', required: true },
|
||||
color: { type: 'color', description: 'Color value (hex like #ff0000)', required: true }
|
||||
},
|
||||
execute: (figma, { id, color }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
|
||||
const c = parseColor(color)
|
||||
node.fills = [{ type: 'SOLID', color: c, opacity: 1, visible: true }]
|
||||
return { id, color: c }
|
||||
}
|
||||
})
|
||||
|
||||
export const setStroke = defineTool({
|
||||
name: 'set_stroke',
|
||||
description: 'Set the stroke (border) of a node.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID', required: true },
|
||||
color: { type: 'color', description: 'Stroke color (hex)', required: true },
|
||||
weight: { type: 'number', description: 'Stroke weight', default: 1, min: 0.1 },
|
||||
align: {
|
||||
type: 'string',
|
||||
description: 'Stroke alignment',
|
||||
default: 'INSIDE',
|
||||
enum: ['INSIDE', 'CENTER', 'OUTSIDE']
|
||||
}
|
||||
},
|
||||
execute: (figma, { id, color, weight, align }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
|
||||
const c = parseColor(color)
|
||||
node.strokes = [
|
||||
{ color: c, weight: weight ?? 1, opacity: 1, visible: true, align: (align ?? 'INSIDE') as 'INSIDE' | 'CENTER' | 'OUTSIDE' }
|
||||
]
|
||||
return { id, color: c, weight: weight ?? 1 }
|
||||
}
|
||||
})
|
||||
|
||||
export const setEffects = defineTool({
|
||||
name: 'set_effects',
|
||||
description:
|
||||
'Set effects on a node (drop shadow, inner shadow, blur). Pass an array or a single effect.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID', required: true },
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Effect type',
|
||||
required: true,
|
||||
enum: ['DROP_SHADOW', 'INNER_SHADOW', 'FOREGROUND_BLUR', 'BACKGROUND_BLUR']
|
||||
},
|
||||
color: { type: 'color', description: 'Shadow color (hex). Ignored for blur.' },
|
||||
offset_x: { type: 'number', description: 'Shadow X offset', default: 0 },
|
||||
offset_y: { type: 'number', description: 'Shadow Y offset', default: 4 },
|
||||
radius: { type: 'number', description: 'Blur radius', default: 4, min: 0 },
|
||||
spread: { type: 'number', description: 'Shadow spread', default: 0 }
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const node = figma.getNodeById(args.id)
|
||||
if (!node) return { error: `Node "${args.id}" not found` }
|
||||
|
||||
const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR'
|
||||
const effect: Record<string, unknown> = {
|
||||
type: args.type,
|
||||
visible: true,
|
||||
radius: args.radius ?? 4
|
||||
}
|
||||
|
||||
if (!isBlur) {
|
||||
|
||||
effect.color = args.color ? parseColor(args.color) : { r: 0, g: 0, b: 0, a: 0.25 }
|
||||
effect.offset = { x: args.offset_x ?? 0, y: args.offset_y ?? 4 }
|
||||
effect.spread = args.spread ?? 0
|
||||
}
|
||||
|
||||
node.effects = [...node.effects, effect as any]
|
||||
return { id: args.id, effects: node.effects.length }
|
||||
}
|
||||
})
|
||||
|
||||
export const updateNode = defineTool({
|
||||
name: 'update_node',
|
||||
description:
|
||||
'Update properties of an existing node: position, size, opacity, corner radius, visibility, text, font.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID', required: true },
|
||||
x: { type: 'number', description: 'X position' },
|
||||
y: { type: 'number', description: 'Y position' },
|
||||
width: { type: 'number', description: 'Width', min: 1 },
|
||||
height: { type: 'number', description: 'Height', min: 1 },
|
||||
opacity: { type: 'number', description: 'Opacity (0-1)', min: 0, max: 1 },
|
||||
corner_radius: { type: 'number', description: 'Corner radius', min: 0 },
|
||||
visible: { type: 'boolean', description: 'Visibility' },
|
||||
text: { type: 'string', description: 'Text content (TEXT nodes)' },
|
||||
font_size: { type: 'number', description: 'Font size', min: 1 },
|
||||
font_weight: { type: 'number', description: 'Font weight (100-900)' },
|
||||
name: { type: 'string', description: 'Layer name' }
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const node = figma.getNodeById(args.id)
|
||||
if (!node) return { error: `Node "${args.id}" not found` }
|
||||
const updated: string[] = []
|
||||
if (args.x !== undefined) { node.x = args.x; updated.push('x') }
|
||||
if (args.y !== undefined) { node.y = args.y; updated.push('y') }
|
||||
if (args.width !== undefined || args.height !== undefined) {
|
||||
node.resize(args.width ?? node.width, args.height ?? node.height)
|
||||
updated.push('size')
|
||||
}
|
||||
if (args.opacity !== undefined) { node.opacity = args.opacity; updated.push('opacity') }
|
||||
if (args.corner_radius !== undefined) {
|
||||
node.cornerRadius = args.corner_radius
|
||||
updated.push('cornerRadius')
|
||||
}
|
||||
if (args.visible !== undefined) { node.visible = args.visible; updated.push('visible') }
|
||||
if (args.name !== undefined) { node.name = args.name; updated.push('name') }
|
||||
if (args.text !== undefined) {
|
||||
figma.graph.updateNode(node.id, { text: args.text })
|
||||
updated.push('text')
|
||||
}
|
||||
if (args.font_size !== undefined) {
|
||||
figma.graph.updateNode(node.id, { fontSize: args.font_size })
|
||||
updated.push('fontSize')
|
||||
}
|
||||
if (args.font_weight !== undefined) {
|
||||
figma.graph.updateNode(node.id, { fontWeight: args.font_weight })
|
||||
updated.push('fontWeight')
|
||||
}
|
||||
return { id: args.id, updated }
|
||||
}
|
||||
})
|
||||
|
||||
export const setLayout = defineTool({
|
||||
name: 'set_layout',
|
||||
description: 'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, padding.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Frame node ID', required: true },
|
||||
direction: {
|
||||
type: 'string',
|
||||
description: 'Layout direction',
|
||||
required: true,
|
||||
enum: ['HORIZONTAL', 'VERTICAL']
|
||||
},
|
||||
spacing: { type: 'number', description: 'Gap between items', default: 0, min: 0 },
|
||||
padding: { type: 'number', description: 'Equal padding on all sides', min: 0 },
|
||||
padding_horizontal: { type: 'number', description: 'Horizontal padding', min: 0 },
|
||||
padding_vertical: { type: 'number', description: 'Vertical padding', min: 0 },
|
||||
align: {
|
||||
type: 'string',
|
||||
description: 'Primary axis alignment',
|
||||
default: 'MIN',
|
||||
enum: ['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN']
|
||||
},
|
||||
counter_align: {
|
||||
type: 'string',
|
||||
description: 'Cross axis alignment',
|
||||
default: 'MIN',
|
||||
enum: ['MIN', 'CENTER', 'MAX', 'STRETCH']
|
||||
}
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const node = figma.getNodeById(args.id)
|
||||
if (!node) return { error: `Node "${args.id}" not found` }
|
||||
|
||||
node.layoutMode = args.direction as 'HORIZONTAL' | 'VERTICAL'
|
||||
node.itemSpacing = args.spacing ?? 0
|
||||
node.primaryAxisAlignItems = (args.align ?? 'MIN') as any
|
||||
node.counterAxisAlignItems = (args.counter_align ?? 'MIN') as any
|
||||
|
||||
const ph = args.padding_horizontal ?? args.padding ?? 0
|
||||
const pv = args.padding_vertical ?? args.padding ?? 0
|
||||
node.paddingLeft = ph
|
||||
node.paddingRight = ph
|
||||
node.paddingTop = pv
|
||||
node.paddingBottom = pv
|
||||
|
||||
return { id: args.id, direction: args.direction, spacing: args.spacing ?? 0 }
|
||||
}
|
||||
})
|
||||
|
||||
export const setConstraints = defineTool({
|
||||
name: 'set_constraints',
|
||||
description: 'Set resize constraints for a node within its parent.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID', required: true },
|
||||
horizontal: {
|
||||
type: 'string',
|
||||
description: 'Horizontal constraint',
|
||||
enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE']
|
||||
},
|
||||
vertical: {
|
||||
type: 'string',
|
||||
description: 'Vertical constraint',
|
||||
enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE']
|
||||
}
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const node = figma.getNodeById(args.id)
|
||||
if (!node) return { error: `Node "${args.id}" not found` }
|
||||
if (args.horizontal || args.vertical) {
|
||||
node.constraints = {
|
||||
horizontal: args.horizontal ?? node.constraints.horizontal,
|
||||
vertical: args.vertical ?? node.constraints.vertical
|
||||
}
|
||||
}
|
||||
return { id: args.id, constraints: node.constraints }
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Structure tools ──────────────────────────────────────────
|
||||
|
||||
export const deleteNode = defineTool({
|
||||
name: 'delete_node',
|
||||
description: 'Delete a node by ID.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID to delete', required: true }
|
||||
},
|
||||
execute: (figma, { id }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
node.remove()
|
||||
return { deleted: id }
|
||||
}
|
||||
})
|
||||
|
||||
export const cloneNode = defineTool({
|
||||
name: 'clone_node',
|
||||
description: 'Clone (duplicate) a node.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID to clone', required: true }
|
||||
},
|
||||
execute: (figma, { id }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
const clone = node.clone()
|
||||
return nodeSummary(clone)
|
||||
}
|
||||
})
|
||||
|
||||
export const renameNode = defineTool({
|
||||
name: 'rename_node',
|
||||
description: 'Rename a node in the layers panel.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID', required: true },
|
||||
name: { type: 'string', description: 'New name', required: true }
|
||||
},
|
||||
execute: (figma, { id, name }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
node.name = name
|
||||
return { id, name }
|
||||
}
|
||||
})
|
||||
|
||||
export const reparentNode = defineTool({
|
||||
name: 'reparent_node',
|
||||
description: 'Move a node into a different parent.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID to move', required: true },
|
||||
parent_id: { type: 'string', description: 'New parent node ID', required: true }
|
||||
},
|
||||
execute: (figma, { id, parent_id }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
const parent = figma.getNodeById(parent_id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
if (!parent) return { error: `Parent "${parent_id}" not found` }
|
||||
parent.appendChild(node)
|
||||
return { id, parent_id }
|
||||
}
|
||||
})
|
||||
|
||||
export const selectNodes = defineTool({
|
||||
name: 'select_nodes',
|
||||
description: 'Select one or more nodes by ID.',
|
||||
params: {
|
||||
ids: { type: 'string[]', description: 'Node IDs to select', required: true }
|
||||
},
|
||||
execute: (figma, { ids }) => {
|
||||
figma.currentPage.selection = ids
|
||||
.map((id) => figma.getNodeById(id))
|
||||
.filter((n): n is FigmaNodeProxy => n !== null)
|
||||
return { selected: ids }
|
||||
}
|
||||
})
|
||||
|
||||
export const groupNodes = defineTool({
|
||||
name: 'group_nodes',
|
||||
description: 'Group selected nodes.',
|
||||
params: {
|
||||
ids: { type: 'string[]', description: 'Node IDs to group', required: true }
|
||||
},
|
||||
execute: (figma, { ids }) => {
|
||||
const nodes = ids
|
||||
.map((id) => figma.getNodeById(id))
|
||||
.filter((n): n is FigmaNodeProxy => n !== null)
|
||||
if (nodes.length < 2) return { error: 'Need at least 2 nodes to group' }
|
||||
const parent = nodes[0]!.parent ?? figma.currentPage
|
||||
const group = figma.group(nodes, parent)
|
||||
return nodeSummary(group)
|
||||
}
|
||||
})
|
||||
|
||||
export const ungroupNode = defineTool({
|
||||
name: 'ungroup_node',
|
||||
description: 'Ungroup a group node.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Group node ID', required: true }
|
||||
},
|
||||
execute: (figma, { id }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
figma.ungroup(node)
|
||||
return { ungrouped: id }
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Component tools ──────────────────────────────────────────
|
||||
|
||||
export const createComponent = defineTool({
|
||||
name: 'create_component',
|
||||
description: 'Convert a frame/group into a component.',
|
||||
params: {
|
||||
id: { type: 'string', description: 'Node ID to convert', required: true }
|
||||
},
|
||||
execute: (figma, { id }) => {
|
||||
const node = figma.getNodeById(id)
|
||||
if (!node) return { error: `Node "${id}" not found` }
|
||||
const comp = figma.createComponentFromNode(node)
|
||||
return nodeSummary(comp)
|
||||
}
|
||||
})
|
||||
|
||||
export const createInstance = defineTool({
|
||||
name: 'create_instance',
|
||||
description: 'Create an instance of a component.',
|
||||
params: {
|
||||
component_id: { type: 'string', description: 'Component node ID', required: true },
|
||||
x: { type: 'number', description: 'X position' },
|
||||
y: { type: 'number', description: 'Y position' }
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const comp = figma.getNodeById(args.component_id)
|
||||
if (!comp) return { error: `Component "${args.component_id}" not found` }
|
||||
const instance = comp.createInstance()
|
||||
if (args.x !== undefined) instance.x = args.x
|
||||
if (args.y !== undefined) instance.y = args.y
|
||||
return nodeSummary(instance)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Page tools ───────────────────────────────────────────────
|
||||
|
||||
export const listPages = defineTool({
|
||||
name: 'list_pages',
|
||||
description: 'List all pages in the document.',
|
||||
params: {},
|
||||
execute: (figma) => {
|
||||
const pages = figma.root.children
|
||||
return {
|
||||
current: figma.currentPage.name,
|
||||
pages: pages.map((p) => ({ id: p.id, name: p.name }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const switchPage = defineTool({
|
||||
name: 'switch_page',
|
||||
description: 'Switch to a different page by name or ID.',
|
||||
params: {
|
||||
page: { type: 'string', description: 'Page name or ID', required: true }
|
||||
},
|
||||
execute: (figma, { page }) => {
|
||||
const target =
|
||||
figma.root.children.find((p) => p.name === page) ??
|
||||
figma.getNodeById(page)
|
||||
if (!target) return { error: `Page "${page}" not found` }
|
||||
figma.currentPage = target
|
||||
return { page: target.name, id: target.id }
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Variable tools ───────────────────────────────────────────
|
||||
|
||||
export const listVariables = defineTool({
|
||||
name: 'list_variables',
|
||||
description: 'List all design variables (colors, numbers, strings, booleans).',
|
||||
params: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Filter by variable type',
|
||||
enum: ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN']
|
||||
}
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
const vars = figma.getLocalVariables(args.type)
|
||||
return { count: vars.length, variables: vars }
|
||||
}
|
||||
})
|
||||
|
||||
export const listCollections = defineTool({
|
||||
name: 'list_collections',
|
||||
description: 'List all variable collections.',
|
||||
params: {},
|
||||
execute: (figma) => {
|
||||
const cols = figma.getLocalVariableCollections()
|
||||
return { count: cols.length, collections: cols }
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Eval escape hatch ────────────────────────────────────────
|
||||
|
||||
export const evalCode = defineTool({
|
||||
name: 'eval',
|
||||
description:
|
||||
'Execute JavaScript with full Figma Plugin API access. Use for operations not covered by other tools. The `figma` global is available.',
|
||||
params: {
|
||||
code: { type: 'string', description: 'JavaScript code to execute', required: true }
|
||||
},
|
||||
execute: async (figma, { code }) => {
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
||||
const wrapped = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()`
|
||||
const fn = new AsyncFunction('figma', wrapped)
|
||||
const result = await fn(figma)
|
||||
if (result && typeof result === 'object' && 'toJSON' in result) return result.toJSON()
|
||||
return result ?? null
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────
|
||||
|
||||
export const ALL_TOOLS: ToolDef[] = [
|
||||
getSelection,
|
||||
getPageTree,
|
||||
getNode,
|
||||
findNodes,
|
||||
createShape,
|
||||
render,
|
||||
setFill,
|
||||
setStroke,
|
||||
setEffects,
|
||||
updateNode,
|
||||
setLayout,
|
||||
setConstraints,
|
||||
deleteNode,
|
||||
cloneNode,
|
||||
renameNode,
|
||||
reparentNode,
|
||||
selectNodes,
|
||||
groupNodes,
|
||||
ungroupNode,
|
||||
createComponent,
|
||||
createInstance,
|
||||
listPages,
|
||||
switchPage,
|
||||
listVariables,
|
||||
listCollections,
|
||||
evalCode
|
||||
]
|
||||
273
src/ai/tools.ts
273
src/ai/tools.ts
|
|
@ -1,269 +1,28 @@
|
|||
import { valibotSchema } from '@ai-sdk/valibot'
|
||||
import { parseColor, FigmaAPI } from '@open-pencil/core'
|
||||
import { ALL_TOOLS, FigmaAPI, toolsToAI } from '@open-pencil/core'
|
||||
import { tool } from 'ai'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type { EditorStore } from '@/stores/editor'
|
||||
import type { Color } from '@open-pencil/core'
|
||||
|
||||
const nodeTypeSchema = v.picklist([
|
||||
'FRAME',
|
||||
'RECTANGLE',
|
||||
'ELLIPSE',
|
||||
'TEXT',
|
||||
'LINE',
|
||||
'STAR',
|
||||
'POLYGON',
|
||||
'SECTION'
|
||||
])
|
||||
|
||||
const rgbaSchema = v.object({
|
||||
r: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
|
||||
g: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
|
||||
b: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
|
||||
a: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1)), 1)
|
||||
})
|
||||
|
||||
const hexSchema = v.pipe(
|
||||
v.string(),
|
||||
v.regex(/^#?[0-9a-fA-F]{6,8}$/),
|
||||
v.description('Hex color like #ff0000 or #ff000080')
|
||||
)
|
||||
|
||||
const colorInputSchema = v.union([rgbaSchema, hexSchema])
|
||||
|
||||
type ColorInput = v.InferOutput<typeof colorInputSchema>
|
||||
|
||||
function resolveColor(input: ColorInput): Color {
|
||||
if (typeof input === 'string') return parseColor(input)
|
||||
return { r: input.r, g: input.g, b: input.b, a: input.a ?? 1 }
|
||||
}
|
||||
|
||||
export function createAITools(store: EditorStore) {
|
||||
return {
|
||||
create_shape: tool({
|
||||
description:
|
||||
'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
type: nodeTypeSchema,
|
||||
x: v.pipe(v.number(), v.description('X position in canvas coordinates')),
|
||||
y: v.pipe(v.number(), v.description('Y position in canvas coordinates')),
|
||||
width: v.pipe(v.number(), v.minValue(1), v.description('Width in pixels')),
|
||||
height: v.pipe(v.number(), v.minValue(1), v.description('Height in pixels')),
|
||||
name: v.optional(v.pipe(v.string(), v.description('Node name shown in layers panel'))),
|
||||
parent_id: v.optional(v.pipe(v.string(), v.description('Parent node ID to nest inside')))
|
||||
})
|
||||
),
|
||||
execute: async ({ type, x, y, width, height, name, parent_id }) => {
|
||||
const id = store.createShape(type, x, y, width, height, parent_id)
|
||||
if (name) store.renameNode(id, name)
|
||||
store.select([id])
|
||||
return { id, type, x, y, width, height, name: name ?? type.toLowerCase() }
|
||||
}
|
||||
}),
|
||||
|
||||
set_fill: tool({
|
||||
description: 'Set the fill color of a node. Accepts hex (#ff0000) or RGBA object.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
id: v.pipe(v.string(), v.description('Node ID')),
|
||||
color: colorInputSchema
|
||||
})
|
||||
),
|
||||
execute: async ({ id, color }) => {
|
||||
const c = resolveColor(color)
|
||||
store.updateNodeWithUndo(
|
||||
id,
|
||||
{ fills: [{ type: 'SOLID', color: c, opacity: 1, visible: true }] },
|
||||
'Set fill'
|
||||
)
|
||||
return { id, color: c }
|
||||
}
|
||||
}),
|
||||
|
||||
set_stroke: tool({
|
||||
description: 'Set the stroke (border) of a node.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
id: v.pipe(v.string(), v.description('Node ID')),
|
||||
color: colorInputSchema,
|
||||
weight: v.optional(v.pipe(v.number(), v.minValue(0.1)), 1),
|
||||
align: v.optional(v.picklist(['INSIDE', 'CENTER', 'OUTSIDE']), 'INSIDE')
|
||||
})
|
||||
),
|
||||
execute: async ({ id, color, weight, align }) => {
|
||||
const c = resolveColor(color)
|
||||
store.updateNodeWithUndo(
|
||||
id,
|
||||
{ strokes: [{ color: c, weight, opacity: 1, visible: true, align }] },
|
||||
'Set stroke'
|
||||
)
|
||||
return { id, color: c, weight }
|
||||
}
|
||||
}),
|
||||
|
||||
update_node: tool({
|
||||
description:
|
||||
'Update properties of an existing node: position, size, opacity, corner radius, visibility, text content, font.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
id: v.pipe(v.string(), v.description('Node ID')),
|
||||
x: v.optional(v.number()),
|
||||
y: v.optional(v.number()),
|
||||
width: v.optional(v.pipe(v.number(), v.minValue(1))),
|
||||
height: v.optional(v.pipe(v.number(), v.minValue(1))),
|
||||
opacity: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1))),
|
||||
corner_radius: v.optional(v.pipe(v.number(), v.minValue(0))),
|
||||
visible: v.optional(v.boolean()),
|
||||
text: v.optional(v.pipe(v.string(), v.description('Text content (for TEXT nodes)'))),
|
||||
font_size: v.optional(v.pipe(v.number(), v.minValue(1))),
|
||||
font_weight: v.optional(v.number()),
|
||||
name: v.optional(v.string())
|
||||
})
|
||||
),
|
||||
execute: async ({ id, corner_radius, font_size, font_weight, name, ...rest }) => {
|
||||
const changes: Record<string, unknown> = { ...rest }
|
||||
if (corner_radius !== undefined) changes.cornerRadius = corner_radius
|
||||
if (font_size !== undefined) changes.fontSize = font_size
|
||||
if (font_weight !== undefined) changes.fontWeight = font_weight
|
||||
store.updateNodeWithUndo(id, changes, 'Update node')
|
||||
if (name !== undefined) store.renameNode(id, name)
|
||||
return { id, updated: Object.keys(changes) }
|
||||
}
|
||||
}),
|
||||
|
||||
set_layout: tool({
|
||||
description:
|
||||
'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, and padding.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
id: v.pipe(v.string(), v.description('Frame node ID')),
|
||||
direction: v.picklist(['HORIZONTAL', 'VERTICAL']),
|
||||
spacing: v.optional(
|
||||
v.pipe(v.number(), v.minValue(0), v.description('Gap between items')),
|
||||
0
|
||||
),
|
||||
padding: v.optional(
|
||||
v.pipe(v.number(), v.minValue(0), v.description('Equal padding on all sides'))
|
||||
),
|
||||
padding_horizontal: v.optional(v.pipe(v.number(), v.minValue(0))),
|
||||
padding_vertical: v.optional(v.pipe(v.number(), v.minValue(0))),
|
||||
align: v.optional(
|
||||
v.pipe(
|
||||
v.picklist(['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN']),
|
||||
v.description('Primary axis alignment')
|
||||
),
|
||||
'MIN'
|
||||
),
|
||||
counter_align: v.optional(
|
||||
v.pipe(
|
||||
v.picklist(['MIN', 'CENTER', 'MAX', 'STRETCH']),
|
||||
v.description('Cross axis alignment')
|
||||
),
|
||||
'MIN'
|
||||
)
|
||||
})
|
||||
),
|
||||
execute: async ({
|
||||
id,
|
||||
direction,
|
||||
spacing,
|
||||
padding,
|
||||
padding_horizontal,
|
||||
padding_vertical,
|
||||
align,
|
||||
counter_align
|
||||
}) => {
|
||||
store.setLayoutMode(id, direction)
|
||||
const ph = padding_horizontal ?? padding ?? 0
|
||||
const pv = padding_vertical ?? padding ?? 0
|
||||
store.updateNodeWithUndo(
|
||||
id,
|
||||
{
|
||||
itemSpacing: spacing,
|
||||
primaryAxisAlign: align,
|
||||
counterAxisAlign: counter_align,
|
||||
paddingLeft: ph,
|
||||
paddingRight: ph,
|
||||
paddingTop: pv,
|
||||
paddingBottom: pv
|
||||
},
|
||||
'Set layout'
|
||||
)
|
||||
return { id, direction, spacing }
|
||||
}
|
||||
}),
|
||||
|
||||
delete_node: tool({
|
||||
description: 'Delete a node by ID.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
id: v.pipe(v.string(), v.description('Node ID to delete'))
|
||||
})
|
||||
),
|
||||
execute: async ({ id }) => {
|
||||
store.select([id])
|
||||
store.deleteSelected()
|
||||
return { deleted: id }
|
||||
}
|
||||
}),
|
||||
|
||||
select_nodes: tool({
|
||||
description: 'Select one or more nodes by ID.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
ids: v.pipe(v.array(v.string()), v.minLength(1), v.description('Node IDs to select'))
|
||||
})
|
||||
),
|
||||
execute: async ({ ids }) => {
|
||||
store.select(ids)
|
||||
return { selected: ids }
|
||||
}
|
||||
}),
|
||||
|
||||
get_page_tree: tool({
|
||||
description:
|
||||
'Get the node tree of the current page. Returns all nodes with their hierarchy, types, positions, and sizes.',
|
||||
inputSchema: valibotSchema(v.object({})),
|
||||
execute: async () => {
|
||||
return toolsToAI(
|
||||
ALL_TOOLS,
|
||||
{
|
||||
getFigma: () => {
|
||||
const api = new FigmaAPI(store.graph)
|
||||
const page = api.getNodeById(store.state.currentPageId)
|
||||
if (!page) return { error: 'No current page' }
|
||||
return {
|
||||
page: page.name,
|
||||
children: page.children.map((c) => c.toJSON())
|
||||
}
|
||||
api.currentPage = api.wrapNode(store.state.currentPageId)
|
||||
api.currentPage.selection = [...store.state.selectedIds]
|
||||
.map((id) => api.getNodeById(id))
|
||||
.filter((n): n is NonNullable<typeof n> => n !== null)
|
||||
return api
|
||||
},
|
||||
onAfterExecute: () => {
|
||||
store.requestRender()
|
||||
}
|
||||
}),
|
||||
|
||||
get_selection: tool({
|
||||
description: 'Get details about currently selected nodes.',
|
||||
inputSchema: valibotSchema(v.object({})),
|
||||
execute: async () => {
|
||||
const nodes = store.selectedNodes.value
|
||||
if (nodes.length === 0) return { selection: [] }
|
||||
const api = new FigmaAPI(store.graph)
|
||||
return {
|
||||
selection: nodes.map((n) => api.wrapNode(n.id).toJSON())
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
rename_node: tool({
|
||||
description: 'Rename a node in the layers panel.',
|
||||
inputSchema: valibotSchema(
|
||||
v.object({
|
||||
id: v.string(),
|
||||
name: v.string()
|
||||
})
|
||||
),
|
||||
execute: async ({ id, name }) => {
|
||||
store.renameNode(id, name)
|
||||
return { id, name }
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ v, valibotSchema, tool }
|
||||
)
|
||||
}
|
||||
|
||||
export type AITools = ReturnType<typeof createAITools>
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ const jsxCode = computed(() => {
|
|||
const highlightedLines = computed(() => {
|
||||
if (!jsxCode.value) return []
|
||||
const grammar = Prism.languages.jsx ?? Prism.languages.javascript
|
||||
return jsxCode.value
|
||||
.split('\n')
|
||||
.map((line) => Prism.highlight(line, grammar, 'jsx'))
|
||||
return jsxCode.value.split('\n').map((line) => Prism.highlight(line, grammar, 'jsx'))
|
||||
})
|
||||
|
||||
let copyTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
|
@ -60,16 +58,15 @@ watch(jsxCode, () => {
|
|||
<ScrollAreaRoot class="min-h-0 flex-1">
|
||||
<ScrollAreaViewport class="size-full">
|
||||
<div class="p-3">
|
||||
<div
|
||||
v-for="(html, i) in highlightedLines"
|
||||
:key="i"
|
||||
class="flex text-xs leading-5"
|
||||
>
|
||||
<div v-for="(html, i) in highlightedLines" :key="i" class="flex text-xs leading-5">
|
||||
<span
|
||||
class="mr-3 shrink-0 select-none text-right text-muted/40"
|
||||
style="min-width: 1.5em"
|
||||
>{{ i + 1 }}</span>
|
||||
<pre class="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words"><code v-html="html" /></pre>
|
||||
>{{ i + 1 }}</span
|
||||
>
|
||||
<pre
|
||||
class="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words"
|
||||
><code v-html="html" /></pre>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
|
|
|
|||
190
tests/engine/tools-ai-adapter.test.ts
Normal file
190
tests/engine/tools-ai-adapter.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { valibotSchema } from '@ai-sdk/valibot'
|
||||
import { ALL_TOOLS, FigmaAPI, SceneGraph, toolsToAI } from '@open-pencil/core'
|
||||
import { tool } from 'ai'
|
||||
import * as v from 'valibot'
|
||||
|
||||
function setup() {
|
||||
const graph = new SceneGraph()
|
||||
const figma = new FigmaAPI(graph)
|
||||
|
||||
const tools = toolsToAI(
|
||||
ALL_TOOLS,
|
||||
{
|
||||
getFigma: () => figma,
|
||||
onAfterExecute: () => {}
|
||||
},
|
||||
{ v, valibotSchema, tool }
|
||||
)
|
||||
|
||||
return { graph, figma, tools }
|
||||
}
|
||||
|
||||
describe('AI adapter', () => {
|
||||
test('generates tool for every definition', () => {
|
||||
const { tools } = setup()
|
||||
for (const def of ALL_TOOLS) {
|
||||
expect(tools[def.name]).toBeDefined()
|
||||
}
|
||||
expect(Object.keys(tools).length).toBe(ALL_TOOLS.length)
|
||||
})
|
||||
|
||||
test('each tool has description and execute', () => {
|
||||
const { tools } = setup()
|
||||
for (const [name, t] of Object.entries(tools)) {
|
||||
const aiTool = t as { description: string; execute: Function }
|
||||
expect(aiTool.description).toBeTruthy()
|
||||
expect(typeof aiTool.execute).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
test('create_shape tool works through adapter', async () => {
|
||||
const { tools, figma } = setup()
|
||||
const createShape = tools.create_shape as { execute: Function }
|
||||
const result = (await createShape.execute({
|
||||
type: 'RECTANGLE',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 50,
|
||||
name: 'Test Rect'
|
||||
})) as any
|
||||
|
||||
expect(result.id).toBeTruthy()
|
||||
expect(result.type).toBe('RECTANGLE')
|
||||
expect(result.name).toBe('Test Rect')
|
||||
|
||||
const node = figma.getNodeById(result.id)!
|
||||
expect(node.x).toBe(10)
|
||||
expect(node.y).toBe(20)
|
||||
expect(node.width).toBe(100)
|
||||
})
|
||||
|
||||
test('set_fill tool works through adapter', async () => {
|
||||
const { tools, figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(100, 100)
|
||||
|
||||
const setFill = tools.set_fill as { execute: Function }
|
||||
await setFill.execute({ id: rect.id, color: '#00ff00' })
|
||||
|
||||
const fills = figma.getNodeById(rect.id)!.fills
|
||||
expect(fills.length).toBe(1)
|
||||
expect(fills[0].color.g).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
test('get_page_tree tool returns structure', async () => {
|
||||
const { tools, figma } = setup()
|
||||
const frame = figma.createFrame()
|
||||
frame.name = 'TestFrame'
|
||||
frame.resize(200, 200)
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(50, 50)
|
||||
frame.appendChild(rect)
|
||||
|
||||
const getTree = tools.get_page_tree as { execute: Function }
|
||||
const result = (await getTree.execute({})) as any
|
||||
expect(result.page).toBeTruthy()
|
||||
expect(result.children.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('onBeforeExecute and onAfterExecute are called', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const figma = new FigmaAPI(graph)
|
||||
const calls: string[] = []
|
||||
|
||||
const tools = toolsToAI(
|
||||
ALL_TOOLS,
|
||||
{
|
||||
getFigma: () => figma,
|
||||
onBeforeExecute: () => calls.push('before'),
|
||||
onAfterExecute: () => calls.push('after')
|
||||
},
|
||||
{ v, valibotSchema, tool }
|
||||
)
|
||||
|
||||
const listPages = tools.list_pages as { execute: Function }
|
||||
await listPages.execute({})
|
||||
|
||||
expect(calls).toEqual(['before', 'after'])
|
||||
})
|
||||
|
||||
test('onAfterExecute called even on error', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const figma = new FigmaAPI(graph)
|
||||
let afterCalled = false
|
||||
|
||||
const tools = toolsToAI(
|
||||
ALL_TOOLS,
|
||||
{
|
||||
getFigma: () => figma,
|
||||
onAfterExecute: () => {
|
||||
afterCalled = true
|
||||
}
|
||||
},
|
||||
{ v, valibotSchema, tool }
|
||||
)
|
||||
|
||||
const evalTool = tools.eval as { execute: Function }
|
||||
try {
|
||||
await evalTool.execute({ code: 'throw new Error("test")' })
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(afterCalled).toBe(true)
|
||||
})
|
||||
|
||||
test('find_nodes works through adapter', async () => {
|
||||
const { tools, figma } = setup()
|
||||
figma.createRectangle().name = 'Button'
|
||||
figma.createText().name = 'Label'
|
||||
figma.createRectangle().name = 'Button Secondary'
|
||||
|
||||
const findNodes = tools.find_nodes as { execute: Function }
|
||||
const result = (await findNodes.execute({ name: 'button' })) as any
|
||||
expect(result.count).toBe(2)
|
||||
})
|
||||
|
||||
test('set_layout works through adapter', async () => {
|
||||
const { tools, figma } = setup()
|
||||
const frame = figma.createFrame()
|
||||
frame.resize(300, 200)
|
||||
|
||||
const setLayout = tools.set_layout as { execute: Function }
|
||||
await setLayout.execute({
|
||||
id: frame.id,
|
||||
direction: 'HORIZONTAL',
|
||||
spacing: 8,
|
||||
padding: 16
|
||||
})
|
||||
|
||||
const node = figma.getNodeById(frame.id)!
|
||||
expect(node.layoutMode).toBe('HORIZONTAL')
|
||||
expect(node.itemSpacing).toBe(8)
|
||||
expect(node.paddingLeft).toBe(16)
|
||||
})
|
||||
|
||||
test('render JSX works through adapter', async () => {
|
||||
const { tools } = setup()
|
||||
const render = tools.render as { execute: Function }
|
||||
const result = (await render.execute({
|
||||
jsx: '<Frame name="Card" w={200} h={100}><Text>Hello</Text></Frame>'
|
||||
})) as any
|
||||
expect(result.name).toBe('Card')
|
||||
expect(result.type).toBe('FRAME')
|
||||
})
|
||||
|
||||
test('delete + get returns error for removed node', async () => {
|
||||
const { tools, figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
const id = rect.id
|
||||
|
||||
const deleteTool = tools.delete_node as { execute: Function }
|
||||
await deleteTool.execute({ id })
|
||||
|
||||
const getNode = tools.get_node as { execute: Function }
|
||||
const result = (await getNode.execute({ id })) as any
|
||||
expect(result.error).toContain('not found')
|
||||
})
|
||||
})
|
||||
219
tests/engine/tools-cli.test.ts
Normal file
219
tests/engine/tools-cli.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { join } from 'path'
|
||||
|
||||
const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts')
|
||||
const FIXTURE = join(import.meta.dir, '../fixtures/material3.fig')
|
||||
|
||||
async function evalCode(
|
||||
code: string
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(['bun', CLI, 'eval', FIXTURE, '--code', code, '--json'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
})
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text()
|
||||
])
|
||||
const exitCode = await proc.exited
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||
}
|
||||
|
||||
function parseJSON(stdout: string): unknown {
|
||||
return JSON.parse(stdout)
|
||||
}
|
||||
|
||||
describe('CLI tool operations via eval', () => {
|
||||
test('create and read back a node', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const r = figma.createRectangle()
|
||||
r.name = 'TestRect'
|
||||
r.x = 100
|
||||
r.y = 200
|
||||
r.resize(300, 150)
|
||||
return r.toJSON()
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.name).toBe('TestRect')
|
||||
expect(result.x).toBe(100)
|
||||
expect(result.y).toBe(200)
|
||||
expect(result.width).toBe(300)
|
||||
expect(result.height).toBe(150)
|
||||
})
|
||||
|
||||
test('set fill on a node', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const r = figma.createRectangle()
|
||||
r.resize(50, 50)
|
||||
r.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||||
return { fills: r.fills }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.fills.length).toBe(1)
|
||||
expect(result.fills[0].color.r).toBe(1)
|
||||
})
|
||||
|
||||
test('set layout on a frame', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const f = figma.createFrame()
|
||||
f.resize(300, 200)
|
||||
f.layoutMode = 'VERTICAL'
|
||||
f.itemSpacing = 16
|
||||
f.paddingLeft = 20
|
||||
f.paddingRight = 20
|
||||
f.paddingTop = 20
|
||||
f.paddingBottom = 20
|
||||
return {
|
||||
layoutMode: f.layoutMode,
|
||||
itemSpacing: f.itemSpacing,
|
||||
paddingLeft: f.paddingLeft
|
||||
}
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.layoutMode).toBe('VERTICAL')
|
||||
expect(result.itemSpacing).toBe(16)
|
||||
expect(result.paddingLeft).toBe(20)
|
||||
})
|
||||
|
||||
test('create component from node', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const f = figma.createFrame()
|
||||
f.name = 'Button'
|
||||
f.resize(200, 48)
|
||||
const comp = figma.createComponentFromNode(f)
|
||||
return { name: comp.name, type: comp.type }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.name).toBe('Button')
|
||||
expect(result.type).toBe('COMPONENT')
|
||||
})
|
||||
|
||||
test('group and ungroup nodes', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const r1 = figma.createRectangle()
|
||||
r1.resize(50, 50)
|
||||
const r2 = figma.createRectangle()
|
||||
r2.resize(50, 50)
|
||||
const group = figma.group([r1, r2], figma.currentPage)
|
||||
const groupType = group.type
|
||||
const childCount = group.children.length
|
||||
figma.ungroup(group)
|
||||
const ungrouped = figma.getNodeById(group.id)
|
||||
return { groupType, childCount, ungroupedExists: ungrouped !== null }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.groupType).toBe('GROUP')
|
||||
expect(result.childCount).toBe(2)
|
||||
expect(result.ungroupedExists).toBe(false)
|
||||
})
|
||||
|
||||
test('find nodes by type on fixture', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const texts = figma.currentPage.findAllWithCriteria({ types: ['TEXT'] })
|
||||
return { count: texts.length, hasTexts: texts.length > 0 }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.hasTexts).toBe(true)
|
||||
expect(result.count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clone a node', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const r = figma.createRectangle()
|
||||
r.name = 'Original'
|
||||
r.resize(100, 100)
|
||||
const clone = r.clone()
|
||||
return {
|
||||
same: r.id === clone.id,
|
||||
cloneName: clone.name,
|
||||
cloneWidth: clone.width
|
||||
}
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.same).toBe(false)
|
||||
expect(result.cloneName).toBe('Original')
|
||||
expect(result.cloneWidth).toBe(100)
|
||||
})
|
||||
|
||||
test('reparent node into frame', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const frame = figma.createFrame()
|
||||
frame.resize(300, 300)
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(50, 50)
|
||||
frame.appendChild(rect)
|
||||
return {
|
||||
parentId: rect.parent?.id,
|
||||
isChild: frame.children.some(c => c.id === rect.id)
|
||||
}
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.isChild).toBe(true)
|
||||
})
|
||||
|
||||
test('set constraints', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const r = figma.createRectangle()
|
||||
r.resize(100, 100)
|
||||
r.constraints = { horizontal: 'CENTER', vertical: 'STRETCH' }
|
||||
return r.constraints
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.horizontal).toBe('CENTER')
|
||||
expect(result.vertical).toBe('STRETCH')
|
||||
})
|
||||
|
||||
test('set effects', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const f = figma.createFrame()
|
||||
f.resize(100, 100)
|
||||
f.effects = [{
|
||||
type: 'DROP_SHADOW',
|
||||
color: { r: 0, g: 0, b: 0, a: 0.25 },
|
||||
offset: { x: 0, y: 4 },
|
||||
radius: 8,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}]
|
||||
return { count: f.effects.length, type: f.effects[0].type }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.count).toBe(1)
|
||||
expect(result.type).toBe('DROP_SHADOW')
|
||||
})
|
||||
|
||||
test('list variables from fixture', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const vars = figma.getLocalVariables()
|
||||
const cols = figma.getLocalVariableCollections()
|
||||
return { variables: vars.length, collections: cols.length }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(typeof result.variables).toBe('number')
|
||||
expect(typeof result.collections).toBe('number')
|
||||
})
|
||||
|
||||
test('switch page', async () => {
|
||||
const { stdout, exitCode } = await evalCode(`
|
||||
const pages = figma.root.children
|
||||
const first = pages[0]
|
||||
figma.currentPage = first
|
||||
return { page: figma.currentPage.name, pageCount: pages.length }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON(stdout) as any
|
||||
expect(result.page).toBeTruthy()
|
||||
expect(result.pageCount).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
390
tests/engine/tools.test.ts
Normal file
390
tests/engine/tools.test.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { ALL_TOOLS, FigmaAPI, SceneGraph } from '@open-pencil/core'
|
||||
|
||||
function setup() {
|
||||
const graph = new SceneGraph()
|
||||
const figma = new FigmaAPI(graph)
|
||||
return { graph, figma }
|
||||
}
|
||||
|
||||
describe('tool definitions', () => {
|
||||
test('all tools have unique names', () => {
|
||||
const names = ALL_TOOLS.map((t) => t.name)
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
})
|
||||
|
||||
test('all tools have description and params', () => {
|
||||
for (const t of ALL_TOOLS) {
|
||||
expect(t.name).toBeTruthy()
|
||||
expect(t.description).toBeTruthy()
|
||||
expect(typeof t.params).toBe('object')
|
||||
expect(typeof t.execute).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
test('required params are marked', () => {
|
||||
for (const t of ALL_TOOLS) {
|
||||
for (const [key, param] of Object.entries(t.params)) {
|
||||
expect(typeof param.type).toBe('string')
|
||||
expect(typeof param.description).toBe('string')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('create_shape', () => {
|
||||
test('creates a frame', () => {
|
||||
const { figma } = setup()
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'create_shape')!
|
||||
const result = tool.execute(figma, {
|
||||
type: 'FRAME',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
name: 'Test Frame'
|
||||
}) as any
|
||||
expect(result.name).toBe('Test Frame')
|
||||
expect(result.type).toBe('FRAME')
|
||||
|
||||
const node = figma.getNodeById(result.id)!
|
||||
expect(node.x).toBe(100)
|
||||
expect(node.y).toBe(200)
|
||||
expect(node.width).toBe(300)
|
||||
expect(node.height).toBe(400)
|
||||
})
|
||||
|
||||
test('creates nested inside parent', () => {
|
||||
const { figma } = setup()
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'create_shape')!
|
||||
const parent = tool.execute(figma, {
|
||||
type: 'FRAME',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 500,
|
||||
height: 500,
|
||||
name: 'Parent'
|
||||
}) as any
|
||||
const child = tool.execute(figma, {
|
||||
type: 'RECTANGLE',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
parent_id: parent.id
|
||||
}) as any
|
||||
|
||||
const parentNode = figma.getNodeById(parent.id)!
|
||||
expect(parentNode.children.some((c) => c.id === child.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('set_fill', () => {
|
||||
test('sets solid fill', () => {
|
||||
const { figma } = setup()
|
||||
const frame = figma.createFrame()
|
||||
frame.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'set_fill')!
|
||||
tool.execute(figma, { id: frame.id, color: '#ff0000' })
|
||||
|
||||
const fills = figma.getNodeById(frame.id)!.fills
|
||||
expect(fills.length).toBe(1)
|
||||
expect(fills[0].color.r).toBeCloseTo(1)
|
||||
expect(fills[0].color.g).toBeCloseTo(0)
|
||||
expect(fills[0].color.b).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
test('returns error for missing node', () => {
|
||||
const { figma } = setup()
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'set_fill')!
|
||||
const result = tool.execute(figma, { id: 'nonexistent', color: '#ff0000' }) as any
|
||||
expect(result.error).toContain('not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('set_stroke', () => {
|
||||
test('sets stroke', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'set_stroke')!
|
||||
tool.execute(figma, { id: rect.id, color: '#0000ff', weight: 2 })
|
||||
|
||||
const strokes = figma.getNodeById(rect.id)!.strokes
|
||||
expect(strokes.length).toBe(1)
|
||||
expect(strokes[0].color.b).toBeCloseTo(1)
|
||||
expect(strokes[0].weight).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('set_effects', () => {
|
||||
test('adds drop shadow', () => {
|
||||
const { figma } = setup()
|
||||
const frame = figma.createFrame()
|
||||
frame.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'set_effects')!
|
||||
tool.execute(figma, {
|
||||
id: frame.id,
|
||||
type: 'DROP_SHADOW',
|
||||
color: '#000000',
|
||||
offset_x: 0,
|
||||
offset_y: 4,
|
||||
radius: 8,
|
||||
spread: 0
|
||||
})
|
||||
|
||||
const effects = figma.getNodeById(frame.id)!.effects
|
||||
expect(effects.length).toBe(1)
|
||||
expect(effects[0].type).toBe('DROP_SHADOW')
|
||||
})
|
||||
|
||||
test('adds blur without color', () => {
|
||||
const { figma } = setup()
|
||||
const frame = figma.createFrame()
|
||||
frame.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'set_effects')!
|
||||
tool.execute(figma, { id: frame.id, type: 'BACKGROUND_BLUR', radius: 10 })
|
||||
|
||||
const effects = figma.getNodeById(frame.id)!.effects
|
||||
expect(effects.length).toBe(1)
|
||||
expect(effects[0].type).toBe('BACKGROUND_BLUR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('update_node', () => {
|
||||
test('updates position and size', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'update_node')!
|
||||
const result = tool.execute(figma, {
|
||||
id: rect.id,
|
||||
x: 50,
|
||||
y: 75,
|
||||
width: 200,
|
||||
height: 150,
|
||||
opacity: 0.5
|
||||
}) as any
|
||||
|
||||
expect(result.updated).toContain('x')
|
||||
expect(result.updated).toContain('size')
|
||||
expect(result.updated).toContain('opacity')
|
||||
|
||||
const node = figma.getNodeById(rect.id)!
|
||||
expect(node.x).toBe(50)
|
||||
expect(node.y).toBe(75)
|
||||
expect(node.width).toBe(200)
|
||||
expect(node.height).toBe(150)
|
||||
expect(node.opacity).toBe(0.5)
|
||||
})
|
||||
|
||||
test('updates corner radius', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'update_node')!
|
||||
tool.execute(figma, { id: rect.id, corner_radius: 12 })
|
||||
|
||||
expect(figma.getNodeById(rect.id)!.cornerRadius).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('set_layout', () => {
|
||||
test('sets auto-layout', () => {
|
||||
const { figma } = setup()
|
||||
const frame = figma.createFrame()
|
||||
frame.resize(300, 200)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'set_layout')!
|
||||
tool.execute(figma, {
|
||||
id: frame.id,
|
||||
direction: 'VERTICAL',
|
||||
spacing: 16,
|
||||
padding: 20
|
||||
})
|
||||
|
||||
const node = figma.getNodeById(frame.id)!
|
||||
expect(node.layoutMode).toBe('VERTICAL')
|
||||
expect(node.itemSpacing).toBe(16)
|
||||
expect(node.paddingLeft).toBe(20)
|
||||
expect(node.paddingTop).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete_node', () => {
|
||||
test('removes a node', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'delete_node')!
|
||||
tool.execute(figma, { id: rect.id })
|
||||
|
||||
expect(figma.getNodeById(rect.id)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clone_node', () => {
|
||||
test('duplicates a node', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.name = 'Original'
|
||||
rect.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'clone_node')!
|
||||
const result = tool.execute(figma, { id: rect.id }) as any
|
||||
|
||||
expect(result.id).not.toBe(rect.id)
|
||||
expect(result.name).toBe('Original')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename_node', () => {
|
||||
test('renames a node', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'rename_node')!
|
||||
tool.execute(figma, { id: rect.id, name: 'My Rectangle' })
|
||||
|
||||
expect(figma.getNodeById(rect.id)!.name).toBe('My Rectangle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reparent_node', () => {
|
||||
test('moves node into frame', () => {
|
||||
const { figma } = setup()
|
||||
const frame = figma.createFrame()
|
||||
frame.resize(300, 300)
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(50, 50)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'reparent_node')!
|
||||
tool.execute(figma, { id: rect.id, parent_id: frame.id })
|
||||
|
||||
expect(figma.getNodeById(frame.id)!.children.some((c) => c.id === rect.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('group_nodes', () => {
|
||||
test('groups two nodes', () => {
|
||||
const { figma } = setup()
|
||||
const r1 = figma.createRectangle()
|
||||
r1.resize(50, 50)
|
||||
const r2 = figma.createRectangle()
|
||||
r2.resize(50, 50)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'group_nodes')!
|
||||
const result = tool.execute(figma, { ids: [r1.id, r2.id] }) as any
|
||||
|
||||
expect(result.type).toBe('GROUP')
|
||||
const group = figma.getNodeById(result.id)!
|
||||
expect(group.children.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('find_nodes', () => {
|
||||
test('finds by name', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.name = 'Button Primary'
|
||||
const text = figma.createText()
|
||||
text.name = 'Label'
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')!
|
||||
const result = tool.execute(figma, { name: 'button' }) as any
|
||||
expect(result.count).toBe(1)
|
||||
expect(result.nodes[0].name).toBe('Button Primary')
|
||||
})
|
||||
|
||||
test('finds by type', () => {
|
||||
const { figma } = setup()
|
||||
figma.createRectangle()
|
||||
figma.createRectangle()
|
||||
figma.createText()
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')!
|
||||
const result = tool.execute(figma, { type: 'RECTANGLE' }) as any
|
||||
expect(result.count).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('get_node', () => {
|
||||
test('returns node details', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.name = 'Test Rect'
|
||||
rect.resize(100, 50)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'get_node')!
|
||||
const result = tool.execute(figma, { id: rect.id }) as any
|
||||
expect(result.name).toBe('Test Rect')
|
||||
expect(result.width).toBe(100)
|
||||
expect(result.height).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('page tools', () => {
|
||||
test('list_pages returns pages', () => {
|
||||
const { figma } = setup()
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'list_pages')!
|
||||
const result = tool.execute(figma, {}) as any
|
||||
expect(result.pages.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('switch_page changes page', () => {
|
||||
const { figma } = setup()
|
||||
const page2 = figma.createPage()
|
||||
page2.name = 'Page 2'
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'switch_page')!
|
||||
tool.execute(figma, { page: 'Page 2' })
|
||||
|
||||
expect(figma.currentPage.name).toBe('Page 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eval', () => {
|
||||
test('executes code with figma api', async () => {
|
||||
const { figma } = setup()
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'eval')!
|
||||
const result = await tool.execute(figma, {
|
||||
code: 'const r = figma.createRectangle(); r.name = "FromEval"; return r.name;'
|
||||
})
|
||||
expect(result).toBe('FromEval')
|
||||
})
|
||||
})
|
||||
|
||||
describe('set_constraints', () => {
|
||||
test('sets constraints', () => {
|
||||
const { figma } = setup()
|
||||
const rect = figma.createRectangle()
|
||||
rect.resize(100, 100)
|
||||
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'set_constraints')!
|
||||
tool.execute(figma, { id: rect.id, horizontal: 'CENTER', vertical: 'STRETCH' })
|
||||
|
||||
const node = figma.getNodeById(rect.id)!
|
||||
expect(node.constraints.horizontal).toBe('CENTER')
|
||||
expect(node.constraints.vertical).toBe('STRETCH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('render', () => {
|
||||
test('renders JSX string', async () => {
|
||||
const { figma } = setup()
|
||||
const tool = ALL_TOOLS.find((t) => t.name === 'render')!
|
||||
const result = (await tool.execute(figma, {
|
||||
jsx: '<Frame name="Card" w={200} h={100} bg="#FFF"><Text>Hello</Text></Frame>'
|
||||
})) as any
|
||||
expect(result.name).toBe('Card')
|
||||
expect(result.type).toBe('FRAME')
|
||||
expect(result.children.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue