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:
parent
54a418f555
commit
530b56acc5
|
|
@ -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]
|
||||
|
|
|
|||
192
packages/core/src/tools/calc.ts
Normal file
192
packages/core/src/tools/calc.ts
Normal 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)}` }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue