Case 02 · Cactus · Design system
One system, three platforms
Cactus runs on three front-ends built at different times by different people. A shared library already existed. Nobody could trust it, so nobody used it. This is the audit, and the system that came out of it.
Company
Role
Senior product designer, system owner
Team
1 designer · 8 developers
Surfaces
POS (Flutter) · Portal, Order and Pay (Next.js)
Status
In progress · v0.1
The finding
The system existed. Nobody could trust it, so nobody used it.
So the first deliverable wasn’t components. It was the evidence: what the code actually does today, measured file by file.
My contribution
Audited the POS codebase (906 Dart files) and the shared widgets library.
Defined the token model and the scope of v0.1.
Planned the rollout with engineering, one platform at a time, starting with Order and Pay.
Not mine: [confirm] implementation in each codebase, by engineering.
The problem
Three products, two frameworks, one designer, eight developers, and no shared source of truth between design and code.
The POS runs on Flutter for tablet and mobile. The account portal and Order and Pay run on Next.js. Hundreds of screens had their values written by hand. The Figma library never matched the code, and the move from Sketch to Figma lost the styles that were there.
Every new screen was a fresh decision about grey, radius and spacing, made by whoever was building it. Nobody was doing anything wrong. There was just nothing to be right against.
The audit · POS app
What the code actually does.
Measured across the Flutter POS app, v1.10.9. These are the baseline every later number gets compared to.
199
distinct hex colours
927 uses across 275 files.
30
near-identical light greys
#EEEEEE alone appears 190 times.
32
font sizes
Including 19.5. Weight is written nine different ways.
47
corner radii
From 1.5 to 110.
53 / 70
spacing values off the 4-pt grid
Counting values up to 64.
108
button widgets
58 public, 50 private, six naming prefixes.
Thirty light greys, all in use. Shown on white, at the values in the code.
ΔE < 5 · perceptually the same
EEEEEE
F2F2F2
E6E6E6
FCFCFC
F3E7E9
EDEDED
FAFAFA
F3F3F3
F8F8F8
F6F6F6
F1F1F1
F9F9F9
E7E7E7
F5F5F5
FBFBFB
F4F4F4
F3F4F8
FEF8F9
E1E1E1
F1F4F9
F6F9FF
F8F9FC
F5F4F5
F7F7F7
EAEAEA
E5E7EB
F3F2F4
F8F9FA
FDF4F4
F3F7F7
The audit · shared library
The library existed. It disagreed with itself.
99 colour tokens holding 89 distinct values, nine groups of exact duplicates, and names that meant different things in different places.
Same name, different value
WARNING
library #FAAD14
app #FFE58F
MAGENTA
library #DD5AC9
app #B60EDA
COLOR_ERROR_BG
comment #FAFDFE
value #FFF1F0
Different names, same value
BLACK = pureBlack
NATURAL_BLACK = offBlack
DIALOG_BLACK = shade1Black
WHITE = pureWhite
FRESH_GREEN = MINT_CREAM
CHERRY_PIE = cherryPie
CAROUSEL_PINK = CLASSIC_ROSE
hi2Grey = hi3Grey
cherryCream = cBABAFF = c00AEEF
A type scale already existed: seven named sizes, from DISPLAY 25 to CAPTION 12.
~70
1,811
Decisions
Four calls that shaped v0.1.
Each one is a bet against the way the last library failed.
01
Source of truth
Chose
Tokens in one file, generated into Flutter and Tailwind.
Rejected
A Figma-only library, with each codebase keeping its own constants.
Why
A library that doesn’t match the code is exactly what already existed, and nobody used it.
02
Structure before values
Chose
Map today’s values to semantic roles and collapse duplicates first.
Rejected
A new palette at the same time.
Why
Changing structure and values together turns every diff into a visual regression nobody can review.
03
Scope of v0.1
Chose
Eight components, starting with the button.
Rejected
A complete library before anyone adopts it.
Why
108 button widgets are what a library nobody adopts looks like. It multiplies, it doesn’t consolidate.
04
Where to start
Chose
Order and Pay: the smallest surface, Next.js, customer-facing.
Rejected
The POS first, because it’s the biggest.
Why
Prove it in one release on a surface we can measure, then carry it to the portal and the POS.
Proposed · v0.1
One name, three outputs.
A primitive holds the value. A semantic token says what it’s for. Each platform reads the same file.
surface/base
#FAFAFA
CTokens.surfaceBase
bg-surface-base
surface/subtle
#F2F2F2
CTokens.surfaceSubtle
bg-surface-subtle
border/default
#E0E0E0
CTokens.borderDefault
border-default
text/primary
#161515
CTokens.textPrimary
text-primary
state/warning
#FAAD14
CTokens.stateWarning
bg-state-warning
Rollout
One platform at a time.
01 · Pilot · in progress
Order and Pay
Next.js. Tokens and the first components replace hand-written values on the guest flow.
02 · Next
Account portal
Next.js with its own Tailwind library. The tokens become its theme.
03 · After
POS app
Flutter, tablet and mobile. Generated constants replace CSAColors and CactusColors.
How I’ll know
Baselines I can measure in code.
Targets are what v0.1 is meant to move. They’re proposals until the pilot ships.
Measure
Today
Target
Screen files using colours without tokens
156 of 306
0 new
Primary buttons in parallel
3
1
Font sizes in use
32
7-step scale
Distinct corner radii
47
[target]
Screens reading the theme
1 call to Theme.of
All
What I took from it
A library nobody trusts adds a third option.
Evidence before components.
Numbers from the codebase made the problem concrete for engineering in a way a redesign couldn’t.
Consolidation is a migration.
Change the structure first and the values later, so every step can be reviewed.
One designer can’t police consistency.
With eight developers and three platforms, the tokens have to do it.
Trust is the adoption metric.
A component gets used when people believe it matches production. Everything else follows.
Keep reading
4 pieces · 3 cases, 1 concept
If your product got complicated, let’s talk.
Copy email
All contact details