7
 min read
July 21, 2026
|
Updated: 

Teaching AI to Migrate Code

Engineering

Here's how we got AI to migrate 12,000+ design tokens to dark mode with 98.75% accuracy - the scoring matrix, iterative UX process, and lint gate that made it stick.

The Problem No One Wants to Touch

Every design system team eventually hits ”The Migration”. You look at your codebase and realize hundreds of components are full of raw utility classes like bg-gray-300 and text-blue-700, and they all need to become theme, token-based classes like bg-surface-tertiary and text-content-primary.

Our main customer-facing web app had accumulated thousands of utility token usages across hundreds of files. They worked, but they carried zero semantic meaning. They couldn't adapt to theme changes; dark mode was simply not going to happen with these in place.

We needed to migrate over 12,000 individual token instances across our codebase without breaking the UI for a single user. Most teams would budget a full quarter for this. We did it in a fraction of that time using AI and a structured scoring system, leaving us with just 150 bugs to fix manually.

That is a 98.75% accuracy rate. Here is how we did it.

Why "Just Asking" the AI Fails

The naive approach is tempting. Paste a component into an LLM and say, "Convert text-gray-900 to the right theme token." It doesn't work.

text-gray-900 maps to #666666. But in a themed system, #666666 could mean multiple things depending on context:

  • text-content-tertiary (low-priority text)
  • text-content-status-none (neutral status indicators)
  • text-interaction-neutral-press (pressed button states)

A hex value doesn't tell you intent. Without structure, the AI guesses—and it guesses wrong often enough to create hundreds of subtle visual bugs that only show up when you flip to dark mode.

LLMs Are Probabilistic, and That Matters Here

LLMs predict the most probable next token, not the correct one. Ask the same model to migrate text-gray-500 twice, and you might get text-content-secondary once and text-content-tertiary the next. Both are plausible; only one is correct. Across 12,000 instances, even a tiny 2% inconsistency rate introduces 240 bugs.

On top of that, an LLM sounds equally confident whether it's 99% sure or 51% sure. Both answers arrive with the same calm, unblinking certainty—the confidence of someone who has never once said "I'm not sure about this one." And midway through a long context window, the model drifts, applying slightly different logic to file #47 than it did to file #1.

The problem isn't the model; it's what you ask it to do. "Which one of 15 tokens is correct?" is a hard prediction. "Is this an error icon?" is easy. If you break one hard question into three easy ones, score each independently, and let a formula decide, the AI stops guessing.

The Prerequisite: UX Collaboration and Iterative Refinement

Before we could build a mathematical formula to guide the AI, we had to establish our ground truth. We couldn't just guess what the design team intended; we had to learn to speak their exact language.

1. Aligning on Token Grammar

We started by embedding ourselves closely with our UX designers. We needed to deeply understand their specific nomenclature and the architectural "why" behind their token system—knowing precisely where a token should be used, and more importantly, where it shouldn't. An AI can't map intent if you haven't clearly defined what that intent looks like in the design system.

2. The Iterative Refinement Cycle

Once we had a shared vocabulary, we didn't launch a massive, automated run. We treated the early migration as an iterative training loop to build our eventual ruleset:

  • Chunking the Code: We broke the codebase down into small, manageable slices of components to refactor.
  • Analyzing the Output: After the AI processed a chunk, we audited the results alongside UX to check for alignment.
  • Interrogating the AI: When a token was replaced incorrectly, we didn't just overwrite it. We asked the AI for its explicit reasoning to see why it miscalculated.
  • Updating the "Skill File": Every single mistake revealed a gap in our instructions. With every iteration, we updated a centralized rules and skills file—adding edge cases, clarifying context clues, and sharpening boundaries.

With each successive chunk, the engineering and UX teams gained a crystal-clear understanding of the exact logical gates required to choose a token. This real-world experience became the foundation of our scoring rubric.

The Token Hierarchy

From our alignment sessions, we mapped the clear structural hierarchy the AI needed to target:

Layer 1: Raw Primitives   ( e.g., gray-300 = #e0e1e3 )
Layer 2: Semantic Aliases ( e.g., neutral-300 = #e0e1e3 )
Layer 3: Theme Tokens     ( e.g., surface-tertiary = #e0e1e3 )

Multiple Layer 3 tokens can resolve to the same hex value today but diverge in dark mode. For example, both surface-tertiary and content-disabled might map to #e0e1e3 in light mode. In dark mode, surface-tertiary becomes #2a2a2d while content-disabled becomes #5c5c5e. Pick the wrong one now, and your dark mode ships with invisible text on matching backgrounds.

The Migration Matrix

Using the data and edge cases gathered from our UX syncs, we built a scoring matrix that turned ambiguous choices into measurable decisions. Instead of guessing the final token, the AI evaluates each potential token candidate across three axes on a 0–3 scale:

Hex Value Match Context Appropriateness Theme Alignment
Score 3 Exact match Perfect fit (e.g., error icon → error token) Exact semantic token exists (e.g., text-status-error)
Score 2 Very close (±10%) Good fit (e.g., warning banner → warning token, though it could plausibly be neutral) Close token exists (e.g., text-content-secondary)
Score 1 Moderately close (±25%) Neutral, no specific contextual meaning Only generic tokens available (e.g., text-content-primary for a specialized use case)
Score 0 Poor match (>25%) Contradicts expected design usage No appropriate token exists in the system

How It Decides

The AI adds up the three scores for every candidate token:

  • 7–9 (High Confidence): Safe to migrate. Use this theme token.
  • 4–6 (Ambiguous): Compare candidates. Prefer the theme token if the context score is 2 or higher (the element's architectural role is clear).
  • 1–3 (Low Confidence): Fall back to the closest raw hex match to avoid breaking layout rules.

These weights and thresholds worked for our token system. Yours might need different axes entirely (like animation tokens caring about easing curves instead of hex values). The point is having a mathematical formula at all, not this specific one.

The Matrix in Action

Say you have an error icon using text-red-500. The hex match to our new token system might only be moderate (1), but the code context is clearly an error icon (3), and an exact text-status-error token exists in the system (3).

Total Score = 7. The AI picks intent over hex proximity.

A regex or string comparison can't do this—it sees text-red-500 as a flat, literal label. An LLM utilizing the matrix reads the surrounding context, understands that an icon in a danger state carries semantic intent, and successfully selects the right token even when multiple theme tokens resolve to the exact same hex value in light mode.

Seeing It in a Real Component: Button States

The error-icon example is one token. The intuition lands harder on a component with states. Take our primary Button. Every interactive state is its own theme token — and that's the whole point, because each state diverges in dark mode:

/* Button.module.css — primary variant */
@apply border-transparent bg-interaction-primary-default;
  &:not(:disabled) {
    @apply hover:bg-interaction-primary-hover active:bg-interaction-primary-press;
  }

/* destructive variant */
@apply border-interaction-destructive-default bg-interaction-destructive-default;
  &:not(:disabled) {
    @apply hover:bg-interaction-destructive-hover active:bg-interaction-destructive-press;
  }

A naive migration sees four greens (default, hover, press, selected) that are visually almost identical in light mode and collapses them to one. That looks fine — until dark mode, where each maps to a different value and the button stops giving any pressed/hover feedback. The matrix forces the question per-state: "is this the resting state, the hover, or the press?" — three easy questions instead of one impossible "which green?"

The disabled state is the honest edge case worth showing too. We don't always have a perfect disabled token, so the button literally dims:

/* disabled primary — the commented-out tokens are the migration TODO */
@apply /* bg-interaction-neutral-contrast-default text-interaction-neutral-subtle-default */ opacity-30;

That commented-out line is the migration in progress: the theme token is staged, but until it's verified in both themes the safe fallback (opacity-30) ships. Honest beats clever.

What a Mistake Actually Looks Like

Here is the single most common misclassification, and why dark mode is the lie detector.

Both content-disabled and surface-tertiary resolve to #e0e1e3 in light mode. So this renders perfectly in production today:

// ❌ WRONG — picked by hex, not intent
<div className="bg-content-disabled">
  <Text>Section background</Text>
</div>

content-disabled is a text token. Using it for a background is semantically backwards — but invisible, because the hex matches. Then dark mode ships and the two tokens fork: surface-tertiary darkens to a panel gray, content-disabled stays a light text gray. Your section background lights up like a headlight, or your "disabled" text goes invisible against it.

The fix is the same hex, the right intent:

// ✅ RIGHT — background token for a background
<div className="bg-surface-tertiary">
  <Text>Section background</Text>
</div>

This is exactly the class of bug that made up most of our 150. Not random — systematic confusions between tokens that happen to share a light-mode hex. The matrix's Context axis exists precisely to catch them: a background element scores 0 on context for a content-* token, dragging the total below the "use theme" threshold.

Our skill file encodes the rule that came out of this: content-disabled is only ever correct on a genuinely disabled element (disabled prop/attribute) — never for default text, secondary info, or, obviously, a background.

The Final Execution Workflow

We didn't throw the whole codebase at an LLM. We tried it that way first—and it failed. The context window was simply too large: the model skipped files, mapped tokens to the wrong targets, and applied inconsistent logic from one file to the next.

So once the matrix was locked in, we ran our production migration as a tiered workflow:

  • Mechanical Codemod: A standard regex script handled the simple, unambiguous 1-to-1 primitive mappings. No AI layer needed.
  • AI Layer: Any ambiguous or complex multi-intent tokens flagged by the codemod were routed through the AI-powered Migration Matrix.
  • Light Theme First: We migrated light mode first because it was our live, production state. If the matrix picked the wrong theme token, the underlying hex values still matched in light mode. This gave us a critical visual safety net.

Dark Mode as Validation

We only tackled dark mode after the light migration was validated and shipped. Since the theme tokens were already embedded in the components (text-content-primary, bg-surface-secondary, etc.), deploying the dark theme simply meant swapping the underlying token map file.

This is where mistakes surfaced. If the AI misclassified a token in light mode—say, using content-disabled where it visually should have been surface-tertiary—dark mode instantly exposed it.

This occurred in only 150 out of our 12,000 instances. Each one was a minor, targeted fix rather than a systemic failure of the codebase. We verified the rest the old-fashioned way: eyes on every screen, clicking through every state, in both themes. No snapshot tooling, no automated visual regression. Just people looking at the product and confirming it looked right.

Keeping New Code Clean: The Lint Gate

A migration you finish on Friday is half-undone by Monday if nothing stops the next engineer from typing bg-gray-300. So the rules we learned didn't just live in a skill file for the AI — we compiled them into a custom lint rule that runs in CI on every PR.

The rule, island-tokens/no-non-theme-color-token, parses every className (and cn()/clsx()/cva() call, and template literals) and rejects any raw-palette or semantic-alias family. It's allowlist-based — only theme-tier families pass:

// .oxlintrc.json — scoped to apps/management/** and libs/management/**
"island-tokens/no-non-theme-color-token": ["error", {
  "allowedFamilies": [
    "surface", "content", "interaction", "stroke",
    "elevation", "tags", "dataViz", "nonSemantic", /* … */
  ],
  "ignoreHardcodedColors": true
}]

Anything outside that set — gray-*, blue-*, interactive-*, even Tailwind's default slate/zinc — fails with a message that points to the migration map:

"bg-gray-300" uses the raw-palette token family "gray".
Replace with a theme-tier token (surface-*, content-*, interaction-*, stroke-*, etc.).

Two details that made it stick:

  • It's opt-in per scope. The rule does nothing unless allowedFamilies is configured, so we turned it on directory-by-directory as each area finished migrating — the same chunking strategy as the migration itself, now enforced. A matching stylelint plugin covers SCSS/CSS.
  • It distinguishes the two failure tiers — raw palette vs. theme alias — so the error tells you how far a token is from compliant, not just that it's wrong.

The AI did the heavy migration once. The linter makes sure it stays migrated, forever, without a human in the loop.

Migrating Smoothly: Deprecate, Don't Demolish

The reason "light theme first" was safe isn't just ordering — it's that we never ripped the old tokens out from under live code. The raw color scale still exists; we just rewired what it points to.

In our color definitions, raw tokens that have a semantic home no longer hold a static hex — they resolve through the theme's CSS variable:

// colors.ts
export const rawColors = {
  gray: {
    300: '#E0E1E3',
    400: /* '#D2D2D2' */ 'rgb(var(--colors-interaction-neutral-contrast-bold-default-rgb) / …)',
    1010: /* '#343434' */ 'rgb(var(--colors-content-primary-rgb) / …)',
    // …
  },
}

So a not-yet-migrated text-gray-1010 still renders — and it even follows dark mode, because it now points at content-primary under the hood. Old code degrades gracefully instead of breaking. That's what let us migrate over weeks instead of in one terrifying big-bang PR.

The full deprecation ladder:

  1. Codemod the unambiguous 1-to-1s — a regex pass, no AI, no risk.
  2. AI-matrix the ambiguous ones — chunk by chunk, light mode first.
  3. Alias the legacy tokens to theme CSS vars — un-migrated code keeps working and even inherits dark mode.
  4. Turn the lint rule from warn error on each directory as it's cleaned, so finished areas can't regress.
  5. Delete the raw scale last — only once a directory is at zero violations does the legacy family come out entirely.

The principle is the same one we apply to any cross-boundary change: add the new path, keep the old one working, and only remove it once nothing depends on it. The migration was never "stop the world and convert." It was a deprecation that the codebase could absorb at its own pace — with a linter making sure the pace only ever went forward.

Wrapping Up

The Migration Matrix is not magic; it's just an engineering structure. Give the AI a formula instead of an open-ended prompt, and it stops guessing. That's the whole trick.

For us, it meant dark mode finally shipped. Our theme tokens are fully locked in. Adding a new theme tomorrow means updating a single JSON mapping file—without ever needing to touch our component code again.

Boaz Hoch

Boaz is a Senior Frontend Engineer at Island, working on the Design System & Frontend Infrastructure team behind Island's Enterprise Browser. With 11 years in frontend engineering, Boaz brings deep expertise in developer experience, performance, and styling — from CI/CD tooling and testing infrastructure to design system architecture. His work spans leading large-scale testing migrations and building the tools and standards frontend teams rely on daily the kind of work that lets a large engineering org ship fast without breaking things.

No items found.