Making a visual direction enforceable
Interstate had a written visual direction and nothing that made it true. Over seventeen PRs in a 30-contributor trading terminal, I turned the document into mechanisms: one geometry class, one token vocabulary, and a drift check that fails CI when a file gets worse.
- Role
- Design Engineer
- Team
- Design Engineer (Me), ~30 contributors in the codebase
- Timeline
- Jul–Aug 2026
- Tools
- Paper, Claude Code, Next.js, Tailwind v4
Interstate had a good visual direction written down, and nothing that made it true. I spent three weeks converting it from a document into things you'd have to actively break, and the most useful thing I built counts my own work as debt.
I owned
- System architecture
- Token vocabulary + alias map
- Shared geometry class
- Header IA
- CI drift ratchet
- Landing page rebuild
- 17 PRs, authored and shipped
Shared
- Review with the engineering leads
- Rollout sequencing
Not mine
- The Graphite Noir visual direction
- Product roadmap and route ownership
I was the only designer in the codebase. Nobody handed me specs and nobody merged for me: every change below is a pull request I wrote, defended in review, and own the consequences of — including the two I got wrong.
The situation
Every screen was styled correctly once, and never again
Interstate is a multi-chain crypto trading terminal. Roughly thirty people commit to one Next.js app, and the surface area (four market boards, a trade page, portfolio, trackers, landing) grows faster than any one person can review.
The result wasn't an ugly product. It was an inconsistent one, in the specific way fast products become inconsistent: every screen was styled correctly at the time it was built.
The clearest symptom was the header band on the four market boards. Same component role, four different sizes:
Underneath that: legacy token names, shadcn defaults, five private palettes living inside individual components, and raw hex inline. And a spec called Graphite Noir, written by a colleague two weeks before I started, that was genuinely good and entirely advisory.
That spec is worth showing, because everything I did afterwards is in service of it. It is a surface ladder rather than a palette — seven steps of near-black that differ by two or three points of luminance, so depth comes from stacking order instead of from shadows and blur.
Graphite Noir · surfaces — my colleague's direction, not my work
--surface-canvas
#060607
page void behind panels
--surface-sunk
#0c0c0e
input wells, header strips
--surface-panel
#0e0e10
milled panels and cards
--surface-panel-top
#111114
lit top edge of the gradient
--surface-elevated
#151518
hover rows, active segment
--surface-overlay
#1b1b1f
modals, dropdowns
Ink — exactly three tiers, and the only hue on the page
--ink-1
#f5f5f4
primary · ~17:1
--ink-2
#b5b5b1
secondary · ~9:1
--ink-3
#7e7e7a
tertiary · ~4.6:1
--status-buy
#12c48b
buy / up
--status-sell
#e5484d
sell / down
--status-warn
#e0a83e
warn
The type scale is named the same way — for where it lives, not for a t-shirt size. sm invites the next person to pick a different one; column label doesn't.
Type roles, set in the words that actually ship in them
Radius — by role, never one value everywhere
--radius-panel
12px
panels, cards
--ds-radius
8px
controls, inputs, chips
micro
4px
table badges
pill
9999px
pills
Elevation — three real steps, and the glows switched off in place
--shadow-sm
shadow
resting
--shadow-md
shadow
popovers
--shadow-lg
shadow
modals
--shadow-glow-md
none
was an accent glow

The rule that makes the whole thing hold: chrome is grayscale, and hue means money moving. A trader glancing at the screen should be able to read direction from colour alone, which only works if nothing decorative is allowed to spend it. Most of my token work is enforcing that one sentence.
That last part is the actual problem, and the only one worth solving. Not "make it look flatter", but make the flat direction hold without me in the room. The direction is my colleague's work. Mine starts where it stops being a document.
The idea
Replace conventions with mechanisms
A convention is something people have to remember. A mechanism is something they'd have to actively break. One idea, applied at three scales:
- Geometry. Four hand-tuned bands became one class that owns height, padding, gap and alignment.
- Tokens. Many colour vocabularies became one canonical set, with every legacy name aliased onto it rather than rewritten.
- Information architecture. Two competing header controls became one control whose options name their own scope.
Every geometry value came from measuring the running app against a reference board, not from taste.
Every value in here was DOM-measured against /discover rather than eyeballed.
Market board header band · DOM-measured in the running app
Before · hand-tuned per board
After · one shared class
spread 6.95px → 0px
The method matters more than the number. I picked one board as the reference and measured the rest against it, rather than opening a design file and re-deciding what the height should be — a redesigned value is one more opinion for the next person to disagree with, a measured one is just what the product already mostly did. And the class owns height, vertical padding, gap and alignment together, because those four drift as a set: pin the height alone and the jump moves into the padding.
The decision I'd defend
Alias the old names instead of deleting them
The canonical set was the easy half. The interesting decision was what to do with everything already referencing the old names.
Rewriting every call site meant a three-hundred-file diff in a repo with no visual regression tests and thirty people mid-flight, so I pointed the legacy names at the canonical ones instead. One file changed, and every screen inherited the direction, including screens I had never opened, owned by people I had never spoken to.
Legacy names, repointed rather than replaced
- --bg-void
- --bg-deep
- --bg-base
- --bg-elevated
- --bg-surface
- --bg-card
- --bg-hover
- --border-subtle
- --border-default
- --border-strong
- --text-primary
- --text-secondary
- --text-tertiary
- --text-muted
- --accent-primary
- --accent-primary-glow
- --color-success
- --color-danger
- --color-warning
- --color-info
20 legacy names → 12 canonical · 1 file changed
The row I like most is --accent-primary-glow → transparent. The direction says the surface is flat, so the glow had to go — but deleting the token means touching every component that referenced it. Pointing it at transparent turns the glows off everywhere in one line and leaves every call site compiling. The same move retired --shadow-glow-sm/md/lg: still there, still referenced, all three now none.
A token you can neutralise is worth more than a token you have to delete.
Aliasing leaves the old names alive, so the codebase now has two ways to say the same thing, and that debt is invisible to a reviewer, which is the worst kind. I flagged the blast radius up front rather than letting review find it: the shared tokens and primitives are used app-wide, so this changed the look of every page, not just the one in the PR. I also flagged the one change users would actually notice, the default accent moving to teal, and preserved the stored preference for anyone who'd set their own.
This is the tradeoff I'd argue for in a review and against in a greenfield repo. On a product moving this fast it's the only version that works; on a smaller team it's just deferred cleanup.
Where I was wrong
An IA fix that put a money transfer inside a dropdown
Two controls in the header answered the same question in incompatible ways. One of them turned out to be displaying a value while dressed as a choice — it looked like a decision the user could make, and it wasn't. Merging them was obviously right, so I merged them, and moved the balance sweep down from a peer control into the account menu.
The IA reads better. It was still the wrong call, and the reason is a category error I'd make again if I hadn't made it once.
A dropdown is a surface you dismiss by looking away. I put a two-minute money transfer inside one.
The sweep isn't a setting. It's a sequence of real cross-chain transfers, and a menu's contract with the user is that it disappears the moment you look away. Those two can't share a container — and every fix I tried was a smaller version of the same mistake:
Two header controls answered the same question, and one was displaying a value while dressed as a choice. Merging them was right.
Costs the user · Nothing yet. This part I'd do again.
Three escalating fixes, each one a smaller version of the same mistake: trying to make a wrong placement safe. The design was fighting itself, and the tell was that every fix made the component less like a menu. I kept the abort guard, wrote its honest limit into the PR — a signed transfer in flight can't be recalled, so it cancels the queue, not the leg — and reverted the placement.
The part worth keeping is the question I now ask before moving any control: how long does this action live, and how long does its container live? If the action outlives the container, no amount of guarding will fix it. That's a design constraint, not an engineering one — it just happens to only announce itself in code.
The component I'd show first
Six notification skins became one plate
Tokens and a shared class fix how things are coloured and sized. They can't fix a component that is the wrong shape. Notifications were the clearest case, and the honest version of this story has three steps in it, because the middle one is mine and I took it back.
What the team had
#1E1F26 plate on a #030304 page · 14px/500 · white / blue / coral borders · width follows the string
My first pass · reverted
380 × 82px · fixed width · animated rail
Shipped
~254 × 38px · fits content · no rail
What the team had was six visual treatments doing one job. The plate was #1E1F26, sitting on a #030304 page — a surface value that appears in neither token set, and nearly thirty points of lightness away from the ground it floated on. Success carried a 1px white border, pending blue, failure red. On a product whose entire colour rule is hue means money moving, the most emphatic outline in the set was spent on a state, and the blue collided with the one hue reserved for information. Thirty-four call sites also forced themselves to top-centre, overruling the position the user had chosen in settings.
Six skins isn't six decisions. It's one decision nobody got to make, six times.
My first pass overcorrected. I put everything on one Noir card — right instinct, wrong card. It was 380×82px to carry a single line, sinking the token avatar into a 36px recessed well and parking the status in a corner badge, diagonally away from the sentence that says what happened. And it ran a duration rail: a moving element on a settled trade. On a trading screen motion means something is in flight, so spending it on a dismissal countdown means the one place it should signal is already noisy. I shipped it, saw it in use, and reverted it.
What shipped is mostly subtraction. The rail is gone; a spinner does that job, only while something is genuinely pending, and sits in the icon slot rather than running under the copy. The well became a 20px avatar inline in the row. The corner badge became a ringed check leading the sentence, so status and outcome read in one movement. The fixed width became a 288px floor that short toasts share.
One card doesn't show that, though — a single shipped toast next to the old ones looks like a smaller rectangle. The change is that everything the platform can say now comes off the same plate and differs only where it has a reason to:

fire one
The rule I'd defend hardest is the last one: the card only grows a second line when a description, a retry or a suggestion is actually passed to it. That moves the judgement out of the component — the card doesn't decide it's important, the call site does. Roughly 370 string-message toasts stayed one row, and the 121 that carry a retry action earned two.
The rule underneath it
One red, and it belongs to money
My reverted card had put failure suggestions in a violet panel — I'd added a sixth hue family to a product whose entire direction is that colour means one thing. Catching that in my own work is the same check I was asking the ratchet to run on everyone else's.
The whole hue budget — everything else on the platform is grayscale
--status-buy
#12c48b
a fill went through, a number went up
--status-sell
#e5484d
a sell, or a failure — deliberately the same red
--status-warn
#fddb24
caught before anything was sent
--status-info
#6e9bff
sparingly, and never on a trade
Four hues, and the tightest one is the pair. A failed transaction and a sell fill share the same red because both are the same thing to a trader glancing at the screen: value moved away from you. Splitting them into two reds would buy a distinction nobody needs and cost the budget a colour.
The consequence I like is what it forbids. A rank-up in the rewards system is good news, and it stays grayscale — because nothing moved. A green there would read as a fill, and being wrong about that on a trading screen is worse than being unexciting. Suggestions became ink, and the copy does the work the violet panel was doing.
What each toast is allowed to spend
Making it stick
A ratchet, not a gate
Tokens and a shared class only stop drift where someone remembers them. So the last piece counts drift per file (raw hex, inline styles, arbitrary colour classes, raw buttons) against a checked-in baseline, and fails if any file gets worse.
Not a gate. A gate blocks every PR on day one and gets switched off by Thursday. A ratchet lets existing debt sit and never grow.
The check runs on the files in the diff, not on the repo. Nobody inherits someone else's debt by opening a pull request near it.
Costs the user · Nothing. A PR that touches clean files never sees this at all.
Nine rules, because "looks off-system" is not something a script can check. Each one names a specific, greppable way to go around the tokens:
Counted as debt like everything else. This is the number that makes the rest of the list mean anything.
That teal bar is the point. A ratchet that exempts its author's work isn't a standard, it's a preference with a script attached. My tokens are a migration target, not a destination — the direction they point at is Graphite Noir, and a file full of --ds-* is a file that still has a step left to take.
I wrote the migration contract alongside it: an alias map, a one-file-per-commit loop, and an explicit list of substitutions a machine must not make. About a hundred references to my accent token sit at sites that are semantically opposite: some are chrome, which the direction says must be grayscale; some are genuinely buy actions, where the target is the status colour. No single alias serves both. The valuable artefact wasn't the token map. It was knowing precisely which substitutions were a human call, and why.
What held
Four measurably different bands became one rule
I have no post-ship analytics for this work and won't claim a user metric. What I can show is that the row stopped moving, on every surface that has one:
One definition, five surfaces · counted from the shipping branch
The stronger signal is the code I didn't write. Inside a week, three other engineers built in the vocabulary in areas I don't own — a new 463-line panel authored natively in the token set, a route promotion that carried the shared class forward, and the trade tab customiser migrated onto Graphite Noir. None of them asked me first, which is the whole test: a system that needs its author in the room is a preference.
What's unresolved
A shared class binds files, not roadmaps
The honest miss is the row marked off above. The portfolio board carries the rule and nobody sees it: I applied it in late July, and the next day a parallel rewrite — already in flight on another branch, by people who had no reason to know I was working on that page — was promoted to be the default route. My version is still in the shipping branch, still correct, and unreachable.
Nothing in CSS could have caught that. The rewrite wasn't drift, it was a roadmap item, and a shared class has no opinion about branches it never touches. Coverage is a coordination problem wearing a technical costume.
Two more I'd rather say than have found:
The token migration is real and incomplete. It moves file by file by design: there are no visual regression tests here, so bisectability is the only safety net, and batching would remove it.
I set a 13px root on html, body to match the mockup's metrics. Every
rem-based Tailwind utility in the app now renders at 13/16 — about 81% of
nominal — and nothing anywhere says so. Set below at the sizes they actually
compute to, which is the only honest way to show it:
It rescales geometry too — h-11 lands at 35.75px, rounded-xl at 9.75px.
I didn't find this landmine; I laid it, hit it twice in my own sizing work,
and then left it: a global relayout with no visual regression tests was the
larger risk. Every shared rule since is written in literal px so it can't be
rescaled again, which is why the band says 44px and not h-11.
How I work
Design against the real thing, not a picture of it
No handoff, because there's nobody to hand off to. I design in Paper, build against the actual components in the codebase, and own the pull request through review.
The reason that matters isn't speed. It's that most of the decisions on this page are only visible in the running product. A 7px height difference between two boards doesn't exist in a design file, where each board is its own frame drawn to look right. A duration rail doesn't read as noise in a static mockup — it reads as a nice detail. The 13px root I set is invisible everywhere except in the app, at which point it has already rescaled a thousand things.
Design files are where you decide what a thing should be. The product is where you find out what it is.
Working in both is how the drift got found at all, and it's also why the mechanisms took the shape they did: a class instead of a spec page, an alias instead of a rewrite, a check that fails a build instead of a guideline nobody reads. Every one of those is a design decision that could only be enforced from inside the code — which is the whole argument for the role, and the reason I'd rather be the person who ships the fix than the person who files it.