4. The design loop
Every skill and the AI panel follow the same loop.
Esta página está em inglês; a tradução está a caminho.
Every skill and the AI panel follow the same loop. It is written in references/design-loop.md and
summarised in ui-designer’s SKILL.md.
4.1 The steps#
| # | Step | Tools | Output the agent keeps |
|---|---|---|---|
| 1 | Orient. Which file, which page, what is selected, who signs, what plan. | whoami, get_metadata (no nodeId: pages; with: outline), ud selection, ud where |
file path, page id, selection ids, seat |
| 2 | Load the system. The digest, then targeted searches for what the task needs. | ud system (resource or ud_run system.digest), get_libraries, search_design_system (one intent per query, batched) |
a component map with keys and properties; variables and styles to bind |
| 3 | Build in small atomic steps. Wrapper frame first, then one section per use_figma call; every call returns the ids it created and the bounds of what it built. |
use_figma (or ud_apply, ud insert --udx) |
ids as string literals for the next call |
| 4 | Look. One screenshot after the composition, one after each visual fix. | get_screenshot / ud_render |
what is wrong visually |
| 5 | Check. Lint the result; fix every error and the warnings that apply. | ud_lint (CMD-36); ud tree for structure |
a clean lint result |
| 6 | Report. Select the result, say what was built, what was reused, what is missing from the system, and what the user should look at. | ud_run selection.set / ud select |
— |
Why section-sized calls rather than one big script: each call is one undo step (the user can undo the footer
without losing the header), the user watches the screen grow, and a failed call loses one section’s work,
never half a section (CMD-12). Why not smaller: one use_figma per node floods history and the context. The
rule in the skill is “one call per section, or per component, or per batch of related property changes”.
Before step 4 (0.2) and step 5 (0.6) exist, the skill uses get_metadata and scripts/audit-frame.js
instead; the generated Capabilities block says which.
4.2 Budgets#
| Read | Default size | How to keep it small |
|---|---|---|
get_metadata of a page |
~40 B per node | pass a frame’s nodeId; ud tree --depth 2 |
ud system |
≤ 4 000 tokens (--budget) |
--only variables,components |
ud inspect |
one node | --fields fills,layout,text |
get_screenshot |
longer edge 1024 px | maxDimension 512 for checks, 2048 for detail |
4.3 Worked example: “design a login screen”#
The user has app/designs/app.design open, with a design system library app/designs/system.design in the
workspace, and types “Design a login screen for the mobile app” in Claude Code. The transcript below is the
sequence of tool calls (MCP notation: → call, ← result, abbreviated). Ids are illustrative.
1. Orient.
→ whoami {}
← {"handle":"Claude","email":null,"plans":[{"key":"ud::local","name":"Pro","seat":"edit"}]}
→ get_metadata {"fileKey":"app/designs/app.design"}
← [{"id":"pg-1a2b3c4d5e6f","name":"Onboarding"},{"id":"pg-7d8e9f0a1b2c","name":"Settings"}]
→ get_metadata {"fileKey":"app/designs/app.design","nodeId":"pg-1a2b3c4d5e6f"}
← <canvas id="pg-1a2b3c4d5e6f" name="Onboarding">
<frame id="nd-5c0e2a9b7f13" name="Welcome" x="0" y="0" width="390" height="844"/>
<frame id="nd-a41d6e2f0c88" name="Sign up" x="490" y="0" width="390" height="844"/>
</canvas>The existing screens are 390 × 844 and sit 100 px apart: the new frame goes at x = 980.
2. Load the system.
→ ud_run {"fileKey":"app/designs/app.design","command":"system.digest","params":{"budget":3000}}
← design system — app.design + library system.design
variables color (Light, Dark): color/bg/surface, color/bg/canvas, color/text/primary, color/text/muted,
color/action/primary, color/border/default …(24)
space: space/4 4, space/8 8, space/12 12, space/16 16, space/24 24, space/32 32
radius: radius/sm 4, radius/md 8, radius/lg 16
text Heading/H1 Inter Semi Bold 28/34, Body/M Inter Regular 16/24, Body/S Inter Regular 14/20, Label/M …
components Button (Variant=primary|secondary|ghost, Size=sm|md|lg; Label: text, Icon: instance swap)
TextField (State=default|focus|error; Label: text, Placeholder: text, Helper: boolean)
Checkbox (Checked=true|false; Label: text) · Logo · Link (Label: text)
guidance Button: "One primary button per screen." TextField: "Always with a visible label."
→ search_design_system {"fileKey":"app/designs/app.design","queries":[
{"entity":"component","query":"Button"},{"entity":"component","query":"TextField"},
{"entity":"component","query":"Checkbox"},{"entity":"component","query":"Logo"},
{"entity":"component","query":"Link"}]}
← {"results":[{"entity":"component","query":"Button","items":[{"name":"Button","type":"COMPONENT_SET",
"key":"lib:system.design#nd-0f3e7a1c9b24","properties":{"Label#1":"TEXT","Variant":"VARIANT","Size":"VARIANT"}}]},
… ]}3. Build: wrapper frame.
→ use_figma {"fileKey":"app/designs/app.design","description":"Login: wrapper frame","code":"
const page = await figma.getNodeByIdAsync('pg-1a2b3c4d5e6f');
await figma.setCurrentPageAsync(page);
const [bg, pad, gap] = await Promise.all([
figma.variables.importVariableByKeyAsync('lib:system.design#vr-6b1f0e2d3c4a'), // color/bg/surface
figma.variables.importVariableByKeyAsync('lib:system.design#vr-2c9d8e7f6a5b'), // space/24
figma.variables.importVariableByKeyAsync('lib:system.design#vr-8a7b6c5d4e3f')]); // space/32
const login = figma.createAutoLayout('VERTICAL', { name: 'Login' });
login.resize(390, 844);
login.x = 980; login.y = 0;
login.fills = [figma.variables.setBoundVariableForPaint({type:'SOLID',color:{r:1,g:1,b:1}}, 'color', bg)];
for (const side of ['paddingLeft','paddingRight','paddingTop','paddingBottom']) login.setBoundVariable(side, pad);
login.setBoundVariable('itemSpacing', gap);
const header = figma.createAutoLayout('VERTICAL', { name: 'Header', placeholder: true });
const form = figma.createAutoLayout('VERTICAL', { name: 'Form', placeholder: true });
login.appendChild(header); login.appendChild(form);
header.layoutSizingHorizontal = 'FILL'; form.layoutSizingHorizontal = 'FILL';
return { login: login.id, header: header.id, form: form.id };"}
← {"result":{"login":"nd-e3b0c44298fc","header":"nd-1c149afbf4c8","form":"nd-996fb92427ae"},
"created":["nd-e3b0c44298fc","nd-1c149afbf4c8","nd-996fb92427ae"],"changed":[],"console":[]}The empty Login frame appears on the canvas with two shimmering sections.
4. Build: header (logo instance, title, subtitle; text styles from the system).
→ use_figma {"description":"Login: header", "code":"… const logo = (await figma.importComponentByKeyAsync('lib:system.design#nd-77c0a1e2b3d4')).createInstance();
await figma.loadFontAsync({family:'Inter',style:'Semi Bold'}); await figma.loadFontAsync({family:'Inter',style:'Regular'});
const title = figma.createText(); title.characters = 'Welcome back'; await title.setTextStyleIdAsync(h1.id); …
header.placeholder = false; return { created: [logo.id, title.id, subtitle.id] };", …}
← {"result":{"created":["nd-2e7d2c03a959","nd-8f434346648f","nd-c4ca4238a0b9"]}, …}5. Build: form (two TextField instances, Checkbox, primary Button, Link).
→ use_figma {"description":"Login: form", "code":"… const email = fieldSet.defaultVariant.createInstance();
email.setProperties({'Label#1':'Email','Placeholder#2':'you@example.com'}); …
const submit = buttonSet.children.find(c => c.name === 'Variant=primary, Size=lg').createInstance();
submit.setProperties({'Label#1':'Sign in'}); … form.placeholder = false;
return { created: [...], bounds: { form: [form.width, form.height] } };", …}
← {"result":{"created":["nd-45c48cce2e2d","nd-d3d9446802a4","nd-6512bd43d9ca","nd-c20ad4d76fe9","nd-c51ce410c124"],
"bounds":{"form":[342,312]}}, …}6. Look.
→ get_screenshot {"fileKey":"app/designs/app.design","nodeId":"nd-e3b0c44298fc","maxDimension":1024}
← [image: Login (390x844 at 980,0).png] {"path":"/tmp/ud-shots/Login-nd-e3b0c44298fc.png","url":"http://127.0.0.1:41873/shots/9f2c…?t=…"}The url is a single-use loopback URL served by the editor (the call went through the bridge); headless
one-shot calls return url: null and only the path (§5.9).
The agent sees that header and form are stacked at the top and the button floats in the middle of nothing: the frame needs the form pushed to the vertical centre, and the “Forgot password?” link sits too close to the button.
7. Check.
→ ud_lint {"fileKey":"app/designs/app.design","nodeId":"nd-e3b0c44298fc"}
← {"errors":1,"warnings":1,"findings":[
{"rule":"touch-target","severity":"error","node":"nd-c51ce410c124","name":"Forgot password?","message":"20×20 < 44×44"},
{"rule":"spacing-scale","severity":"warning","node":"nd-996fb92427ae","message":"gap 10 is not on the space scale (8, 12)"}]}8. Fix (one targeted call, not a rebuild).
→ use_figma {"description":"Login: fix layout and link target", "code":"
const [login, form, link, s12] = await Promise.all([
figma.getNodeByIdAsync('nd-e3b0c44298fc'), figma.getNodeByIdAsync('nd-996fb92427ae'),
figma.getNodeByIdAsync('nd-c51ce410c124'),
figma.variables.importVariableByKeyAsync('lib:system.design#vr-3e4f5a6b7c8d')]); // space/12
login.primaryAxisAlignItems = 'CENTER';
form.setBoundVariable('itemSpacing', s12);
link.minHeight = 44; link.counterAxisAlignItems = 'CENTER';
return { changed: [login.id, form.id, link.id] };"}
← {"result":{"changed":["nd-e3b0c44298fc","nd-996fb92427ae","nd-c51ce410c124"]}, …}
→ get_screenshot {"fileKey":"app/designs/app.design","nodeId":"nd-e3b0c44298fc"}
← [image]
→ ud_lint {"fileKey":"app/designs/app.design","nodeId":"nd-e3b0c44298fc"}
← {"errors":0,"warnings":0,"findings":[]}9. Report.
→ ud_run {"command":"selection.set","params":{"nodes":["nd-e3b0c44298fc"]}}
← {"selection":["nd-e3b0c44298fc"]}I added Login (390 × 844) to the Onboarding page, next to Sign up, and selected it. It uses only system components (Logo, TextField ×2, Checkbox, Button primary/lg, Link), the
space/*andcolor/*variables and the Heading/H1 and Body/M text styles. Lint is clean. There is no “Sign in with Google” component in the library; if you want social login I can add one tosystem.designfirst. Undo takes back one step per section (4 steps).
History now shows four event groups signed Claude, with the notes “Login: wrapper frame”, “Login: header”,
“Login: form”, “Login: fix layout and link target”.