Vantage Circle · Design system

One design system for a
13-year-old product.

8,375 hardcoded style values, holding 459 distinct ones, consolidated into the 243 tokens both teams now build from

A design system shouldn’t require tribal knowledge. The brief was to make every module look consistent, and underneath them there was nothing to be consistent with: no tokens, no scale, no palette, and 23 different off-whites, each of which had been somebody’s reasonable local decision. So I built the foundation first, rounding 459 distinct values up to 243 canonical tokens. The job was never tidying the UI. It was building something designers and engineers could both trust, that survives the people who wrote it. It is now the operating standard for both the frontend and design teams.

BriefStakeholder objective: make every module in the app look consistent
RoleDesign Team Manager; project lead on this work, owning the design system end to end: the foundation audit, the token canon, the rollout strategy, and adoption across both teams. Working directly in the codebase, with guardrails.
Timeline2 May to 2 June 2026, about a month: from the first audit report to the token system merging into the release branch.
TeamI manage a design team of 12, including four UI/UX designers and a Principal Product Designer. Four of us worked on this project; the frontend team owned the guardrails and the builds.
ProductVantage Circle: employee recognition and rewards platform, used by 3.2M+ users across 700 companies. Multi-tenant across six regions, including a white-label build.
StackAngular 20 SPA, SCSS, Angular Material, Bootstrap 4.6 · Figma, Lucide · Claude Code, Codex · Stylelint for enforcement
OutcomeOne design language across the product: every surface now built from the same values. Type sizes, radii and colours now come from tokens in 81–93% of declarations, up from 0–11%; raw hex down from 1,998 to 126. There is no guesswork left about which value belongs to which element. New components and features are quicker to build because the decisions are already made. The foundation half of design consistency.

TL;DR

The challenge
Vantage Circle is an employee recognition and rewards platform used by 3.2M+ users across 700 companies. Thirteen years of development had left 8,375 hardcoded literals and no shared primitives underneath them: no tokens, no scale, no palette. Nothing looked broken, and that was the problem. On a multi-tenant product a hardcoded brand hex is a defect rather than a preference; it renders Vantage purple inside a customer whose brand is maroon, on a screen nobody on the team will ever open.
The strategy
Rather than standardise components first, I inverted the order to foundation first, components second and took that to the CTO for approval. A four-person squad (myself, a Principal Product Designer and two UI/UX designers) paired human design judgement with AI agents that did the mechanical work.
Management impact
  • Took designers directly into an Angular 20 branch with Claude Code and Codex, behind guardrails that surfaced a risky edit before it landed, which made working in production code a low-stakes thing to do.
  • Designed a two-step guardrail framework with the frontend team. An independent regression audit across 370 changed files found zero critical and zero confirmed high-severity defects.
  • Consolidated 459 distinct values into 243 canonical tokens. Type, radius and colour now come from tokens in 81–93% of declarations, up from 0–11%; raw hex fell from 1,998 to 126.

01The product and the strategic call

Vantage Circle is an employee recognition and rewards platform used by 3.2M+ users across 700 companies. Employees recognise each other’s work, earn points, and spend them on gift cards, merchandise and experiences.

Fig. 1 The recognition feed
The Vantage Circle recognition feed: a
    left profile card with awards, badges and greetings counts; a centre column with a recognition
    composer and a feed post; right-hand widgets for work anniversaries and upcoming milestones.
The product’s busiest surface, and the one every other module borrows patterns from; the profile card, the stat row, the card grid and the widget column all reappear across rewards, perks and redeem.

Two structural facts shape everything that follows. It is thirteen years old, built and rebuilt by successive teams. And it is multi-tenant: one codebase serving enterprise customers across six regions, each setting their own brand colour, including a white-label build where Vantage’s own identity disappears entirely. Nobody on the team can see most of the interfaces this code renders.

The brief

Make every module in the app look consistent.

The objective came from the CTO, and it was one sentence. Rewards, perks, redeem, recognition, checkout, profile, admin: each built at a different time by different people, and it read that way.

The standard play is to go straight at the components: standardise the button, the card, the dropdown, module by module until the surfaces match. Before committing the team to it, I audited the product screen by screen, and the entire stylesheet layer underneath it, to establish what those components were built out of.

No screen looked broken. No button was visibly the wrong colour. That was the finding. Thirteen years of hardcoding had produced a product where surfaces almost line up, and the whole thing reads as slightly hand-assembled without anyone being able to say why, because underneath, the components were built out of nothing. Just 8,375 hardcoded values, every one decided locally.

Grouping the colours by perceptual distance in CIELAB, below the threshold at which two colours are reliably distinguishable, found 26 clusters of values nobody could tell apart on screen, holding 113 of the values and 67% of all colour usage. The largest held 23 different off-whites, every one of them meaning “the very light background.”

Fig. 2 One colour, or 23? 23 values · 177 uses · real values, unmodified
#f6f6f786#f2f4f722#f9fafb19#f6f5f77#f2f2fb7#f9f9fc5#f7f8fa5#f6f7f84#f5f5f54#f7f7f73#f9f9f92#f4f4f42#fafafa1#f9f8fc1#f8f9fa1#f8f7fc1#f7f9fb1#f7f9f91#f6f6f61#f4f5fe1#f3f5f71#f3f2f51#f1f3f51

16#eef0f4

8#e5e5e5

7#33475b

7#f9f5ff

5#eaeaea

4#f6fef9

4#f0eeff

3#ecfdf3

3#f0f9ff

3#d5d5d5

2#ffffff

2#d9dde7

2#637281

2#d0d5dd

2#949fb7

2#cecece

2#eddcef

2#c3c3c3

2#9ca3af

2#878787

2#dadada

2#e4deff

2#e0e0e0

2#b9b9b9

2#666666

Above: the largest cluster, drawn full-bleed with no gaps. Below: the other 25 clusters, each collapsing to the value the platform already used most. None of these was a mistake. Each was somebody matching a colour by eye, or copying a neighbouring file, or working from a mockup that had itself drifted. Every instance is defensible; the aggregate is not.

Twelve ways to say “click here”

There was no button component either, only habits accumulated in strata: three overlapping systems (in-house, Bootstrap and Angular Material) competing in one cascade, each with its own idea of height, radius, padding and hover.

Fig. 3 Seven of the twelve ways to say “click here” each drawn from its real declarations
As it renderedWhat it wasInstances
In-house “small accept”40×120px, 25px radius, sets no colour at all111
In-house tenant-coloured34.2px tall, 14.4px label, redefined in 11 files75
In-house generic40px tall, 190px minimum width, also no colour57
Bootstrap35px tall, 4px radius, 16px label64
Angular Material36px tall, its own letter-spacing and ripple94
Bespoke one-offs, 25+ named classes16px radius here, 4px or 25px elsewhere~60
Raw <button>, no class at allwhatever the browser draws37
Three overlapping systems competing in one cascade, each with its own idea of height, radius, padding and hover. The tenant-coloured button is one class redefined in eleven component files: tonal where the mixin defines it, solid brand in six of them, a partner’s blue in one, grey when disabled, and a smaller label in another. Two of the three in-house buttons set geometry but no colour, so each inherits whatever its surrounding module happens to give it. And the Bootstrap name is a Bootstrap 3 one: 4.6 defines no such rule, so 23 of those instances carry a class that does nothing.

Underneath all of it sat 2,171 !important declarations. Most existed to win specificity fights against Angular Material’s runtime-injected styles, a legitimate reason for some of them and a symptom for the rest. Either way, the cascade could not be reasoned about by reading it: you could not predict which rule would win.

So component standardisation is the wrong first move. A component is only as consistent as the values underneath it. Standardise the button while its values are still arbitrary and you have fixed one component; the next person still has 459 values to choose from and no canonical one to reach for.

I took the audit back to the CTO and proposed inverting the order: foundation first, components second. It meant a stretch of the project with nothing visible to show. The argument for it was that the alternative buys consistency you have to keep paying for. That got approved, and it reset the shape of the whole engagement.

02Mobilising the squad

Managing a team of 12 designers meant the delegation strategy mattered as much as the audit did. Several modules across one app is several hundred components, and that is more than one person rounds up in a month.

I split the work by module ownership rather than by functional silo, and sent each owner their slice of the audit with its real numbers attached.

WhoTook
Me
project lead
Profile, error and empty states, and every module outside perks and rewards. Plus the foundation-first case to the CTO, the baseline audit, the rules the consolidation had to obey, review and merge on every pull request, the QA triage and the decision log.
Principal Product Designer Perks. And co-decided the canon with me: which ramp step each colour cluster resolves to, the button tiers, the shadow and radius ramps. The value calls that can’t be computed.
UI/UX Designer Rewards, the largest module in the app.
UI/UX Designer Icons, end to end: six sources consolidated onto one Lucide set, 195 icons catalogued per module.
Frontend team Both guardrails: the global-versus-local check and the .ts escalation. Stylelint enforcement, every flagged logic change, and keeping builds green through the migration.
CTO Set the original brief. Approved the inversion to foundation-first. Arbitrated the architecture questions: the Angular Material approach, what the guardrails blocked, and what shipped when.
Two people applying the same judgement independently produces conflicts rather than consistency, so the work was split by module rather than run in parallel on the same files.

Everyone worked on a shared design branch and raised a pull request per slice. I reviewed and merged every one, which is where the canon was enforced: a pull request that invented a value instead of reaching for an existing one did not get merged. We stood up daily for the duration, on this project only.

Why this sat with the design team

The work looks like frontend work, and some of it is. But almost none of the difficulty was in editing files. It was in deciding which of twenty-three off-whites survives. Whether a 25px radius and a 50px pill are the same intention or two. Which of eleven definitions of one button class is the real one, and what the other ten should become. Those are design decisions that happen to be written in CSS.

Run through the frontend team, the same project would have taken about twice as long. Not because it is harder for them, but because every one of those calls would have left as a ticket, waited for an answer, and come back. There are several thousand of them. Owning the code removed the round trip, which is most of where the month went.

03Human judgement vs. AI labour

The project worked because we separated the mechanical work from the design decisions. Mixing the two is how migrations become unreviewable and stall.

Rounding up: 459 values, 243 tokens

The first step was not a redesign. The rule I set: don’t change a single CSS value yet. The temptation with a mess this size is to fix it while you are in there, snap the 15px to 16px, correct the type scale. We held the line on all of it, because a migration that also improves things is a migration nobody can review.

So this stage was purely a naming job: walk the platform by eye, decide what set of values it needs, and round the existing ones up to that set. The Principal Product Designer and I worked the canon together, the canon being the token set plus the rules governing its use. Deciding which ramp step twenty-three off-whites collapse to is not something you compute: the right answer is the value that appears most on the surfaces users look at longest, not the one that appears most in the codebase.

Fig. 4 The roll-up every real literal, folding onto its canonical token
Marks are positioned by real value; height is usage. Hover any mark to read it.
Black marks are substitutions a codemod could prove lossless: the literal already equalled a token, so the swap is a rename and nothing more. Mid-grey did not match and needed a designer to decide. The palest are values the system declined to absorb: sub-grid alignment, beyond-scale lengths, and design-tool fractions like 17.631px. Keeping those stops a design system becoming a bulldozer. The seven categories charted here hold 214 distinct values between them; colour is charted separately in Fig. 2, and the remaining token families are not charted at all.

Two of those ratios matter more than the rest. Spacing, 79 → 8, is the biggest reduction and the least interesting decision: that is not seventy-nine intentions, it is seventy-nine instances of nudging a number until it looked right. Z-index is the one category where consolidation isn’t the point at all: values ran from 1 to 99999999 with no logic, which is why tooltips rendered behind dropdowns. The win isn’t fewer values, it’s ordered ones.

Deciding what the system refuses to have

Twelve button styles collapsed to five tiers and two sizes: primary, secondary and tertiary taking colour from the tenant’s brand variable; icon-only and overlay are not tenant-aware, because they sit on photography where a brand tint fights the content underneath. The original spec had a third, larger size; we cut it after I audited every call site and found no 48px CTA anywhere in the app. A size nobody uses is a size that invites somebody to use it inconsistently later.

Six icon sources became 195 icons from one: Lucide, stroke 1.2px, 24×24, catalogued per module.

Fig. 5 The canon committed token values · live
Tenant The brand ramp is color-mix() against the tenant’s own colour. Switching here recomputes it, and the tenant-aware button tiers below.

Brand ramp 8 steps, derived per tenant

2550100200300400500600

Neutrals 7 steps, bluish-warm, never pure grey

50100200300400500600

Status fixed, never tenant-derived; green must stay green

success
warning
error
info

Type scale 8 steps, plus a sub-floor badge and two display sizes

--font-size-badgeRecognition8px
--font-size-xsRecognition11px
--font-size-smRecognition12px
--font-size-denseRecognition13px
--font-size-mdRecognition14px
--font-size-lgRecognition16px
--font-size-xlRecognition18px
--font-size-2xlRecognition20px
--font-size-3xlRecognition26px
--font-size-display-smRecognition34px
--font-size-displayRecognition40px

Radius 10 steps, plus a full-round token

none0px
2xs2px
xs4px
sm6px
base8px
md10px
lg12px
xl16px
2xl20px
pill50px

Elevation 6 steps, one ink

xs
sm
md
lg
xl
2xl
A specimen, not the manual. The full system runs to a 30-section reference, a field guide, an icon pack and the decision log, roughly 4,400 lines. Status colours are the one family that is never tenant-derived: a customer whose brand is red does not get red success states.

The constraint a tenant-derived ramp creates

If the brand colour is an input, contrast is not something the palette can guarantee on its own. Three rules carry it. Status colours are never tenant-derived, so success stays green whatever the customer’s brand is. Text and surface tokens come from the neutral ramp, not the brand ramp, so legibility does not move when the brand does. And the tiers that sit on photography, icon-only and overlay, are deliberately neutral. The type scale carries the same logic: 11px is the floor for anything a user has to read, and the 8px badge token is documented as decoration only, never text.

Fig. 6 Buttons, before and after rendered from the real declarations

Before: what was in production

tonal
solid
partner blue
disabled
small accept
small decline
Bootstrap
  • The first four are the same class. It is redefined in eleven component files: tonal where the mixin defines it, solid brand in six of them, a partner’s blue in one, grey when disabled. Every override carries !important
  • Heights 40px, 34.2px and 35px; radii 25px and 4.5px; labels at 14.4px and 18px
  • The fractions come from 0.8rem and 1.9rem resolving against an 18px root
  • The small accept button sets no colour of its own, so it inherits whatever the surrounding module gives it. Shown here in the inherited body colour

After: five tiers, two sizes

  • Two heights only: 40px and 32px. No third size, because no 48px CTA existed anywhere in the app
  • One radius: --radius-pill. One min-width, one gap, one transition
  • Primary, secondary and tertiary take colour from the tenant’s brand variable. Icon and overlay do not. They sit on photography, where a brand tint fights the image underneath
  • Zero !important
Live specimens, not screenshots. The left column is styled with the declarations read from the pre-migration stylesheet; the right from the committed token file. Hover either row: the legacy buttons each animate all 0.3s, the canonical ones transition only the properties that change.

Which half a machine could do

The substitution work then split in two, and that split made the rest possible.

The mechanical half. A codemod performed ~3,876 literal-to-token substitutions under one requirement: zero pixel difference, every substitution provably lossless, a rename and nothing more. The agents did the parts that are mechanical: sweeping 347 stylesheets for every literal and clustering them by perceptual distance, running the lossless substitutions, reconciling Figma exports against the committed tokens. None of that needs an eye, and all of it used to be the reason an audit like this never got done.

Fig. 7 Which half a machine could do across the seven numeric categories
  • 3,493 uses lossless: 47 values a codemod could swap
  • 1,444 uses needed an eye: 97 values with no exact token
  • 408 uses kept as-is: 70 values left alone
Measured across the categories in Fig. 4. Roughly two in three literal uses could be migrated by a tool that was forbidden from changing anything.

That constraint sounds conservative. It is the reason the change could be reviewed at all. Substitutions that also adjust values cannot be reviewed by a human, because real regressions hide inside the cosmetic noise. Substitutions that change nothing can be verified as a class.

The judgement half went to per-module owners as handover reports rather than a rollout I performed on everyone else’s code, each with their slice’s real numbers and three permitted outcomes per literal: consolidate to the nearest token, keep as a deliberate one-off, or factor into a feature-scoped token. The calls had to be made by whoever knew the module. A system imposed any other way gets followed until the day I stop watching.

Handing the repetitive labour to the agents let four designers work directly in a production codebase at all. The shape of the project is not really a design system built in a month; it is a design team keeping the judgement and handing away the labour.

04The framework: mitigating risk

Editing a 13-year-old cascade carries a real risk of breaking things nobody touched. Partway through, that pattern emerged.

The cause was simpler and worse than the theory going around. Styling that belonged to one element lived in the global stylesheet, under a class unrelated elements also wore. Applying a token to it changed every element wearing that name, including ones that were never in the diff.

I worked with the frontend team to turn this into two blocking pre-edit checks.

Guardrail 1: is this style global or local?

Name the element, list its classes, search who else uses them. Local → replace the literal with the token; it cannot ripple. Global → don’t edit it. Surface a written proposal with the current rule, the proposed change, and the count of elements sharing the style, and leave it unapplied.

Guardrail 2: does this need a .ts change?

UI work must not touch component logic without approval. Flagged items route to a frontend-support log with symptom, file, line, root cause and proposed fix.

These two are the reason the rollout didn’t unwind. Without them, a token migration inside a 13-year-old codebase is a very large, very confident find-and-replace across a cascade nobody fully understands. They also changed how the work felt to do: a designer editing production stylesheets could tell which edits were safe to make alone and which needed a second pair of eyes, instead of guessing.

An independent regression audit across 370 changed files found zero critical and zero confirmed high-severity defects.

05Shifting the burden of proof

Nobody argued with the design system. What it ran into was harder to argue with: every check around it still validated against the old baseline, because that was the only baseline anyone had. You can persuade a person; a default has nobody to convince.

A validation sweep produced a 59-item fix plan: careful work, properly written, and exactly what the process asks for. Implemented as specified, roughly 49 of the 59 would have reverted an intentional consolidation.

That is not a testing failure, it is a gap I had left. A sweep compares a screen against the reference it was given, and I had not yet given anyone the new one. Without it, nobody looking at a screen can distinguish “this grey is a mistake” from “this grey is deliberately different from the grey it replaced.” Both read as a change, and the natural remedy is put it back, and that is what the migration existed to stop.

I triaged all 59 against two questions: is something objectively broken, and does the proposed remedy restore something the migration consolidated? That split it into about ten to take as written and about forty-nine needing a design ruling. The genuinely broken ones were worth having, including a comment in the token file stating the wrong value for --radius-pill, which had caused a run of radius regressions downstream. The bug wasn’t in any stylesheet; it was in a sentence.

The 28-entry decision log was the response, and it’s the work I’d point to first. Each entry records the production baseline, the change, the rationale and the blast radius. The value was not in the writing. It was that the default changed. Before it existed, every consolidation was one person’s preference until argued otherwise. After it, the standard was the reference, and a divergence from it was the thing needing justification.

Shifting where the burden of proof sits is most of what establishing a design system consists of. The tokens are the easy half.

06Outcomes and long-term adoption

By the close of the one-month cycle the system was merged into the release branch. The canon is now how the product gets built: buttons from the five tiers, dropdowns from the one pattern, icons from the 195, colours from the tokens.

Fig. 8 The canon in production three modules, one visual language
Leaderboard: a three-card podium for the top three, followed by ranked rows.
Leaderboard: podium cards and ranked rows, both built from the card and avatar patterns.
Perks home: a notice strip, a promotional banner carousel, and a grid of partner brand tiles.
Perks: a different content type entirely, on the same surfaces, radii and spacing.
Brands directory: an alphabetical index and a grid of partner logo tiles.
Brands: the 1:1 logo chip, the third reuse of a recipe first written for award badges.
Three modules built by different people at different times. The consistency is not enforced by review. Each is assembled from the same tokens, the same five button tiers and the same icon set, because that is now the cheapest way to build a screen.
Fig. 9 The same audit, run again share of styling declarations that reference a token
CategoryBeforeAfter Literal declarations left
Type size0%93%73
Radius2%83%50
Colour11%81%92
Shadow8%75%18
Spacing0%61%642
!important all stylesheets 2,171731 −66%
The audit that opened this project, re-run against the merged result across 351 component stylesheets. Raw hex alone fell from 1,998 to 126. Outside spacing, the entire product now carries 233 literal styling declarations, where before there were thousands. Spacing is the deliberate laggard: it is where the sub-grid alignment debt lives, and the canon declined to absorb it rather than pretend a 13px nudge was a scale step.

The !important row is the one that tells you most. Clearing two-thirds of those 2,171 declarations turned the stylesheet layer back into something a person can predict by reading it. What is left is mostly the genuine Angular Material specificity fights, and each one is now allowlisted and documented rather than inherited. It is the same shift as the hex values: an exception someone recorded rather than a habit someone continued.

What is left is not leftovers. The 126 hex values still in the codebase are mostly the partner-brand palettes the canon has no claim over (Amazon’s yellow is Amazon’s yellow), plus the AI-generated chart colours the token file explicitly rules out of scope. The spacing residue is the sub-grid alignment debt, which the system declined to absorb rather than dignify a 13px nudge as a scale step. The difference is that every one of these is now an annotated exception rather than the ambient default: a literal in this codebase today is a decision someone recorded, not a guess someone made.

The measure of a design system isn’t what it looks like on the day it ships. It is whether the twenty-fourth off-white gets added next quarter. Right now it doesn’t, and not because anyone is policing it: the token library lives in the codebase branch, and reaching for the canonical value is now the path of least resistance for designers and engineers alike.

That is the first half of it. Consistency is not the same claim as usability, and a token file cannot tell you that a row has a click handler and no tabindex. What the foundation bought was the speed to go and find out. That is the subject of the accessibility story.