4. The design loop

Every skill and the AI panel follow the same loop.

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.

text
→ 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.

text
→ 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.

text
→ 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).

text
→ 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).

text
→ 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.

text
→ 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.

text
→ 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).

text
→ 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.

text
→ 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/* and color/* 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 to system.design first. 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”.