Add calc tool for layout arithmetic, make set_layout direction optional

- 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
This commit is contained in:
Anton A S 2026-03-11 15:55:19 +03:00
parent 54a418f555
commit 530b56acc5
6 changed files with 254 additions and 21 deletions

View file

@ -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<string, string> = {
mode: 'blendMode'
}
/** Args that are parameters to the tool, not node properties to track */
const SKIP_ARGS: Record<string, Set<string>> = {
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<string, unknown>,
before: Record<string, unknown>,
after: Record<string, unknown>
): 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]

View file

@ -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<string, (...args: number[]) => 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)}` }
}
}
})

View file

@ -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<string, unknown> = {

View file

@ -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'

View file

@ -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
]

View file

@ -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.