How to Migrate a Production Frontend Without Losing What Users Actually Rely On A developer detailed a process for migrating production frontends to a shared design system without losing user-facing behavior, using an AI agent to read, catalogue, and build components. The approach involves extracting design tokens, fingerprinting components, clustering recurring patterns, and evidence-gated building with three-tier testing, applied to three Angular apps including a finance platform. Every team that's tried migrating a real production app to a new framework; Angular to React, Vue to Svelte, jQuery to anything, or even just upgrading to a new major version with a different component library — hits the same trap. You either end up with a visual and behavioral regression fest buttons that almost look right, tables that sort differently, modals that pop up instead of sliding in the way users have muscle memory for , or the migration quietly becomes a full redesign, because "while we're in there" is irresistible. Six months later, nobody can explain why the new invoice screen doesn't work like the old one did. I spent the last few weeks doing this migration for real — several production apps, different ages, different UI libraries, none documented — into one shared design system, with an AI agent handling the reading, cataloguing, and building. What follows is the process that got us through it without losing the thing that actually matters: not the exact CSS, but the definitive look and the behavioral contract users already know. This isn't a framework-specific guide. The case study happens to involve Angular codebases, but the pipeline — tokens, fingerprinting, clustering, evidence-gated building, three-tier testing — works regardless of what you're migrating from or to. If your app has screens, components, and users who expect things to work a certain way, this applies. The apps Three production apps. The oldest was roughly ten years old, sitting on a forked UI library with years of small inconsistencies baked in. The newest had ~550 real screens across 26 feature modules on a modern component framework. The third was a mid-complexity finance platform with about 70 admin routes. None shared a design system. Each had independently invented its own search toolbar, confirmation modal, and paginator — three implementations of the same idea, unaware the others existed. The job: look at all that real code, find what genuinely recurring shapes existed, turn the ones with real evidence into shared components, and leave an audit trail a human could actually check. Step 0: Design tokens come before a single component The biggest mistake in a "preserve the look" migration is starting with components. If you build a Button before you've extracted color, spacing, and typography as data, you hardcode one app's blue into it. The next app's slightly-different blue has nowhere to go except a one-off override — and you've already lost the "one source of truth" you migrated for. So the first real deliverable was a token set — every recurring visual value pulled from the actual apps' stylesheets, given a role-based name, not an appearance-based one. Not blue-500. action.primary.default. The difference matters: a role-based name stays correct even when two apps that both use "the primary action color" happen to use two slightly different shades — the token is what's shared, the hex value is a fact about one app's theme. js php // tokens - tailwind theme generated, not hand-maintained module.exports = { colors: { action: { primary: { default: " 3f4395", hovered: " 363a80", disabled: " c4c5dd" }, critical: { default: " d82c0d", hovered: " b02306" }, }, text: { primary: " 505050", subdued: " 6d7175", critical: " d72c0d", }, surface: { default: " ffffff", hovered: " f6f6f7", }, }, }; Every component afterward uses bg-action-primary-default, text-text-critical — never a literal hex. That single decision let three visually-different apps converge on one library without anyone fighting over whose blue wins. How the tokens were derived: not invented, extracted. We read computed styles and variable files across all three apps, found recurring values with clear semantic roles this gray is always disabled-text, this red is always destructive-action , and named them by role. Where an app hadn't overridden its UI library's defaults, we kept the default — the rule from day one was extraction records reality, it doesn't redesign. Step 1: Audit source code first, browser second The instinct is to open the running app and start clicking around, screenshotting things. That turned out to be the slower and less accurate way. Reading a component's actual template and logic file tells you, with certainty, what states exist. Every conditional render is either in the code — and the code tells you exactly what triggers it — or it's absent, which is a fact, not a guess based on how far you clicked. On one screen, a live browser walkthrough looked completely correct; reading the actual error-handling binding revealed it was wired to the wrong field — a real bug that never would've surfaced by clicking, because the visual states looked identical by coincidence. So the process was: read the router config first to get a complete inventory of every real screen not the ones someone remembers — the ones actually registered . Then for each distinct UI region, write a fingerprint: json { "id": "app-b toolbar-search-filter", "region": "Shared search toolbar: search input, rows-per-page select, sort control, export icon, collapsible filter accordion.", "determinedFrom": "source", "source": "shared/components/search-filter/search-filter.component.html", "note": "One narrow extra found: a single occurrence of a date-picker icon opening a modal — held, not built, below the evidence bar." } The browser wasn't abandoned — it was demoted to fallback, used only for confirming live deployments match source, catching truly runtime-only behavior, and a final visual gut-check. Critically: every inconsistency gets written down, not cleaned up. A dead route registered twice, a modal whose template was a leftover copy-paste from a different component, three separate implementations of the same query-builder inside one app — all recorded exactly as found. The temptation to "fix" an obvious inconsistency is exactly what turns a migration into an unplanned redesign. Step 2: Cluster before you build With 550+ screens in the largest app alone, you can't treat every screen's UI as unique. The next stage groups fingerprints by structural and behavioral similarity — not by which screen they came from, but by what shape they share. json { "id": "record-card-screens", "screens": "payment-records", "withdrawal-requests", "transactions" , "recurringPattern": "Card-based repeating record list: leading checkbox, permission-gated action menu, typed detail fields, pagination", "occurrences": 16, "catalogRef": "no existing match — new gap" } This is where the evidence bar kicks in: does this pattern recur enough to justify permanent shared infrastructure, or is it a one-off that happens to look reusable? We used roughly three independent real occurrences as the threshold. Below it, the finding gets recorded and held. Above it, it becomes a build candidate. Some patterns sat "held, pending more evidence" for weeks before a second or third sighting justified them; a few never crossed the bar and are still just documented observations, on purpose. Step 3: Match against the catalog — never silently drop a gap Every cluster gets checked against the design system as it exists right now: exact match, partial match with a named diff, or no match. The classification can't be vague. The non-negotiable rule: a gap never gets absorbed into "close enough" and never gets silently skipped. Every partial or none produces a real, permanent, trackable proposal — what the gap is, how many occurrences justify it, what's different from anything existing, and a status pending until a human decides . json { "id": "default-table-component", "status": "pending", "proposedAction": "new-component", "evidence": { "occurrences": 16 }, "diff": "No catalog component models this shape. Existing table is column-based, not card-based.", "decision": null } A human can scan pending proposals at any point and make real prioritization calls — instead of trusting that "the tool handled it." Every component that ships carries its own evidence trail: which app, which screens, how many sightings, decided by whom. Step 4: Build behavioral contracts, not visual copies This is where "don't lose the behavioral patterns" gets enforced in code. The instinct when rebuilding a table is to copy the markup. The right instinct is to copy the contract: what can the user do, and how does the consuming code control it? tsx export interface TableProps