<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: rachycodes</title>
    <description>The latest articles on DEV Community by rachycodes (@devopeoluwa).</description>
    <link>https://dev.to/devopeoluwa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F687830%2F509fd236-4d3e-4de1-98eb-1972a5594a6b.jpg</url>
      <title>DEV Community: rachycodes</title>
      <link>https://dev.to/devopeoluwa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devopeoluwa"/>
    <language>en</language>
    <item>
      <title>How to Migrate a Production Frontend Without Losing What Users Actually Rely On</title>
      <dc:creator>rachycodes</dc:creator>
      <pubDate>Mon, 31 Aug 2026 17:57:13 +0000</pubDate>
      <link>https://dev.to/devopeoluwa/how-to-migrate-a-production-frontend-without-losing-what-users-actually-rely-on-1ng1</link>
      <guid>https://dev.to/devopeoluwa/how-to-migrate-a-production-frontend-without-losing-what-users-actually-rely-on-1ng1</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The apps&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Step 0: Design tokens come before a single component&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tokens -&amp;gt; tailwind theme (generated, not hand-maintained)&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#3f4395&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hovered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#363a80&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#c4c5dd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#d82c0d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hovered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#b02306&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#505050&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subdued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#6d7175&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#d72c0d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;hovered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#f6f6f7&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Step 1: Audit source code first, browser second&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app-b__toolbar-search-filter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Shared search toolbar: search input, rows-per-page select, sort control, export icon, collapsible filter accordion."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"determinedFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shared/components/search-filter/search-filter.component.html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"One narrow extra found: a single occurrence of a date-picker icon opening a modal — held, not built, below the evidence bar."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Step 2: Cluster before you build&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"record-card-screens"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"screens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"payment-records"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"withdrawal-requests"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"transactions"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recurringPattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Card-based repeating record list: leading checkbox, permission-gated action menu, typed detail fields, pagination"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurrences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"catalogRef"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no existing match — new gap"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Step 3: Match against the catalog — never silently drop a gap&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default-table-component"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"proposedAction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"new-component"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"occurrences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"diff"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No catalog component models this shape. Existing table is column-based, not card-based."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Step 4: Build behavioral contracts, not visual copies&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;tsx&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;TableProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TableColumn&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;keyField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;selectable&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;selectedRowIds&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;onSelectionChange&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;expandable&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;renderExpandedRow&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sectionHeader&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sectionHeader prop exists because a real screen pre-computes which row starts a new visual group and renders a header directly above it — with no internal grouping logic. The rebuild deliberately doesn't add "smarter" grouping, because the source doesn't have it. Matching the source's actual behavior — including its lack of cleverness — is the point.&lt;/p&gt;

&lt;p&gt;The same discipline applies to extending components. A chart component built for line charts shouldn't speculatively grow pie/donut support "just in case." It stays narrow until a second app shows a real pie chart — then gets extended as a discriminated type so the shapes stay distinct:&lt;/p&gt;

&lt;p&gt;tsx&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ChartProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ChartLineProps&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;ChartPieProps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Two real shapes, type-safe — not one bag of optional props&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And composition over duplication was the default. A "progress card" with status indicators wasn't built as a copy of the existing bare progress bar — it was built around it, so the bar stays usable on its own wherever the richer chrome isn't needed.&lt;/p&gt;

&lt;p&gt;Step 5: Auto-generate the component catalog&lt;/p&gt;

&lt;p&gt;A README that says "Button supports variant: primary | secondary" becomes a lie the day someone adds outlined and forgets to update it. If anyone — human or AI — will answer "does this already support X" by reading documentation, that documentation needs to be impossible to drift.&lt;/p&gt;

&lt;p&gt;The fix: parse the library's compiled TypeScript types with the TS compiler API and regenerate the catalog on every change. This caught a real bug: when one component's props became a discriminated union, the generator silently dropped it from the catalog entirely. Nobody would've noticed by eyeballing it; it only surfaced because we checked the component count after each regeneration. The lesson generalizes — any tool you build to keep a system honest needs the same scrutiny as the system itself.&lt;/p&gt;

&lt;p&gt;Step 6: Test at three separate levels&lt;/p&gt;

&lt;p&gt;"We tested it" meant three genuinely different things, because each catches a different class of regression.&lt;/p&gt;

&lt;p&gt;Render correctness — does it even load without throwing? Every component story gets loaded headlessly and checked for zero console errors. This caught a real bug: one story used an external placeholder URL unreachable in a sandboxed test, leaving invisible boxes where avatars should render.&lt;/p&gt;

&lt;p&gt;Visual correctness — does it actually look right? Zero errors doesn't mean "looks right." Key components get screenshotted and spot-checked against the source app, catching things like a chart library's known first-paint sizing quirk (pie chart rendering as a squished sliver before settling).&lt;/p&gt;

&lt;p&gt;Whole-screen parity — does the rebuilt page match the original, structurally and interactively? A naive pixel diff between old and new produces enormous, useless noise — anti-aliasing, font-hinting, scrollbar differences. What works is comparing region by region: crop the sidebar, the nav bar, the table header from both versions and diff each independently.&lt;/p&gt;

&lt;p&gt;screens/{screen}/&lt;br&gt;
  region-diffs/&lt;br&gt;
    sidebar-diff.png       # pixel diff, this region only&lt;br&gt;
    table-header-diff.png&lt;br&gt;
  parity-report.json       # pass/fail per region, real diff percentages&lt;/p&gt;

&lt;p&gt;Parity isn't only visual — it's interactive. Clicking a row in the original triggers a specific state change; the rebuild gets checked against the same click, the same resulting state. A screen that looks identical but responds differently to the same interaction has failed parity just as surely as one with the wrong color.&lt;/p&gt;

&lt;p&gt;Step 7: A human still approves anything permanent&lt;/p&gt;

&lt;p&gt;At no point did the pipeline unilaterally decide "this is now shared infrastructure." Clustering and matching could recommend and flag — "16 real occurrences, strong candidate" — but the decision to promote something into the shared library, extend a component's API, or accept an inconsistency as intentional stayed a human call every time.&lt;/p&gt;

&lt;p&gt;That's not an AI trust issue; it's the same reason you don't let a linter auto-merge its own suggested API changes. Anything permanent and load-bearing deserves explicit sign-off.&lt;/p&gt;

&lt;p&gt;The stuff nobody warns you about&lt;/p&gt;

&lt;p&gt;Migrations surface debt, they don't create it. More than once, auditing "one component" turned up three separate, slightly different implementations of the same feature already in the same app, undiscovered by its own team. Writing that down clearly — not silently picking a winner — is part of the job.&lt;/p&gt;

&lt;p&gt;Your test infrastructure will lie to you. One login script hung indefinitely, not because login was broken, but because a blocked analytics beacon meant a "page fully loaded" event the script waited on never fired. It looked exactly like broken auth. The fix was waiting for DOM readiness instead of network-idle.&lt;/p&gt;

&lt;p&gt;The source app keeps moving. Re-checking one app months later showed it had swapped its entire UI framework in the interim. Almost none of our design-system work broke. The abstractions were built around structure and behavior (a toolbar with search, sort, and bulk-actions), not specific markup — so a ground-up framework swap on the source side barely touched the catalog. That single result is the strongest evidence this approach actually works.&lt;/p&gt;

&lt;p&gt;Auth is disproportionately expensive. SSO handoffs, token-based logins, environments enforcing different auth rules — all of it took iterative debugging. Budget for it explicitly.&lt;/p&gt;

&lt;p&gt;The checklist&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extract design tokens before writing a single component. Name by role, not appearance.&lt;/li&gt;
&lt;li&gt;Build the fingerprint → cluster → match → propose pipeline before building components.&lt;/li&gt;
&lt;li&gt;Set a real evidence bar (~3 independent occurrences) and enforce it. Let humans override deliberately and rarely.&lt;/li&gt;
&lt;li&gt;Give every unmatched pattern a permanent, trackable proposal file.&lt;/li&gt;
&lt;li&gt;Model behavior, not markup. Copy the interaction contract, not the divs.&lt;/li&gt;
&lt;li&gt;Auto-generate your component catalog from compiled types. Test the generator itself.&lt;/li&gt;
&lt;li&gt;Test at three levels: renders without error, looks right, and matches the original both visually and interactively (region-based, not whole-page diffing).&lt;/li&gt;
&lt;li&gt;Keep a human approving anything that becomes permanent shared infrastructure.&lt;/li&gt;
&lt;li&gt;Sort out authenticated access early — it blocks everything downstream.&lt;/li&gt;
&lt;li&gt;Re-verify against live apps periodically. Source-first extraction is resilient to drift, but only if you confirm that.&lt;/li&gt;
&lt;li&gt;The honest summary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this worked because an AI "understood design." It worked because a disciplined, evidence-driven pipeline forced every decision — what to build, what to extend, what to hold, what to disclose rather than quietly fix — to be backed by real, countable sightings, tracked in files a human could read.&lt;/p&gt;

&lt;p&gt;The AI was genuinely good at the tedious part: reading hundreds of screens consistently, applying the same evidence bar to the five-hundredth screen as the first, never getting bored enough to skip disclosure. It was never the thing deciding what counted as done, what deserved to become permanent, or whether a weird inconsistency in a ten-year-old screen was a bug or a quirk worth preserving.&lt;/p&gt;

&lt;p&gt;Those calls stayed human, every time — and that's exactly why the migration kept the apps' real look and behavior instead of quietly becoming a redesign nobody asked for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm a frontend engineer who recently went through this process across multiple production codebases. If you're planning a similar migration, I'd love to hear how your approach compares — especially the parts that surprised you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>automation</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
