design-system
DAX Design System
DAX Design System
Practical reference for the CSS primitives that power user, admin, and merchant surfaces. Source of truth lives in code:
app/styles/tokens.css ← all CSS custom properties
app/styles/typography.css ← font roles + utilities + coin lockup
app/styles/motion.css ← keyframes + reduced-motion guard
app/globals.css ← component primitives (.b-* / .dax-*) + reset + focus rules
Two palettes, one component vocabulary. Audience determines the palette
via [data-shell="user|admin|merchant"] on the layout root — there is no
runtime theme toggle.
| Shell | Palette | Surfaces | When |
|---|---|---|---|
user, admin (default) |
Bright sage | oklch(0.94 .. 1) light |
End-user wallet + admin console |
merchant |
Deep slate | oklch(0.19 .. 0.27) dark |
Merchant payouts / invoices / API console |
Tokens
All tokens are CSS custom properties scoped per-shell. Consume tokens — never inline OKLCH literals in component CSS.
Surfaces
| Token | Role |
|---|---|
--bg |
Page background |
--bg-elev |
Panel / card on top of --bg |
--bg-sunk |
Inset surface (input rows, action chips, sub-cards) |
Ink
| Token | Role | Contrast on --bg |
|---|---|---|
--ink |
Primary text | AAA |
--ink-2 |
Secondary text, table cells, sidebar nav idle | AA |
--ink-3 |
Muted captions, table headers, time stamps | AA (lifted on merchant from 0.58 → 0.62 to clear small-text AA) |
Status
Each status color comes with a -soft companion. Use --pos etc. for
foreground (text, icons, lines), -soft for backgrounds (pills,
banner fills, row tints).
| Token | -soft | When |
|---|---|---|
--pos |
--pos-soft |
Successful payment, paid invoice, completed batch |
--neg |
--neg-soft |
Errors, blocked rows, void / due / failed |
--warn |
--warn-soft |
Pending, opened, executing, broadcast |
--info |
--info-soft |
Validated, ready, neutral system info |
Borders
| Token | Role |
|---|---|
--rule |
Default borders (panels, lines, table headers) |
--rule-2 |
Subtle dividers (table inner rows) |
Accent
| Token | Role |
|---|---|
--accent |
Brand accent — focus rings, primary CTA on merchant, ticker prompt |
--accent-soft |
Active states (chip on, asset selected, accent pill bg) |
--accent-ink |
Foreground that sits on --accent-soft |
Type families
| Token | Stack |
|---|---|
--font-sans |
Inter → system sans |
--font-mono |
JetBrains Mono → SF Mono → Menlo |
--font-serif |
Aliased to --font-sans (Newsreader retired) |
Type scale
--text-2xs 10px — kbd hints, table sublabels
--text-xs 11px — muted captions
--text-sm 12px — secondary body, mono data
--text-md 13px — tables, default body
--text-base 14px — form inputs, primary body
--text-lg 16px — sidebar wallet fiat
--text-xl 22px — panel headers
--text-2xl 28px — section heroes
--text-3xl 44px — balance totals (mono, tnum)
Line-height tokens: --leading-tight 1.15 · --leading-snug 1.35 · --leading-base 1.5
Tracking tokens: --tracking-tight -0.02em · --tracking-snug -0.01em · --tracking-loose 0.04em
Radius
| Token | Value | Use |
|---|---|---|
--radius-sharp |
2px | Sort buttons, micro chips |
--radius-sm |
3px | Form lines, table inner cells, default chip |
--radius-md |
8px | Secondary buttons, input groups |
--radius-lg |
14px | Panels, cards, login surfaces |
--radius-pill |
999px | Sidebar nav, primary buttons, status pills |
Spacing (4-step grid)
--space-1 4 · --space-2 8 · --space-3 12 · --space-4 16 · --space-5 20 · --space-6 24 · --space-8 32 · --space-10 40
Motion
| Token | Value | Use |
|---|---|---|
--motion-fast |
120ms | Hover state shifts, chip toggles |
--motion-base |
200ms | Default UI transitions |
--motion-slow |
320ms | Page-level fade-in, drawer open |
--ease-out |
cubic-bezier(0.2, 0, 0, 1) |
Default for entering/state changes |
--ease-in |
cubic-bezier(0.4, 0, 1, 1) |
Exit/dismiss transitions |
Typography
Three layers — pick the layer that matches your intent.
Utilities
| Class | Effect |
|---|---|
.mono |
JetBrains Mono + tabular numbers |
.tnum |
Tabular numbers only (keep current font) |
.serif |
Currently aliased to sans (Newsreader retired) |
Semantic roles
Apply by intent, not by appearance.
| Class | Family | Size | When |
|---|---|---|---|
.t-display |
mono | --text-3xl |
Balance totals, hero numbers |
.t-headline |
sans | --text-xl |
Panel headers (▸ surface.name) |
.t-title |
sans | --text-md 500 |
Card titles, list items |
.t-body |
sans | --text-md 400 |
Default body copy |
.t-data |
mono | --text-sm |
Tables, KV pairs, terminal logs |
.t-label |
mono | --text-2xs uppercase |
Form labels, section captions |
Decorative
| Class | Use |
|---|---|
.coin |
Single-letter colored monogram disc — pair with `data-sym="BTC |
Component primitives
All primitives live in app/globals.css. The list below is non-exhaustive
— app/globals.css is the canonical source. Use these classes; do not
hand-roll Tailwind utility salads on merchant/admin surfaces.
Layout shell — .b-shell, .b-side, .b-topbar
<div className="dir-b">
<div className="b-shell">
<aside className="b-side">…sidebar…</aside>
<div className="b-topbar">…topbar…</div>
<main>…page content (typically wrapped in `.b-grid` or `.b-flow`)…</main>
</div>
</div>
.b-sideis a 240px fixed left rail; collapses to off-canvas drawer ≤900px (controlled bydata-side-openon.b-shell)..b-topbaris a slim 14×22 row aligned with sidebar top; holds the icon strip and logout button.
Panel — .b-panel
The canonical content surface. Always paired with .b-panel-h + .b-panel-body.
<section className="b-panel" style={{ gridColumn: '1 / -1' }}>
<div className="b-panel-h mono">
<span>▸ surface.name</span>
<span className="b-panel-sub">12 rows · updated 2m ago</span>
</div>
<div className="b-panel-body">…content…</div>
</section>
Tables — .b-table
<table className="b-table mono">
<thead><tr><th>#</th><th>address</th><th className="r">amount</th></tr></thead>
<tbody>
<tr className="clickable">
<td>…</td>
<td>…</td>
<td className="r tnum">…</td>
</tr>
</tbody>
</table>
States: tr:hover → --bg-sunk, tr.on → --accent-soft, tr.clickable cursor: pointer.
Status pills — .b-inv-status.s-<status>
| Class | Color family | Used for |
|---|---|---|
.s-draft, .s-expired, .s-dead |
muted gray | Inactive |
.s-sent, .s-detected |
accent | Soft activity |
.s-opened, .s-open, .s-pending, .s-broadcast, .s-executing, .s-queued, .s-underpaid |
warn (amber) | In-flight |
.s-validated, .s-ready, .s-overpaid |
info (blue) | Pre-flight / surplus |
.s-paid, .s-confirmed, .s-completed, .s-delivered |
pos (green) | Success |
.s-due, .s-overdue, .s-void, .s-hold, .s-failed, .s-blocked, .s-invalid_address, .s-invalid_amount, .s-insufficient_balance |
neg (red) | Error / blocking |
.s-partial_success |
orange | Mixed outcome |
Form lines — .b-line
<label className="b-line">
<span className="b-lbl">amount:</span>
<input type="text" />
</label>
States: :focus-within → border switches to --accent. For invalid input,
add aria-invalid="true" on the wrapper (<label>) — wire styling via
.b-line[aria-invalid="true"] if you need a visible error tint.
Buttons — .b-btn, .b-btn.primary, .b-btn.ghost
<div className="b-btn-row">
<button className="b-btn ghost">cancel</button>
<button className="b-btn primary">confirm ⏎</button>
</div>
Chips — .b-chip
<div className="b-chips">
<button className="b-chip on">all</button>
<button className="b-chip">paid</button>
<button className="b-chip">due</button>
</div>
Filter grid — <FilterGrid> / <FilterRow> / <FilterForm>
Use the React components in components/filters/filter-grid.tsx, not raw
.dax-filter-* classes. They keep status / created / search rows aligned
across pages.
Accessibility
Focus rings
A single global rule in app/globals.css covers all interactive
primitives (a, button, [role="button"], form controls,
[tabindex]). The ring is 2px solid var(--accent) with a 2px offset.
Don't override or remove this in component CSS. If a primitive needs
a custom ring, replace via :focus-visible only (not :focus).
Reduced motion
app/styles/motion.css ends with a @media (prefers-reduced-motion: reduce) block that:
- Caps all animation/transition durations at 0.01ms globally.
- Keeps the braille loading spinner running (slows it to 1s steps) — it communicates ongoing work and silencing it would create false-success confusion.
- Replaces shimmer skeletons with a flat
--bg-sunkblock.
Contrast
Run color pairs through a contrast checker before adding a new token. Targets:
- Body text (≥14px): 4.5:1
- Large text (≥18px or ≥14px bold): 3:1
- UI glyphs (icons, dividers): 3:1
Merchant --ink-3 was lifted from oklch(0.58 …) to oklch(0.62 …) to
clear AA on .b-panel-sub, .muted, and table headers.
Keyboard navigation
- All interactive elements must be reachable via Tab in visual order.
- Use
<button>for actions,<a>for navigation. Don't put click handlers on bare<div>s. - Modal dismiss must be wired to ESC. The login flow uses
<form>so Enter submits naturally. - Tables: rows with
.clickableshould announce as buttons or wrap an<a>— not a click handler on the<tr>.
Pre-delivery checklist
Before merging UI changes, verify:
- No new component-local OKLCH literals (audit
git diffforoklch() - No
font-size:/font-family:in components — use.t-*or token - No new keyframes outside
app/styles/motion.css - Hover/active/focus states all visually distinct
- Both shells exercised (light + dark) — check
[data-shell]toggle in DevTools - Touch targets ≥44px on phone breakpoint
- Tested at 375 / 768 / 1024 / 1440
- No
console.logleft over (pnpm lintwill catch) -
pnpm typecheckclean -
pnpm testclean
Migration notes
The system was reorganized in May 2026. If you're updating older code that pre-dates this:
font-family: var(--font-newsreader)→ use.t-displayorvar(--font-serif)(which now aliases to sans).- Inline
oklch(0.96 0.07 28)for error backgrounds →var(--neg-soft). - Inline
oklch(0.95 0.05 155)for success backgrounds →var(--pos-soft). - Inline
oklch(0.95 0.06 80)for warning backgrounds →var(--warn-soft). - Inline
oklch(0.95 0.05 255)for info backgrounds →var(--info-soft). - Hard-coded
border-radius: 14pxon a panel →var(--radius-lg). - Hard-coded
transition: … .14s→var(--motion-fast) var(--ease-out).
Migrating one component per PR is fine — old hex values still resolve to the same colors, so behavior is preserved.