Let Rules Draw the UI: Why Design-to-Code Should Work Before the LLM Arrives

“Turn this Figma frame into code” sounds like the perfect LLM task. The input is visual, the output is code, and there are enough naming decisions in the middle to keep a model pleasantly busy.

I chose a less exciting architecture for figma-to-nativewind.

The plugin first converts the selected Figma subtree into a normalized intermediate representation. Pure functions map that representation into React Native and NativeWind code. Only after valid code already exists may an optional LLM pass improve names and structure.

In other words, the model is allowed to tidy the kitchen. It is not allowed to decide whether the stove exists.

That ordering solved three problems at once: reproducibility, testability, and graceful failure. It also forced me to decide which parts of design-to-code are actually judgments and which parts are merely transformations wearing a dramatic hat.

Round 1: Stop Translating Figma Directly Into JSX

The shortest implementation would walk a Figma SceneNode and emit JSX immediately. Read a frame, print a View. Read text, print a Text. Convert padding while you are already holding the node.

That path works until a second output target appears. Then extraction, style mapping, naming, vector handling, and rendering are tangled into one vendor-specific walk.

figma-to-nativewind inserts an intermediate representation, or IR, between the design runtime and the renderer:

Figma SceneNode
  -> extract.ts
  -> framework-agnostic IR
  -> pure transforms
  -> generate-rn.ts
  -> React Native + NativeWind code

The IR stores design intent rather than Figma API objects. Width and height are represented as fill, hug, or a fixed numeric value. Layout becomes direction, alignment, justification, gap, and padding. Text has content and typography. Children remain a nested tree.

The important property is not the exact field list. It is that downstream modules no longer need the Figma runtime.

At the recorded repository state, a Next.js and Tailwind renderer is still planned rather than delivered. So “a second renderer is cheap” remains a design rationale, not a benchmark. But the seam is real: a future renderer can consume the same normalized tree without teaching itself how to walk SceneNode again.

Round 2: Make the Core Boring Enough to Unit Test

Everything downstream of extraction should be a function over data.

That sounds obvious until vectors arrive. Figma vector export requires calling exportAsync on a SceneNode, which is only reachable in the plugin host. Putting that call into a supposedly pure transform would drag the runtime back across the boundary.

The plugin handles the exception explicitly. The host walks the IR, exports vector nodes, and injects the result. The following SVG-to-JSX work returns to pure code.

The practical consequence is a much smaller expensive test surface. Style mapping, component extraction, vector conversion, name sanitation, theme parsing, and code generation can all run in Vitest with static IR fixtures. Only the extractor needs mocked Figma nodes, and end-to-end behavior needs the actual plugin runtime.

This is why I like IRs even in small tools. They are sometimes dismissed as architecture astronaut equipment. Here the type definition is modest, but it buys a clear answer to a useful question:

Which part of this pipeline requires the vendor runtime?

If the answer starts expanding beyond extraction and the one host-only vector step, the boundary is telling us something broke.

Round 3: Deterministic Does Not Mean “Exact Pixels or Nothing”

Rule-based conversion still needs judgment. Consider a Figma padding value of 15 pixels while the NativeWind scale contains a 16-pixel step.

There are two bad extremes. Always preserve the raw value, and the generated code fills with arbitrary classes such as p-[15px] even when the designer clearly intended the standard scale. Always round, and the generator silently changes genuinely intentional off-scale values.

figma-to-nativewind uses a snap tolerance. The default is one pixel at the recorded HEAD, with strict and loose modes mapping to zero and two pixels. Within the tolerance, a value maps to the nearest scale step. Outside it, the exact value survives as an arbitrary class.

The core rule looks like this:

export function spacingClass(
  prefix: string,
  px: number,
  tolerance = SNAP_TOLERANCE_PX,
  spacingTokens: Record<string, string> = {}
): string {
  const token = spacingTokens[String(Math.round(px))];
  if (token) return `${prefix}-${token}`;
  if (px === 0) return `${prefix}-0`;
  const step = snapSpacing(px, tolerance);
  return step !== null ? `${prefix}-${step}` : `${prefix}-[${Math.round(px)}px]`;
}

There is a useful priority hidden in that function:

  1. An exact imported design token wins.
  2. A nearby standard scale value comes next.
  3. The original numeric intent survives as an arbitrary value.

That is deterministic conversion without pretending the world is perfectly aligned to a scale.

The one-pixel default is not supported by a calibration study in the repository. It is a documented design choice. Being deterministic means the same input and options produce the same result; it does not magically turn every threshold into science.

Round 4: Put the LLM After the Correct Answer

Once the deterministic path can produce usable code, an LLM becomes much easier to govern.

The optional pass receives the already-generated code and the IR. Its scope is naming and structural cleanup. It is off by default, available only in the run-plugin surface, and failure falls back to the deterministic result.

This is the load-bearing split:

required value: deterministic conversion
optional value: naming and structural refinement

If the provider is unavailable, the plugin still works. If no API key exists, the plugin still works. If the model returns something unhelpful, there is still a known-good baseline to keep.

The architecture does not prove that the LLM cannot change the visual output. The “do not change the visual result” rule lives in the prompt and review discipline, not in a type-level validator. That limitation matters. Calling a model pass “cleanup” does not make it harmless by declaration (models have never been famous for respecting job titles).

The safest version of this pattern would compare the refined result against a structural or rendered invariant before accepting it. That validator is not part of the recorded design, so the article does not pretend it exists.

What Moved Out of the Model

The most interesting part of the project was watching the LLM's planned territory shrink.

Vector conversion, repeated-subtree extraction, and color-token mapping were all implemented deterministically. They moved into the rule-based pipeline because their behavior could be described and tested.

That left naming and structural cleanup—the places where several reasonable answers may exist.

This suggests a practical rule for AI-assisted code generation:

If you can write a stable fixture for the behavior, try to own it deterministically before assigning it to a model.

LLMs are valuable where judgment is real. Using one for a transformation with a clear rule makes the output harder to reproduce, harder to regression-test, and more expensive without adding meaningful intelligence.

The Architecture I Would Reuse

For another design-to-code tool, I would start with the same layers:

  1. Extract vendor objects into a normalized IR.
  2. Keep runtime-specific calls at the boundary.
  3. Make mapping and rendering pure functions.
  4. Thread options as data instead of reading global runtime state.
  5. Preserve exact values when token mapping cannot justify a substitution.
  6. Produce useful output before any model call.
  7. Let the LLM refine only what may safely degrade.

This is not an argument against using AI in code generation. It is an argument for giving AI a job with a clear blast radius.

The deterministic path should be the product. The model should be the colleague who improves the names after the product already works.

And if that colleague calls every component ContainerWrapperFinal2, you still have valid JSX underneath. Small victories.