Skip to main content
Back to Case Study
Ai Design System

Building Fast in the Age of AI

Vibe coding is incredible for exploring different ways a product can take shape and iterating fast. The issue with starting a vibe-coded project is not the start, but in the subsequent maintenance and refactoring needed once we have a working product.

By the time Fixerator hit the milestone where beta felt real, the UI was a mess of inconsistencies and leftover code that was unreliable and unprofessional. Half the surfaces were light. Half were dark. We had terracotta as a primary and sage as a secondary, holdovers from an earlier brand pass that had been abandoned in spirit but never in code. The typography used a fluid Utopia scale that looked great in theory and produced 11px body text on certain viewports in practice. Every branch that touched one thing ended up generating new code from scratch, because AI was running rampant in the codebase without proper guidance on what already existed.

The border on a card in the project manager was border-earth-sand. The border on a card in the expert dashboard was border-white/10. Both were cards. Neither was wrong on its own. Together, they were a mess.

Something had to give before we put this in front of real users.

A Code-First Design System

The challenge was clear: now that we have a working prototype, we needed to make sure we have a sustainable way of maintaining the codebase. A second, less important experiment we were running was can we design consistently without involving Figma?

We got to work.

Step 1: Document the Component Inventory

Before anything could be fixed, we needed to understand what existed. That meant auditing every UI primitive in the codebase and catalog it.

We found 11 components with light and dark variants that had accumulated independently. Some components had been built twice in different parts of the app for the same purpose. Others had props that existed in the type definition but were never actually used anywhere. Raw HTML <input> and <textarea> elements were scattered across features that should have been using DS components all along.

We also inventoried the page layout layer and found the same story. Padding, scroll context, and sidebar behavior were being decided locally in each page file. Every new page was a new opportunity to make a slightly different choice.

Step 2: Streamline the Variants

With the full inventory in hand, the question became: what do we actually need?

The answer, for Fixerator, was simpler than expected. The product has a distinct personality: dark, warm, a little deadpan, the Terminator joke as a throughline. A light mode would be technically correct and feel completely wrong. More practically, maintaining two coherent themes doubles the token surface area and doubles the scope of every component decision. We were a team of two with a beta deadline.

Dark-only was the right call.

Every variant="light" prop was removed from every primitive. Dark became the default. Dark became the only option. Components that need to distinguish between surface levels use opacity values from the neutral ramp, not theme switching. The Alert component, for example: the old light-background warning style (a cream card with amber text) reads as a foreign object on dark surfaces. The new warning variant uses dark amber: same semantic meaning, cohesive with the surface it lives on.

Removing the light variants cut the component decision space in half. Every choice after this point only had to work in one context.

Step 3: Build a Cohesive Brand Identity

With the scope simplified, we could make real decisions about the token layer: the named values that everything else references.

We started with the neutrals, because those are what make dark UIs readable rather than just dark. We built a full ramp on HSL(30°), a warm undertone that avoids the cold, grey failure mode of most dark interfaces. The ramp runs from #2A2520 at the deepest to #F8F7F7 at the lightest, with enough stops to cover every surface level, border, and text hierarchy without reaching for opacity hacks everywhere.

The semantic colors each got a clear role:

  • Rust/copper replaced terracotta as the primary pair. Warmer, more legible on dark surfaces, and visually connected to the homeowner/hardware context.
  • Forest green became the secondary: success states, active navigation, confirmations. The color that reads “this worked.”
  • Slate blue became the tertiary: informational states, AI response context, links. Neutral enough not to compete.

Typography followed the same logic. The fluid Utopia scale was elegant for editorial work. For a dense application (materials lists, price tables, step-by-step plans), predictable sizing matters more than elegance. We set a 14px floor and defined a fixed type ramp. Every text size now has a name and a reason.

Step 4: Refactor Page by Page

Token layer in place, variants pruned. The last step was applying all of it across the app.

The migration off legacy tokens was tedious but mechanical: grep the codebase for bg-white, bg-surface, text-earth-brown-*, border-earth-sand, bg-earth-tan, bg-earth-cream, replace each with its dark equivalent. bg-white became bg-white/6. border-earth-sand became border-white/10. text-earth-brown-* became text-white/60 or text-white/40 depending on hierarchy level.

At the component level, this meant replacing every raw HTML <input> and <textarea> with the DS TextInput and Textarea components, converting the landing page suggestion chips from bespoke elements to Card instances, and giving the chat interface intentional DS treatment: rust user message bubbles, card-bg for AI responses, earth-cream for the text.

Halfway through the page pass, we added the three layout primitives that the audit had flagged as missing: AppShell, AppSidebar, and AppFooter. AppShell standardized the outer container and scroll context. AppSidebar became pure navigation: active state via usePathname, mobile hamburger baked in. AppFooter is just a footer, but now it is the same footer everywhere.

The last piece was a design system showcase page: a full component inventory with all states rendered. Not for users. For us, and for the AI. The next time a branch needs to build a new surface, the answer to “what should I reach for?” is a URL, not a memory. That is the real fix for the problem we started with.

What It Actually Changed

The most useful outcome was not the palette or the components. It was the shared vocabulary.

Before the refactor, two people (or two AI-assisted branches) could independently reach for different tokens to express the same semantic need and both be making a reasonable local decision. After, there is a right answer. Border opacity on a card is border-white/10. Primary text is earth-cream. Interactive primary is rust. You do not have to negotiate it. You look it up and move on.

The beta went live with a UI that felt like one person had made it. For a product built the way we built this one, that is not a given. The design system is what made it possible.

About this post

Wilma Huertas

Written by

Wilma Huertas

Part of the case study

Fixerator: AI-Powered Home Project Planning

Read the case study