100% AI-generated Swift: the pipeline behind our iOS design system

· 13 min read

A Figma component and its iOS implementation shown side by side, scored at 99.2% similarity

Our design team maintains a complete design system in Figma: hundreds of components, 7 brand themes, one file that 8 production iOS apps depend on. This post is about getting that design system into native iOS: every component, every variant, every theme, as SwiftUI code the apps ship. Figma is the input, Swift is the output.

The objective was set upfront: fully AI-generated Swift, no manual code, following our conventions and landing pixel-perfect against the design. That sounds cheap in 2026, because anyone can paste a screenshot into a model and get a SwiftUI view that looks right. The gap between demo and production is component two hundred, theme number seven, and the Tuesday the designer moves a badge without telling anyone.

The answer is not a better prompt but a pipeline: five stages, ending in a tool called FigDiff that decides whether anything ships.

Why vibe coding does not scale

A generated design system fails in specific, predictable ways:

Each stage of the pipeline exists because one of these bit us.

The pipeline

The pipeline
Figma is the contract. Everything downstream is generated, checked, and shipped.
flow
failure loop
Figma file
source of truth
Custom Figma plugin
contracts and variables
Contract
one JSON per component
Contract tests
generated first, red until green
Agent generates Swift
structure and layout, tokens only
FigDiff eval
rendered fidelity
Snapshot tests
does it stay right?
Shipped to 8 apps
DTCG + Style Dictionary
generated deterministically
Skills and rules
instructions and conventions
Custom MCP server
narrow answers, labeled kilobytes
reference render, fetched at eval time
fail: agent revises
laps stop converging: re-extract the contract
all tests green
under 1%, no warnings
The model owns generation and interpretation; deterministic tooling owns facts, values, and acceptance. The plugin pushes the contract out; the MCP server serves the file back on demand. The agent receives the specification; the evaluator receives an independent reference. Nothing ships until every gate is green.

The single most important engineering decision in this system is what the AI is not allowed to do: the model sits in one box.

Stage 1: codify the rules before generating anything

The first thing we built was not a generator. It was two kinds of documents, written and versioned like code. AI rules encode how Swift is written in this repository: conventions like every color and spacing value comes from a token, every component exposes its variants the same way, and a list of banned patterns the model may never emit. Skills encode how the work gets done: each one owns a single step of the pipeline and refuses to run if its inputs are not ready.

This is the boring stage and the one that separates a pipeline from a prompt. Vibe coding improvises all of these decisions per component; rules and skills mean every generation produces code that looks like it came from the same engineer, and a convention change is one edit, not two hundred components' worth of chat history.

The skills ship as a Claude Code plugin, invoked under its namespace. /design-system:implement orchestrates a full component build end to end; /design-system:figdiff renders the comparison that decides whether the result is faithful.

A single do-everything prompt degrades as it grows; single-purpose skills with hard inputs and outputs, standing on a shared rulebook, compose like functions.

One run of /implement, given a Figma URL. The failing laps are real behaviour, not decoration: each measurement names one composition rule the Swift got wrong, the agent rebinds that rule to the contract, and the run only closes when the drift reaches zero. Four laps here, 12.40% to 0.00%.

The two gates in that run guard different things. The contract tests ask did you use the right values, and with a rulebook this explicit the agent passes them easily. Green proves the values. It does not prove the component, because the contract says what the designer decided, not how to build it in SwiftUI, and Figma has no VStack, no ZStack, no modifier order.

The rules constrain that translation hard, but they cannot decide it. VStack or LazyVStack, .padding().background() or .background().padding(), .frame(maxWidth: .infinity) on the container or on the child: each pair reads as the same intent and renders differently. No rule can enumerate those cases, and no assertion catches them, because the values are all correct. That is the gap FigDiff measures, and when the render disagrees with the design the measurement names the layer, the agent rebinds that one rule, and the run goes around again.

Stage 2: a custom plugin and a custom MCP server

The agent needs to read Figma. Figma has an official MCP server, and we do not use it. Cost is the obvious objection and the least interesting one. The real problem is composition.

Properties are easy. Any exporter can tell you a label is 15pt semibold in #292927. What an iOS agent has to answer is structural: which container holds that label, along which axis, at what spacing, how it aligns against its siblings, whether it hugs its content or fills its parent, and what happens to all of it in the other eleven variants. Get every property right and the wrong nesting still ships a broken card.

The official server is built for design-to-code, so it answers by generating code. get_design_context returns React and Tailwind for one node, which means composition comes back as JSX you have to parse, with auto-layout already compiled down to flex flex-col gap-[16px]. Its structured view, get_metadata, is the complement: ids, names, positions and sizes, and no styling at all. Neither call returns structure and meaning together, so you walk the tree node by node. Figma's own troubleshooting page shows where that ends, quoting a get_design_context response of 351,378 tokens against a 25,000 limit, and recommending you raise the cap.

Component sets, which is what a design system is actually made of, are the weakest spot. Asking Figma's codegen for one of our component sets returns "css": {}. Developers report the same shape: children and Code Connect snippets disappear once a component becomes a variant set, and get_design_context and get_variable_defs can disagree about which token a variant uses.

So our plugin exports what we actually need and our MCP server serves it: one contract per component set, carrying the composition tree explicitly. For one such organism that is 33 nodes, 6 levels deep, each declaring its axis, spacing, cross-axis alignment, and whether it hugs or fills, across all 12 variants in one call, with three variant axes resolved and only per-variant deltas stored.

The distinction that survives all of this is declared versus derived: which numbers the designer pinned on purpose, and which merely fell out of auto-layout. A render cannot tell you. A contract can, and that difference runs through the rest of the pipeline.

The workflow: a component appears or changes in Figma, someone runs /implement with the node, and the skill detects which of two modes applies.

The pipeline's most important artifact is its least glamorous: one JSON file per component set, every variant, every per-variant visual decision. The Swift is generated from it, the tests decode it, FigDiff resolves fixtures from it. When we say the design system is AI-generated, the honest phrasing is contract-driven, with AI doing the translation.

A trimmed excerpt. The contract: the whole set's decision table.

{
  "component": "DSProductCard",
  "figmaNode": "342:1187",
  "variantCount": 40,
  "defaults": {
    "imageRatio": "16:9",
    "titleColorIsPrimary": true,
    
  },
  "variants": [
    {
      "id": "article__large",
      "imageRatio": "4:3",
      "titleStyle": "heading.md",
      "progressBar": false,
      
    },
    … 39 more variants
  ]
}

The contract answers "what did the designer decide, per variant". A variant only carries what differs from the defaults, so the diff between two Figma versions is small and readable, which is what makes update mode possible.

Why JSON and not a markdown spec? A spec has to be understood, and understanding varies between runs; a contract only has to match.

Which raises the obvious question: if the contract holds every decision, why keep an LLM at all? Because the contract is complete about what, not how. Turning those facts into repository-aware SwiftUI, choosing composition, reusing existing primitives, respecting our APIs, is not mechanical. The model earns exactly that gap, and no more.

Stage 3: tokens are generated, but never by the model

Colors, spacing, typography, radii: none of that is AI-generated, by rule, not accident.

Design tokens leave Figma as DTCG, the W3C community-group format, and Style Dictionary turns them into Swift files deterministically: same input, byte-identical output, all 7 brand themes.

The principle: the model generates structure, deterministic tools generate values. A model writing layout code is doing what models are good at; a model retyping two hundred hex colors is an expensive, occasionally creative copy machine. Deriving every mechanical value mechanically shrinks the surface where the AI can be wrong to the one place we can verify it: the rendered result.

Stage 4: designers keep designing, so we check drift

A generated design system is only correct with respect to the Figma file as it was at generation time. Designers keep working: a padding changes, a color gets rebound, a badge moves, and nobody files a ticket, because from the designer's side nothing broke.

So drift checking is a first-class MCP tool, not a quarterly audit: it compares the live file against what the code was generated from and reports every divergence. Design moved but code did not: regenerate. Code diverged from design: a finding for a human, because either an engineer overrode something deliberately or the generation was wrong.

Drift checks make the system scalable rather than just automated: staying correct while two teams keep moving is the actual product.

Stage 5: FigDiff, the eval

The pipeline's central problem: every stage so far produces something no human wrote. So who says it is right?

iOS has a standard answer: snapshot testing. But a snapshot test asserts: this view looks the same as it did yesterday. It is a regression tool; it cannot tell you the view was ever correct, and for generated code that is the entire question. A snapshot recorded against a wrong implementation preserves the mistake forever, with a green checkmark.

What we needed first is an eval: an independent measurement against the spec, which is the Figma file. FigDiff is that comparison. Pixels are the last oracle, not the only one: the contract tests prove the decisions, the unit tests prove the logic, and FigDiff proves the render. Accessibility is encoded in the skills as convention, not yet gated by measurement.

87:96055 · true bounds · 1:1 · layout=grid
MAJOR 2.2 %
FIGMA
Bottes Andreas
Bottes hybrides
282 €470 €
400000%
SWIFT
Bottes Andreas
Bottes hybrides
282 €470 €
400000%
DIFF
FIGMA SWIFT 2.2 %
Bottes Andreas
Bottes hybrides
282 €470 €
400000%
Bottes Andreas
Bottes hybrides
282 €470 €
400000%
A FigDiff report, reconstructed for this article on a stand-in card: the real component and its reports are internal. Coral is the Figma render, cyan is the iOS render, and where the two agree they combine into white. They agree on what to draw and disagree on where: the image and the title sit nearly flush, and the offset widens on every line below them. That is what a line-height problem looks like before you know that is what it is.

FigDiff renders every variant natively, pulls the matching Figma node through the same MCP, and produces the three-panel verdict that landed at the end of the /implement run above: Figma export, native Swift render, pixel difference. Snapshot testing takes over after FigDiff has said yes: the eval proves the implementation, the snapshot protects it. In the wrong order, snapshots embalm mistakes.

Figma and iOS do not render alike, and you have to engineer around it

The naive version, subtract the images and report a percentage, fails immediately: Figma and iOS are different rendering engines, and text antialiasing alone can make two identical layouts differ by double digits. Every glyph edge and every rounded corner lands on slightly different subpixels, so the difference image lights up along every contour in the design while the layout is perfectly correct.

Two mechanisms absorb that noise. A 2px gaussian blur on both images before subtracting, and then the one that does most of the work: a pixel is only counted if it differs and is not within a point of an edge that both renders agree is there. Shared contours are where rasterizers legitimately disagree, so a difference sitting on one is forgiven; a difference in open space is real. The run reports both numbers, raw and net, so the size of that excusal stays visible instead of being quietly absorbed.

That buys a usable percentage, and it costs the metric its eyesight in three specific ways. This is the part worth understanding, because each blind spot was found by a bug that shipped, not by reasoning ahead.

The blur dilemma
One 5px vertical shift, the same metric, two answers.
Unblurred · the truth
Blurred · what the metric sees
FigmaiOS
5PX SHIFT every line doubled, offset grows down the card
0.31% · PASS pairs merged, difference under the noise floor
A threshold high enough to forgive rasterizer noise is high enough to forgive this.
Whatever absorbs Figma-versus-iOS rendering noise also absorbs the evidence of a real vertical shift. The two are the same size, so no single threshold separates them.

Blind to a uniform shift. The percentage answers how many pixels disagree, which says nothing about where the content sits. So a second number is computed on the unblurred images: take the brightness gradient, sum it per row, and find the row that is the centre of mass of all that edge energy. In plain terms, the average height of the ink. It ignores colour and survives the blur, because moving a text block down moves its centre of mass down whatever you do to the edges. A shift past three points is a finding on its own.

Blind to a wrong colour. The threshold is a sum across the three channels, tuned high enough to tolerate rasterizer fringing, so an error spread evenly over a whole surface never trips it. A card background off by 16 of 255, plainly wrong to the eye, scored zero. So background pixels get measured separately, per channel, with a tolerance of about four levels.

Blind to a wrong corner radius. Radii live entirely on contours, which is exactly what the excusal band forgives. So corners are measured geometrically rather than by pixel difference, and a confirmed radius drift forces the variant to at least major, whatever the percentage says.

The rule holding it together: a pass with a warning is not a pass. The run is green only when the worst variant is under one percent and no metric has raised a finding. Nothing is averaged, because averaging is how a real defect that one metric can see gets outvoted by three that cannot.

The pattern is worth naming, because it is the same one as the two guardrails earlier. Every metric here exists because a previous metric was proven blind by a bug that reached review. None of them was designed up front. A measurement you trust is one whose failure modes you have collected.

What a component actually costs

Not every component costs the same, and the pipeline's value is not visible until you compare the cheap case with the expensive one.

The button was close to a one-shot. A single commit shipped the whole thing: twenty-four variants, two sizes by three states by four styles, the spec, the Figma contract and its oracle suite. Extract the contract, generate, green, done. If every component looked like the button, none of this machinery would be worth building and a careful developer with the Figma file open would keep up.

Then there is the hardest case: an organism, a card assembled from a dozen smaller components rather than drawn as one. Three brand forks of the same set, around forty variants each: over a hundred variants of a single component. At that width nothing is a one-shot, and the tool stops being a checker and starts being a search procedure.

Deterministic where possible, probabilistic where needed

Every rule in this pipeline comes from one principle: use a deterministic tool wherever a deterministic tool can do the job, and spend the model only where nothing else can.

Token values are a script, not a prompt, because a script cannot round 14 to 16 on a bad day. The contract is extracted from Figma, not interpreted. Fixtures are derived from the contract by code. The verdict is arithmetic on two images. What remains for the model is the part that is genuinely open-ended: reading a design and writing idiomatic Swift structure and layout for it. Translation is a judgement call; a hex value is not.

The consequence is the interesting bit, because the boundary is not fixed. Every lap moves a decision out of the probabilistic column and into the deterministic one, permanently:

Every lap moves one decision across, permanently
Drift is detected, its cause is isolated, and what was once a judge verdict becomes a deterministic gate and a regression test.
lap N
code-based graders (regression suite)
model-based graders (capability frontier)
lap N+1
code-based graders (regression suite)
model-based graders (capability frontier)
lap N+2
code-based graders (regression suite)
model-based graders (capability frontier)
new components add new judgements
invariant encoded between every lap: the boundary only moves right, never back.
Converted only after root-cause diagnosis: gates assert rendered outcomes with explicit tolerances, never implementation details. Each lap graduates one root-caused judge verdict into a code-based gate and a regression test, so the deterministic share only grows, while the frontier keeps expanding.

The exergue gap was a thing the model had to infer from a design; now it is a declared property with a test pinning it. Half-leading was a per-component judgement; now it is derived from the token. Neither will ever be decided by a model again. The model got no better between those laps. The pipeline did.

Which is the actual scaling claim, and it is the opposite of the usual one. This does not scale because the model improves, which is somebody else's roadmap and not something I can schedule. It scales because every day of running it shrinks the surface where a model is allowed to be creative, and grows the surface where the answer is already determined. Run it long enough and the interesting question stops being how good the model is, and becomes how little of the job is still left to guess at.

Limits, and what comes next

If you steal one thing, steal the split: structure from the model, values from deterministic tools, and an eval whose inputs nobody can quietly adjust. Everything else is plumbing.

The honest limitations: the pipeline still waits for a human, since nothing happens until someone runs /design-system:implement by hand, and drift detection is manual too. Accessibility is enforced by the skills as convention, not yet proven by a gate. And the pipeline proves fidelity to the design, not correctness of the design: if the designer publishes a mistake, it ships the mistake faithfully.

The design system is internal, but none of the hard parts are secret. The generation was never the hard part. The hard part is deciding what to forbid and what to measure, and that part travels.