From 530b56acc518e85111dfec9ef7e2c0fc0dc56939 Mon Sep 17 00:00:00 2001 From: Anton A S Date: Wed, 11 Mar 2026 15:55:19 +0300 Subject: [PATCH] Add calc tool for layout arithmetic, make set_layout direction optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add calc tool (recursive descent parser) so AI uses exact math instead of error-prone mental arithmetic for layout sizing - Make set_layout direction param optional — keeps current layoutMode when only changing spacing/padding (eliminates ~50% of NO-OP mutations) - Add set_layout to SKIP_ARGS in NO-OP detection - Update system prompt: calc in workflow, mandatory before rendering into fixed-size containers --- packages/core/src/tools/ai-adapter.ts | 13 +- packages/core/src/tools/calc.ts | 192 ++++++++++++++++++++++++++ packages/core/src/tools/describe.ts | 31 ++++- packages/core/src/tools/modify.ts | 5 +- packages/core/src/tools/registry.ts | 5 +- src/ai/system-prompt.md | 29 ++-- 6 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/tools/calc.ts diff --git a/packages/core/src/tools/ai-adapter.ts b/packages/core/src/tools/ai-adapter.ts index 44af77c58..0ce94c073 100644 --- a/packages/core/src/tools/ai-adapter.ts +++ b/packages/core/src/tools/ai-adapter.ts @@ -94,7 +94,7 @@ function emitToolLog( if (def.mutates && !error) { nodeAfter = captureNodeSnapshot(figma, args) if (nodeBefore && nodeAfter) { - unchangedProps = detectUnchangedProps(args, nodeBefore, nodeAfter) + unchangedProps = detectUnchangedProps(def.name, args, nodeBefore, nodeAfter) } } @@ -202,14 +202,25 @@ const ARG_TO_NODE_PROP: Record = { mode: 'blendMode' } +/** Args that are parameters to the tool, not node properties to track */ +const SKIP_ARGS: Record> = { + set_effects: new Set(['type', 'color', 'offset_x', 'offset_y', 'radius', 'spread']), + set_fill: new Set(['type', 'color']), + set_stroke: new Set(['type', 'color']), + set_layout: new Set(['align', 'counter_align', 'padding', 'padding_horizontal', 'padding_vertical']), +} + function detectUnchangedProps( + toolName: string, args: Record, before: Record, after: Record ): string[] { + const skipSet = SKIP_ARGS[toolName] const unchanged: string[] = [] for (const [argKey, argVal] of Object.entries(args)) { if (argKey === 'id' || argVal === undefined) continue + if (skipSet?.has(argKey)) continue const nodeProp = ARG_TO_NODE_PROP[argKey] ?? argKey const beforeVal = before[nodeProp] const afterVal = after[nodeProp] diff --git a/packages/core/src/tools/calc.ts b/packages/core/src/tools/calc.ts new file mode 100644 index 000000000..d43e33652 --- /dev/null +++ b/packages/core/src/tools/calc.ts @@ -0,0 +1,192 @@ +import { defineTool } from './schema' + +/** + * Recursive descent parser for safe arithmetic expressions. + * Supports: + - * / % ** ( ) and Math.min/max/floor/ceil/round/abs/sqrt/pow + */ + +const MATH_FUNCTIONS: Record number> = { + 'Math.min': (...args) => Math.min(...args), + 'Math.max': (...args) => Math.max(...args), + 'Math.floor': (n) => Math.floor(n), + 'Math.ceil': (n) => Math.ceil(n), + 'Math.round': (n) => Math.round(n), + 'Math.abs': (n) => Math.abs(n), + 'Math.sqrt': (n) => Math.sqrt(n), + 'Math.pow': (base, exp) => Math.pow(base, exp), +} + +class Parser { + private pos = 0 + constructor(private input: string) {} + + parse(): number { + this.skipWhitespace() + const result = this.parseAddSub() + this.skipWhitespace() + if (this.pos < this.input.length) { + throw new Error(`Unexpected character '${this.input[this.pos]}' at position ${this.pos}`) + } + return result + } + + private parseAddSub(): number { + let left = this.parseMulDiv() + this.skipWhitespace() + while (this.pos < this.input.length && (this.peek() === '+' || this.peek() === '-')) { + const op = this.advance() + const right = this.parseMulDiv() + left = op === '+' ? left + right : left - right + this.skipWhitespace() + } + return left + } + + private parseMulDiv(): number { + let left = this.parseExponent() + this.skipWhitespace() + while (this.pos < this.input.length && (this.peek() === '*' || this.peek() === '/' || this.peek() === '%')) { + const op = this.advance() + const right = this.parseExponent() + if (op === '*') left *= right + else if (op === '/') left /= right + else left %= right + this.skipWhitespace() + } + return left + } + + private parseExponent(): number { + let base = this.parseUnary() + this.skipWhitespace() + if (this.pos < this.input.length - 1 && this.input[this.pos] === '*' && this.input[this.pos + 1] === '*') { + this.pos += 2 + const exp = this.parseUnary() + base = base ** exp + } + return base + } + + private parseUnary(): number { + this.skipWhitespace() + if (this.peek() === '-') { + this.advance() + return -this.parseUnary() + } + if (this.peek() === '+') { + this.advance() + return this.parseUnary() + } + return this.parsePrimary() + } + + private parsePrimary(): number { + this.skipWhitespace() + + if (this.peek() === '(') { + this.advance() + const val = this.parseAddSub() + this.skipWhitespace() + this.expect(')') + return val + } + + if (this.input.startsWith('Math.', this.pos)) { + return this.parseMathFunction() + } + + return this.parseNumber() + } + + private parseMathFunction(): number { + const start = this.pos + while (this.pos < this.input.length && /[A-Za-z.]/.test(this.input[this.pos])) { + this.pos++ + } + const name = this.input.slice(start, this.pos) + const fn = MATH_FUNCTIONS[name] + if (!fn) throw new Error(`Unknown function: ${name}`) + + this.skipWhitespace() + this.expect('(') + const args: number[] = [] + this.skipWhitespace() + if (this.peek() !== ')') { + args.push(this.parseAddSub()) + this.skipWhitespace() + while (this.peek() === ',') { + this.advance() + args.push(this.parseAddSub()) + this.skipWhitespace() + } + } + this.expect(')') + return fn(...args) + } + + private parseNumber(): number { + this.skipWhitespace() + const start = this.pos + if (this.peek() === '-' || this.peek() === '+') this.pos++ + while (this.pos < this.input.length && /[0-9.]/.test(this.input[this.pos])) { + this.pos++ + } + const str = this.input.slice(start, this.pos) + const num = Number(str) + if (str.length === 0 || Number.isNaN(num)) { + throw new Error(`Expected number at position ${start}, got '${this.input.slice(start, start + 10)}'`) + } + return num + } + + private skipWhitespace(): void { + while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) { + this.pos++ + } + } + + private peek(): string { + return this.input[this.pos] ?? '' + } + + private advance(): string { + return this.input[this.pos++] ?? '' + } + + private expect(ch: string): void { + if (this.input[this.pos] !== ch) { + throw new Error(`Expected '${ch}' at position ${this.pos}, got '${this.input[this.pos] ?? 'EOF'}'`) + } + this.pos++ + } +} + +function evaluate(expr: string): number { + return new Parser(expr).parse() +} + +export const calc = defineTool({ + name: 'calc', + description: + 'Arithmetic calculator. ALWAYS use this instead of mental math for layout calculations. ' + + 'Evaluates a math expression and returns the numeric result. ' + + 'Examples: "844 - 56 - 96 - 82" → 610, "Math.floor(390 * 0.6)" → 234', + params: { + expr: { + type: 'string', + description: 'Math expression, e.g. "844 - 56 - 96 - 82" or "Math.min(300, 400 - 2 * 20)"', + required: true + } + }, + execute: (_figma, { expr }) => { + try { + const result = evaluate(expr) + if (!Number.isFinite(result)) { + return { error: `Expression "${expr}" produced ${String(result)}` } + } + return { expr, result } + } catch (e) { + return { error: `${e instanceof Error ? e.message : String(e)}` } + } + } +}) diff --git a/packages/core/src/tools/describe.ts b/packages/core/src/tools/describe.ts index 6ba2ef3eb..22fe095aa 100644 --- a/packages/core/src/tools/describe.ts +++ b/packages/core/src/tools/describe.ts @@ -527,7 +527,15 @@ function detectRole(node: SceneNode): string { return 'generic' } -function describeChild(node: SceneNode): { role: string; name: string; summary: string; id: string } { +interface ChildDescription { + role: string + name: string + summary: string + id: string + children?: ChildDescription[] +} + +function describeChild(node: SceneNode, graph: SceneGraph, depth: number, gridSize: number): ChildDescription { const role = detectRole(node) let summary = '' if (node.type === 'TEXT') { @@ -543,19 +551,32 @@ function describeChild(node: SceneNode): { role: string; name: string; summary: if (fill) summary += `, ${colorToHex(fill.color)}` if (node.cornerRadius > 0) summary += ', rounded' } - return { role, name: node.name, summary, id: node.id } + const result: ChildDescription = { role, name: node.name, summary, id: node.id } + + if (depth > 0 && node.childIds.length > 0) { + const kids: ChildDescription[] = [] + for (const childId of node.childIds) { + const child = graph.getNode(childId) + if (!child || !child.visible) continue + kids.push(describeChild(child, graph, depth - 1, gridSize)) + } + if (kids.length > 0) result.children = kids + } + return result } export const describe = defineTool({ name: 'describe', description: - 'Semantic description of a node: role, visual style, layout, children summary, and design issues.', + 'Semantic description of a node: role, visual style, layout, children summary, and design issues. Use depth=2 to see grandchildren (recommended for root frames).', params: { id: { type: 'string', description: 'Node ID', required: true }, + depth: { type: 'number', description: 'How many levels of children to include (default: 1, max: 3)' }, grid: { type: 'number', description: 'Grid size for alignment checks (default: 8)' } }, execute: (figma, args) => { const gridSize = args.grid ?? 8 + const depth = Math.min(args.depth ?? 1, 3) const raw = figma.graph.getNode(args.id) if (!raw) return { error: `Node "${args.id}" not found` } @@ -564,11 +585,11 @@ export const describe = defineTool({ const layout = describeLayout(raw) const issues = detectIssues(raw, gridSize, figma.graph) - const children: { role: string; name: string; summary: string; id: string }[] = [] + const children: ChildDescription[] = [] for (const childId of raw.childIds) { const child = figma.graph.getNode(childId) if (!child || !child.visible) continue - children.push(describeChild(child)) + children.push(describeChild(child, figma.graph, depth - 1, gridSize)) } const result: Record = { diff --git a/packages/core/src/tools/modify.ts b/packages/core/src/tools/modify.ts index c3cbfb16e..bbfd9853f 100644 --- a/packages/core/src/tools/modify.ts +++ b/packages/core/src/tools/modify.ts @@ -172,8 +172,7 @@ export const setLayout = defineTool({ id: { type: 'string', description: 'Frame node ID', required: true }, direction: { type: 'string', - description: 'Layout direction', - required: true, + description: 'Layout direction (keeps current if omitted)', enum: ['HORIZONTAL', 'VERTICAL'] }, spacing: { type: 'number', description: 'Gap between items', default: 0, min: 0 }, @@ -197,7 +196,7 @@ export const setLayout = defineTool({ const node = figma.getNodeById(args.id) if (!node) return { error: `Node "${args.id}" not found` } - node.layoutMode = args.direction as 'HORIZONTAL' | 'VERTICAL' + if (args.direction) node.layoutMode = args.direction as 'HORIZONTAL' | 'VERTICAL' node.itemSpacing = args.spacing ?? 0 node.primaryAxisAlignItems = args.align ?? 'MIN' node.counterAxisAlignItems = args.counter_align ?? 'MIN' diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 7d0406c6f..ff6fcb02b 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -37,6 +37,7 @@ import { } from './analyze' import { describe } from './describe' import { designToTokens, designToComponentMap, getCodegenPrompt } from './codegen' +import { calc } from './calc' export const ALL_TOOLS: ToolDef[] = [ // Read @@ -142,5 +143,7 @@ export const ALL_TOOLS: ToolDef[] = [ designToComponentMap, getCodegenPrompt, // Eval - evalCode + evalCode, + // Utility + calc ] diff --git a/src/ai/system-prompt.md b/src/ai/system-prompt.md index bbe8cdab1..281c91abe 100644 --- a/src/ai/system-prompt.md +++ b/src/ai/system-prompt.md @@ -96,7 +96,9 @@ Split into **2–3 render calls**, not 10. Example for a movie card: That's it — 3 renders total, not one per element. Each render fills a major section. Call `describe` on the root after each to verify. -**Sizing rule for skeleton sections:** When a section uses `grow={1}` to fill remaining space, its children that should expand must also use `grow={1}` — not `h="fill"`. And critically: give the **fixed-height** child an explicit `h={N}` that leaves enough room. If the root is 844px and header=72, actions=116, tabbar=87, then card area = 844−72−116−87 = 569px. If photo=420 that leaves only 149px for content — too little. Plan the math before rendering. +**Sizing rule for skeleton sections:** When a section uses `grow={1}` to fill remaining space, its children that should expand must also use `grow={1}` — not `h="fill"`. And critically: give the **fixed-height** child an explicit `h={N}` that leaves enough room. + +🧮 **Use `calc` for ALL layout arithmetic** — never compute sizes in your head. Call `calc("844 - 72 - 116 - 87")` → 569 to verify available space. Before rendering content into a fixed-size container, `calc` the total height of children + gaps + padding to confirm it fits. LLMs make arithmetic errors — the `calc` tool does not. ## Typography @@ -193,7 +195,7 @@ All visual elements (Rectangle, Ellipse, Star, Polygon, Line) have **no fill by # Inspecting -- `describe` — semantic info: role, style, layout, children, design issues. **Primary verification tool** — call after every render. +- `describe` — semantic info: role, style, layout, children, design issues. **Primary verification tool** — call after every render. Use `depth=2` on root to see two levels deep in one call. - `get_jsx` — JSX tree, same syntax as render. Read → tweak → render back. - `diff_jsx` — unified diff between two nodes. - `get_page_tree` — full hierarchy of the current page. @@ -204,6 +206,8 @@ All visual elements (Rectangle, Ellipse, Star, Polygon, Line) have **no fill by 🚫 **Never use `export_image`** — it's slow and wastes tokens on base64 data. Use `describe` instead. +- `calc` — arithmetic calculator. **Always use for layout math** instead of mental arithmetic: `calc("844 - 56 - 96 - 82")` → 610. Use before rendering to verify children fit, after describe to check available space, and whenever computing padding/gap/size values. + # Modifying For targeted edits, use specific tools instead of re-rendering: @@ -222,14 +226,17 @@ For targeted edits, use specific tools instead of re-rendering: # Workflow (MANDATORY) -1. `render` — skeleton (outer frame + empty sections) -2. `describe` root — verify sizes -3. `render` into section A (e.g. poster) -4. `describe` root — verify -5. `render` into section B (e.g. content) -6. `describe` root — final check -7. Fix with `set_*` / `update_node` +1. `calc` — compute section heights: `calc("844 - 56 - 96 - 82")` → available space +2. `render` — skeleton (outer frame + empty sections with calculated heights) +3. `describe` root with `depth=2` — verify sizes at all levels +4. `render` into section A (e.g. poster) +5. `describe` root with `depth=2` — verify (one call shows sections + their children) +6. `render` into section B (e.g. content) +7. `describe` root with `depth=2` — final check, `calc` totals if content might overflow +8. Fix with `set_*` / `update_node` -Typically **3 renders + 3 describes** for a full design. `describe` always targets the **root frame** — shows all sections at once. +Typically **3 renders + 3 describes** for a full design. `describe` the **root frame** with `depth=2` after each render — this shows sections AND their children in one call, so you can spot sizing issues without extra calls. -🚫 Do NOT put everything in one render. Do NOT skip `describe` between renders. +🧮 Before filling a fixed-height container, always `calc` the total: `calc("childH1 + childH2 + gaps * (N-1) + padTop + padBot")` and compare to available height from `describe`. This catches overflow before it happens. + +🚫 Do NOT put everything in one render. Do NOT skip `describe` between renders. Do NOT call `describe` on individual children when `depth=2` on the root would show the same info.