Perp
Perp v2 · Frontend

Design system

A living reference for the trading terminal — every swatch on this page renders through the real token and reports its own computed value, so these docs cannot drift from tokens.css. The build log at the end records how it was decided.

Next.js App RouterTailwind v4 @themeCharts Lightweight ChartsGrid 4pxFamilies 2Theme Dark-only
01

Token architecture

app/styles/tokens.css

Two 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.

Layer 1
Primitives
Raw, context-free values. Numbered ramps with no opinion about usage. Never referenced directly in JSX.
--color-neutral-950--color-long-500--color-primary-500
Layer 2
Semantic aliases
Purpose-named, pointing at Layer 1. This is the entire public API of the system.
--color-surface-raised--color-buy--color-text-tertiary
Consumption
Generated utilities
Tailwind derives a utility per semantic token, so the class name states the intent.
bg-surface-raisedtext-buy-textborder-border-subtle
02

Color

5 ramps · 58 semantic tokens

Layer 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.

950
900
850
800
750
700
600
500
400
300
200
100
50
neutral · the backbone
300
400
500
600
700
primary · interactive
300
400
500
600
700
long · buy
300
400
500
600
700
short · sell
300
400
500
600
secondary · accent

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.

TokenComputed · source
--color-surface-base
App canvas
 
neutral-950
--color-surface-raised
Panels, cards, rails
 
neutral-900
--color-surface-input
Text fields — raised above the panel
 
literal
--color-surface-overlay
Dropdowns, popovers
 
neutral-850
--color-surface-modal
Dialogs, bottom sheets
 
neutral-800
--color-surface-toast
Toasts — the top of the ladder
 
neutral-750
--color-surface-toast-flash
Brighter step a toast settles down from on arrival
 
literal
--color-surface-inset
Slider grooves, table headers
 
literal
--color-surface-hover
Row and control hover
 
white 4%
--color-surface-active
Pressed state
 
white 8%
--color-surface-selected
Active tab, chosen row
 
primary 12%

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.

TokenComputed · source
--color-text-primary
Prices, values, headings
 
neutral-100
--color-text-secondary
Supporting copy, sizes
 
neutral-300
--color-text-tertiary
Column headers, labels
 
neutral-400
--color-text-disabled
Inactive controls
 
neutral-500
--color-text-inverse
On filled buy / primary
 
neutral-950
--color-text-link
Inline links
 
primary-400

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.

TokenComputed · source
--color-buy
Solid fill — buy button, bid bar
 
long-500
--color-buy-text
Green type on dark
 
long-400
--color-buy-muted
Generic tint
 
long 14%
--color-buy-depth-total
Order book — cumulative depth, the rear bar
 
long 13%
--color-buy-depth-level
Order book — this level's own size, front bar
 
long 32%
--color-sell
Solid fill — sell button, ask bar
 
short-500
--color-sell-text
Red type on dark
 
short-400
--color-sell-muted
Generic tint
 
short 14%
--color-sell-depth-total
Order book — cumulative depth, the rear bar
 
short 13%
--color-sell-depth-level
Order book — this level's own size, front bar
 
short 32%

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.

TokenComputed · source
--color-interactive
Primary CTA, active tab
 
primary-500
--color-interactive-hover
Hover
 
primary-400
--color-interactive-active
Pressed
 
primary-600
--color-interactive-muted
Selected background
 
primary 12%

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.

TokenComputed · source
--color-health-safe
Margin ratio comfortable
 
long-500
--color-health-caution
Approaching maintenance
 
warning-500
--color-health-risk
Reduce or add margin
 
literal
--color-health-critical
Liquidation imminent
 
danger-500

Layer 2 · Borders

Subtle must nearly disappear — it separates table rows. Strong must be findable without being loud — it outlines inputs.

TokenComputed · source
--color-border-subtle
Table rows, panel splits
 
neutral-800
--color-border-default
Panel outlines, dividers
 
neutral-700
--color-border-strong
Input outlines
 
neutral-600
--color-border-focus
Keyboard focus
 
primary-500
--color-border-error
Validation failure
 
danger-500
Accessibility caveat carried forward. Red/green as the sole carrier of direction fails for roughly 8% of men with deuteranopia. Every buy/sell surface must also encode direction non-chromatically — a +/− sign, a LONG/SHORT label, or bar orientation. The semantic layer makes an alternate palette a one-file edit; it does not excuse relying on hue alone.
03

Typography

IBM Plex superfamily

Two 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.

Why not a neutral grotesque. The first pass used Inter, which is the reflexive choice and reads as one. It was swapped after a specimen bake-off against Plex, Archivo and Instrument Sans in a real order book. Plex won on a structural argument rather than taste: mono and sans sit adjacent inside the same table row here, and a superfamily shares a skeleton, x-height and weight axis, so those columns stay optically aligned. Archivo had the tightest set width but brings no matched mono.
display-lg48 / 1.08 / −0.03em · 600Marketing only
Trade perpetuals
display-md36 / 1.14 / −0.025em · 600
Sub-millisecond matching
heading-lg28 / 1.2 / −0.02em · 600
Portfolio overview
heading-md22 / 1.26 / −0.015em · 600
Open positions
heading-sm18 / 1.33 / −0.01em · 600
Cross margin overview
body-lg16 / 1.5 / 0 · 400
Deposit collateral to begin trading with leverage.
body-md14 / 1.45 / 0 · 400App default
Your order will rest on the book until filled or cancelled.
body-sm13 / 1.4 / 0 · 400Table workhorse
Limit order · SOL-USD · reduce only
caption12 / 1.35 / 0.005em · 400
Estimated liquidation price updates as margin changes.
micro11 / 1.3 / 0.04em · 500
Price (USD) · Size (SOL) · Total
num-xl24 / 1.16 / −0.015em · 600Header last price
205.09
num-lg20 / 1.2 / −0.01em · 600
18,485,368.61
num-md14 / 1.36 / 0 · 500Book rows. Coloured cells drop to 400
204.96 245.09 289.14
num-sm13 / 1.33 / 0 · 500
+4.96 +2.48% 00:25:50
04

Spacing

4px base grid

A 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.

0.52px
14px
1.56px
28px
312px
416px
520px
624px
832px
1040px
1248px
1664px

Fixed layout constants

Chrome dimensions are tokenised too, so panels compute against them with calc() instead of scattering magic numbers across components.

TokenValueApplies to
--size-header56pxGlobal top nav
--size-market-bar64pxTicker / stats strip
--size-order-form320pxRight rail at lg; 344px at xl
--size-orderbook288pxBook column; 320px at xl
--size-row26pxOrder-book / trades row — sets book density
--size-control-md36pxDefault input / button height
--size-control-lg44pxMobile minimum touch target
--size-bottom-nav56pxMobile tab bar only
05

Radii & borders

7 steps · 5 border tokens

Radius encodes scale: the larger the surface, the softer the corner. Controls sit at md, containers at lg, and only modals reach xl.

none0px · Tables
xs3px · Depth bars
sm4px · Tags, pills
md6px · Controls
lg8px · Panels
xl12px · Modals
full9999px · Avatars, toggles

Border tokens

TokenPurpose
--color-border-subtleTable rows, panel splits
--color-border-defaultPanel outlines, dividers
--color-border-strongInput outlines
--color-border-focusKeyboard focus
--color-border-errorValidation failure
06

Elevation

5 tiers · 4 rings

Each 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.

e0Flush · canvas
e1Cards, panels
e2Dropdowns
e3Modals
e4Command palette

Rings — state, not depth

focusKeyboard focus
focus-errorInvalid input
glow-buyArmed buy CTA
glow-sellArmed sell CTA
07

Motion

4 durations · 3 curves · 8 animations

Trading 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.

TokenValueApplies to
--duration-instant80msColor and opacity on press
--duration-fast120msHover, focus, tab switch
--duration-base180msDropdowns, tooltips, accordions
--duration-slow260msModals, mobile bottom sheet
--ease-out-quart0.25, 1, 0.5, 1Default — entrances
--ease-in-out-quart0.76, 0, 0.24, 1Between two anchored states
--ease-spring0.34, 1.56, 0.64, 1Toggles, sliders — slight overshoot

Named animations

TokenTimingApplies to
--animate-shimmer1.6s linear infiniteSkeleton sweep — the only looping animation in the app
--animate-overlay-in / -outbase / fastDialog and sheet scrims
--animate-dialog-in / -outbase / fastCentred modal — scales from 0.97; animates transform ONLY, never translate
--animate-sheet-in / -outslow / baseMobile bottom sheet — the one surface allowed a full-height travel
--animate-menu-in / -outfast / instantDropdown menus, from the edge Radix placed them against
--animate-toast-in / -out560ms linear / fastDesktop toast — short travel, then two luminance ticks that cool to rest
--animate-toast-in-mobile560ms linearMobile toast, arriving downward from the top
--animate-flash-buy / -sell400msPrice-tick flash on an order-book level
Every animation is a token because inline ones failed silently. Dialog previously carried 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.
Price-tick flashes are load-bearing, not decoration. The book repaints many times per second; 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.
08

Breakpoints & layout

3 targets · 2 transitional

Mobile-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.

base< 768
chart / book / form — tab-switched
md768 – 1023
chart
book
lg1024 – 1439
chart
book
order form
xl1440 – 1919
chart + bottom tabs
book / trades
order form
2xl≥ 1920
chart + persistent tabs
book / trades
order form
TargetRangeLayout behaviour
base< 768Single column. Order form promotes to a bottom sheet; book, chart and trades become tabs; 56px bottom nav; all touch targets ≥ 44px.
md768 – 1023Transitional. Chart and book share a row, order form still a sheet.
lg1024 – 1439Three columns appear. Order form docks to a 320px right rail; bottom tabs scroll horizontally.
xl1440 – 1919Full terminal. 344px form, 320px book, bottom tab panel always visible.
2xl≥ 1920Content caps and centers; book widens to show cumulative totals rather than stretching the chart indefinitely.
09

Build log

how it was decided

Kept 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.

  1. 00

    Read the system before drawing anything

    done

    Mapped 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
  2. 01

    Resolve the stack forks

    done

    Four decisions that would have been expensive to reverse later: framework, token authoring, theme scope and charting. Settled before a line of CSS.

  3. 02

    Build the two-layer token system

    done

    Layer 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
  4. 03

    Verify the tokens actually compile

    done

    Grepped 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
  5. 04

    Catch the tree-shaking trap

    done

    Rendering 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
  6. 05

    Base components

    done

    Eighteen 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
  7. 06

    Design pass — type and signature

    done

    A 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
  8. 07

    Design review pass

    done

    First 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
  9. 08

    Overlays, scrolling and elevation

    done

    Radix 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
  10. 09

    Waiting, absence and failure

    done

    Every 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
  11. 10

    Toasts, and the status/direction collision

    done

    Fill 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
  12. 11

    Global header and landing page

    done

    Deposit 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
  13. 12

    Review pass — the two things that were wrong

    done

    Both 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
  14. 13

    Backend integration

    next

    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

Framework
Next.js App Router
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.
Token authoring
Tailwind v4 @theme
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.
Theme scope
Dark-only
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.
Charting
TradingView Lightweight Charts
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

Primary is blue, not the buy green
--color-interactive → primary-500
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.
buy and buy-text are separate tokens
long-500 for fills, long-400 for type
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.
Margin health is its own ramp
Four stops, separate from feedback
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.
Variants typed, not hand-written
CVA + a configured tailwind-merge
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.
Native elements under every control
Real inputs and selects, styled via peer/appearance-none
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.
Inputs are raised — reversed after review
--color-surface-input sits above the panel
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.
Directional ramps dimmed
Greens ~8-11:1 down to ~6.4-7.6:1
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.
10

Open questions

recorded, not resolved

Carried 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.