Token architecture
app/styles/tokens.cssTwo layers, strictly separated. Components never name a raw value — they ask for a purpose. That indirection is what makes a theme swap, a colorblind mode or a brand change an edit to one file instead of a search across every component.
--color-neutral-950--color-long-500--color-primary-500--color-surface-raised--color-buy--color-text-tertiarybg-surface-raisedtext-buy-textborder-border-subtleColor
5 ramps · 58 semantic tokensLayer 1 · primitive ramps
Neutral steps are deliberately tight below 800 — that dark end is where panels separate from the canvas, and it needs more resolution than the light end ever will.
Layer 2 · Surfaces
On a near-black ground, shadow barely registers — elevation reads through background lightness plus a hairline border. Inputs sit ABOVE the panel, not below: the first pass recessed them and they vanished into the page, which is also why every comparable terminal raises them. surface-inset is now reserved for things that genuinely are recessed — slider grooves, table headers, meter tracks. Toasts sit at the very top: at surface-overlay a fill confirmation measured 1.06:1 against the panel behind it — the same colour, for practical purposes — and was reported as unnoticeable. At surface-toast it is 1.24:1, and the arrival flash peaks at 1.70:1.
Layer 2 · Text
Four steps, hard stop. A fifth always ends up indistinguishable from the fourth in practice, and only gives people a decision they can get wrong.
Layer 2 · Directional
The most-repeated colors in the product. `buy` is the fill for solid controls; `buy-text` is the lighter step used for green type on dark ground, because the fill green fails contrast at 11px. The depth pair is separate from `muted` because muted-strong is shared with the Buy/Sell segmented control — widening the gap there to separate the two book bars would have made the order ticket loud again.
Layer 2 · Interactive
Deliberately blue, not the buy green. If the confirm button, the active tab and a long position all shared one hue, green would stop meaning direction.
Layer 2 · Margin health
A dedicated ramp for liquidation proximity. Kept separate from feedback colors on purpose — a healthy account should never share a token with a success toast, or the two drift apart the moment either is retuned.
Layer 2 · Borders
Subtle must nearly disappear — it separates table rows. Strong must be findable without being loud — it outlines inputs.
Typography
IBM Plex superfamilyTwo families, hard limit — and one superfamily, which is the point. IBM Plex Sans carries the entire interface; IBM Plex Mono is restricted to order IDs, transaction hashes and API keys, where character disambiguation genuinely matters and a slashed zero earns its keep. Numbers stay in Plex Sans with tabular figures, which is why the order book does not shiver on every tick.
Spacing
4px base gridA single multiplier generates the whole scale, so p-3 is always 12px and nobody has to memorise a lookup table. A dense terminal lives almost entirely in steps 1–3; anything above 8 is page-level composition.
Fixed layout constants
Chrome dimensions are tokenised too, so panels compute against them with calc() instead of scattering magic numbers across components.
Radii & borders
7 steps · 5 border tokensRadius encodes scale: the larger the surface, the softer the corner. Controls sit at md, containers at lg, and only modals reach xl.
Border tokens
Elevation
5 tiers · 4 ringsEach tier pairs a shadow with its surface token — on this ground the shadow alone is nearly invisible, so lightness does the real work and the shadow only reinforces it.
Rings — state, not depth
Motion
4 durations · 3 curves · 8 animationsTrading interfaces punish slow animation. Nothing that gates a click exceeds 120ms; base is for surfaces, and slow is reserved for panels and sheets that move a long distance.
Named animations
animate-in fade-in, which belong to the tailwindcss-animate plugin. That plugin is not installed here, so both classes compiled to nothing and every dialog hard-cut into view — with a fully green build the entire time. This is the third defect on this project that a passing build did not catch, which is why the rule is to grep the compiled CSS and check the browser.animate-flash-buy and animate-flash-sell fade a 12→0% tint over 400ms, and are the only affordance showing which level actually moved. Both collapse under prefers-reduced-motion.Breakpoints & layout
3 targets · 2 transitionalMobile-first — unprefixed styles are the phone layout. Conventional Tailwind names are kept rather than custom ones, so muscle memory and every doc example still apply.
Build log
how it was decidedKept in the repo on purpose. The decisions are the interesting part of a design system, and they are exactly what gets lost once the tokens look inevitable in hindsight.
- 00
Read the system before drawing anything
doneMapped the existing backend contract from source rather than assuming it — the REST surface, the websocket feed shapes and the database enums are what the UI has to model.
- ·11 REST endpoints behind JWT bearer auth
- ·WS feeds: last-traded-price, mark-price, depth, trades
- ·Depth arrives as [price, qty] string tuples — decimals stay strings end to end
- ·Gap found: no private user channel yet, so fills and order status are REST-only for now
- 01
Resolve the stack forks
doneFour decisions that would have been expensive to reverse later: framework, token authoring, theme scope and charting. Settled before a line of CSS.
- 02
Build the two-layer token system
doneLayer 1 primitives — raw ramps with no opinion about usage. Layer 2 semantic aliases pointing at them. Components read only Layer 2, which is the whole reason a theme swap is a one-file edit.
- ·5 primitive ramps → 58 semantic tokens
- ·Type scale: display through micro, plus a tabular numeric set
- ·4px spatial grid from a single multiplier
- ·Radii, borders, elevation, motion, breakpoints, layout constants
- 03
Verify the tokens actually compile
doneGrepped the compiled CSS for every token family rather than trusting the build's green checkmark. This caught a real bug.
- ·Tailwind v4 has no --duration-* theme namespace — the variables existed but generated no utility classes, silently
- ·Fixed by registering the four named speeds via @utility
- ·Re-verified: 13 token families generate, keyframes included
- 04
Catch the tree-shaking trap
doneRendering the styleguide exposed a second silent failure — two neutral steps were undefined at runtime and fell back to inherited text color. Plain @theme drops any variable no utility class references, and this page reads tokens through var() in inline styles, which the scanner cannot see.
- ·Symptom: the neutral ramp rendered light-dark-light at the top end
- ·Cause: --color-neutral-200 and -50 were tree-shaken out of :root
- ·Would also have silently broken every --size-* layout constant
- ·Fixed with @theme static, which emits all declared variables
- ·Re-verified 31 tokens resolve in the browser, not just in the build
- 05
Base components
doneEighteen atoms built from the tokens, variants typed through CVA. Every interactive control implements the same six states, and Field owns the label/hint/error accessibility wiring so no input re-implements it.
- ·cn() had to teach tailwind-merge the custom type scale — stock behaviour silently drops text-body-md when it meets text-buy-text, which would have stripped the size off every button
- ·12 merge cases pinned in a throwaway test before any component was written
- ·NumericInput is a text input, not type=number: number inputs discard malformed values and scroll-mutate on hover, which is unacceptable for money
- ·Delta and Side close the Phase 1 colorblind gap — the sign and the word are not optional props
- ·Verified in-browser: label binding 23/23, aria-invalid wired to role=alert, roving tabindex on the segmented control
- 06
Design pass — type and signature
doneA deliberate check against the design-lead brief before organisms get built on top. Two gaps: the type pairing was the reflexive choice, and the system had no signature element. Both are cheap to change now and expensive after Phase 3 builds dense surfaces.
- ·Ran a specimen bake-off — Inter vs IBM Plex Sans vs Archivo vs Instrument Sans, in a real order book at 11-13px
- ·Chose IBM Plex on a structural argument: mono and sans sit adjacent in the same table row, and a superfamily keeps those columns optically aligned
- ·Dropped Inter's cv05/ss03 feature settings, which do not exist in Plex
- ·Added slashed zero on monospace only — order ids get transcribed, prose does not
- ·Signature: the seam. Opposing quantities meet at a join whose position is the ratio, and the value sits on the join
- 07
Design review pass
doneFirst round of feedback against the built terminal. Four of five points held; the fifth was misdiagnosed in a useful way — the % buttons and the leverage slider were read as duplicates, which is a labelling failure rather than a redundant control.
- ·Inputs raised instead of recessed, 44px with 20px figures in the ticket only — tables stay dense
- ·Leverage slider rebuilt: its track was surface-inset on a dark panel, so only the thumb was visible
- ·% row labelled 'of buying power' and grouped with Quantity; leverage moved behind a divider
- ·Directional ramps dimmed against measured contrast, muted fills 24% → 22%
- ·Ticket Seam cut — it showed nothing at 0% and Margin required already states it
- ·Post/reduce-only collapsed behind an Advanced disclosure
- 08
Overlays, scrolling and elevation
doneRadix took over the behaviour-heavy overlays, the terminal became a fixed viewport shell, and every section finally got an elevation. Driven by real feedback against the built screen rather than a checklist.
- ·Radix Dialog, Tabs, Tooltip and ScrollArea — used directly rather than via the shadcn CLI, which would have injected its own token vocabulary into globals.css
- ·ScrollArea is now the default scroll region: its overlay scrollbar takes no layout width, so a native bar can no longer appear mid-stream and reflow the rows beneath it
- ·Terminal is h-dvh + overflow-hidden at lg; min-height floors totalling ~622px were forcing a page scrollbar on short laptops
- ·scrollbar-gutter: stable reserves the page scrollbar permanently
- ·Every section is a raised panel on the base canvas, separated by an 8px seam — the market bar included, since full-bleed stopped aligning once the gutter was reserved
- ·Order-book depth bars split into two nested tints: cumulative behind, the level's own size in front
- ·Deposit dialog added, mapping to POST /order/onramp
- 09
Waiting, absence and failure
doneEvery surface that depends on a network now has three designed states instead of one. The rule that fell out of it: check loading BEFORE empty, or an account with no positions flashes “No open positions” on every page load — telling someone something false about their money.
- ·Skeleton, SkeletonRows and SkeletonTable pinned to --size-row and to the real table's min-width, so nothing shifts when data lands
- ·EmptyState and ErrorState share one structure, so a panel's height does not jump between empty, failed and loaded
- ·Error states are `danger`, never `sell` — red means SHORT everywhere else, so the triangle glyph and the heading carry the meaning
- ·The order book now distinguishes “socket still opening” from “the book is genuinely empty”; a permanent shimmer on a quiet market is a lie
- ·The chart's placeholder is candle-shaped — a grey rectangle reads as a broken chart
- 10
Toasts, and the status/direction collision
doneFill confirmations on Radix Toast. The design problem was not the component: a fill carries a STATUS and a DIRECTION, and both want the colour. Green means LONG here, so a green “Filled” on a short fill states the opposite of the truth.
- ·Split by job — the toast container carries status only, and direction lives in the body as a Side badge, which always prints the word
- ·There is deliberately no buy/sell toast intent; a completed fill is `neutral`, not `success`, because --color-success aliases the long green
- ·Rejections keep their glyph: a failure is not a direction, and the triangle carries it without hue
- ·The visible stack is capped at four — a market order against a thin book fills in partials and an uncapped queue buries the market bar
- ·Radix keeps a toast mounted while its exit animation runs, so dismissal is not a race between a timer and a transition
- ·Found and fixed: Dialog's `animate-in fade-in` were tailwindcss-animate classes with no plugin installed — dead CSS behind a green build
- 11
Global header and landing page
doneDeposit and the account menu moved out of the market bar, which had been holding account-level actions only because it was the sole piece of top chrome. The terminal's fixed-viewport contract survived the extra 56px row.
- ·Radix DropdownMenu for the account menu — items style off data-highlighted, so arrowing looks identical to mousing
- ·The margin used/free Seam appears inside the menu: repetition is what makes a signature one
- ·Header is a hard h-(--size-header) shrink-0 row inside the h-dvh column — no min-height, or short laptops grow a page scrollbar again
- ·Landing page is the only route allowed the display type scale, and is deliberately static: the feed falls back to a simulator, and a marketing page quietly showing invented prices is worse than one showing none
- 12
Review pass — the two things that were wrong
doneBoth reported from looking at the built screen, and both were real. One was a composition bug in CSS that no build could have caught; the other was a design failure that a contrast measurement turned into a number.
- ·Dialogs jittered on open and close: Tailwind v4 compiles -translate-x-1/2 to the INDEPENDENT `translate` property, not `transform`. The keyframe also said translate(-50%,-50%), and since CSS applies `translate` before `transform`, the whole animation ran at -100%/-100% and snapped into place when it ended
- ·Fix: keyframes animate `transform` only and never restate the centring — scale composes cleanly, translate does not. Measured after: 0px of centre drift across the entrance and every scrub point of the exit
- ·Toasts were going unnoticed. Measured: surface-overlay against the panel behind it is 1.06:1 — for practical purposes the same colour, and shadow cannot rescue it on near-black where elevation is carried by lightness
- ·Fix: a dedicated surface-toast at the top of the elevation ladder (1.24:1), border-strong, shadow-e4, and a grey stripe on the neutral intent so a fill has the same anatomy as every other toast
- ·The entrance now carries the attention rather than the position: a short travel, then TWO luminance ticks peaking at 1.70:1 before cooling to rest over 560ms — a repeated signal reads as deliberate where one can be mistaken for a repaint, and the eye catches the second even if it missed the arrival
- ·Runs `linear` so the keyframe percentages are a rhythm rather than an ease curve; only the travel segment keeps ease-out-quart, via a per-keyframe animation-timing-function
- ·Toasts moved from bottom-right to top-right, offset by --size-header. Bottom-right was dead space diagonally opposite the dialog the user had just dismissed; docking at top-0 would have covered the Deposit button and account menu, so it clears the header and covers the ambient market bar instead
- ·The stack is flex-col-reverse: newest nearest the docked edge, so an expiring toast is removed from the far end and nothing above it shifts
- 13
Backend integration
Replace the five placeholder modules with a real API client: markets, candles, account balances, the account tables and the order submit path. Fills arrive over the private ws-server channel rather than the local stand-in that fires them today.
Stack decisions
- Why
- Routing, route groups and a marketing surface come free, and the terminal is one route group away from the landing page.
- Trade-off
- SSR earns almost nothing here — the terminal is entirely client-side and realtime. A Vite SPA would have been the leaner honest fit.
- Why
- CSS-first tokens generate their own utilities, so a semantic token and its class name cannot drift apart. One definition, one source of truth.
- Trade-off
- Ties the token layer to Tailwind's namespace rules — which bit immediately, see the duration gap below.
- Why
- What every serious trading terminal ships, and it concentrates effort on getting one theme genuinely right rather than two adequately.
- Trade-off
- Light mode is deferred, not free — but because components only ever read Layer 2, adding it is a remap of one file rather than a component refactor.
- Why
- 45kb, purpose-built for candles plus volume plus crosshair, and themeable from our own color tokens.
- Trade-off
- Less composable than a React-native charting library; custom overlays mean working through its imperative API.
Design decisions
- Why
- Backpack's primary CTA is green and it works, because for them green IS buy. Splitting them keeps green meaning exactly one thing: direction.
- Trade-off
- Loses the single-accent cohesion of the reference. Worth it — if the confirm button, active tab and a long position shared a hue, green would stop carrying information.
- Why
- The fill green fails contrast as 11px type on the near-black canvas. Green numbers need the lighter step.
- Trade-off
- Two tokens where designers expect one, so it needs documenting — a single token would have forced a choice between a washed-out button and unreadable book rows.
- Why
- Liquidation proximity is a domain signal, not a UI state. Aliasing it to success/warning/danger couples two things that will be retuned independently.
- Trade-off
- Four more tokens, and a near-duplicate of the feedback palette today. They diverge the first time either is adjusted.
- Why
- A Button carrying intent × size × state is where class strings rot. CVA makes the matrix declarative and the props type-safe, so an invalid intent fails at compile time.
- Trade-off
- Three more dependencies and a merge layer that has to be taught the custom token scale — which itself turned out to be load-bearing rather than optional.
- Why
- Keyboard behaviour, form participation and the mobile picker come free and correct. A custom listbox would need focus trapping and type-ahead just to match the platform.
- Trade-off
- Less styling control — the native select's open dropdown is OS-rendered and cannot be themed beyond option colors.
- Why
- The first pass recessed inputs on the theory that a well reads as 'type here'. In the built terminal they disappeared into the page, and every comparable product (Backpack, Binance, Bybit, Hyperliquid, dYdX) raises inputs on dark themes. The minority convention lost on contact with the real screen.
- Trade-off
- surface-inset had to be re-scoped to genuinely recessed things — slider grooves, table headers — rather than deleted, so the token now means something narrower than its name suggests.
- Why
- Measured rather than eyeballed: the originals sat far above the 4.5:1 AA floor, and on a screen that is mostly green and red that surplus read as glare. Every step still clears AA for body text.
- Trade-off
- Less headroom for future dark-on-colour combinations, and short-600 is now large-text-only at 3.4:1.
Open questions
recorded, not resolvedCarried deliberately rather than quietly. Each one has a decision point attached so it does not become an accidental default.
No private websocket channel
The ws-server broadcasts public market data only, keyed by market. Fill notifications and order-status updates have no push path, so the UI would have to poll REST after submitting an order. A per-user channel is agreed but not built; Phase 3 onward assumes push, not polling. This is the largest open item before integration.
Account state is duplicated
A hardcoded balance of 2521 appeared in the order ticket, the deposit dialog and the balances table, with two other figures elsewhere — four surfaces, four numbers. Resolved: `AccountProvider` in lib/account.tsx now owns one snapshot from GET /equity/balances, and every surface reads it. Equity is available + locked, summed as decimal strings rather than floats.
Mobile is verified analytically, not visually
The layout has been checked for page-level overflow and min-width overruns by measuring the DOM, but never actually viewed at a phone width — window resizing is not available in this environment. Worth confirming in DevTools device mode before more layout work lands on top of it.