Skip to content

Design System (Frontend)

Verified 2026-08-13 · Decisions v3-77, v3-129

The frontend design system lives in one shared workspace package, @aset/ds-editorial, consumed by both apps (apps/web, apps/admin-web). Fonts, colors, type scale, semantic tokens, the pattern components, and the showcase all have a single source there — so a font or color change is a one-file edit that both apps pick up, instead of drifting across per-app CSS.

This page exists so backend/product can see and verify the structure. It is not app-specific logic.

Where everything lives

ConcernFile (in packages/ds-editorial/src/)Import path
Font loading (@import url() Google Fonts)fonts.css@aset/ds-editorial/fonts.css
Values — palette, type scale, font-family tokens, shadcn semantic --color-*tokens.css@aset/ds-editorial/tokens.css
Components — the 5 canonical patterns + cnpatterns.tsx (via index.ts)@aset/ds-editorial
Showcase — the living /styleguide pageshowcase.tsx@aset/ds-editorial/showcase
  • Fonts: Quicksand (sans) + DM Serif Display (display/serif). Serif is for display figures (≥ 20px stat/lead/hero numbers) and page/section titles (H1) — e.g. the admin page headers ("Create Pool", "Yield"). Dense scanned/read text stays sans.

    ⚠️ Google Sans is the marketing site's font, not the product's. apps/landing/global.css overrides --font-sans to Google Sans for itself only, and the face is not loaded in web or admin at all. The app's title font is DM Serif Display. Admin's auth cards (login, super-login, totp-step, fund-invite-accept) were the one place carrying a bare CardTitle, so they inherited Quicksand and read as unstyled next to every other admin page title; they now use font-serif font-semibold, matching page-header.tsx.

  • Type scale: text-{label,data,read,stat,lead,dstat,hnum,hero}. The three reading sizes are fixed at 12 · 14 · 16; the five display sizes are fluid and shrink-onlystat 18→20, lead 20→24, dstat 24→32, hnum 28→40, hero 32→48, each ramping over a 375→1280px viewport (v3-129).

    ⚠️ The fluid tokens reach apps/landing too, which imports the same tokens.css. Fourteen call sites there use text-lead / text-stat, so their body copy now runs 20→24px and 18→20px instead of sitting fixed at 24 / 20 — consistent with that site's own --text-display-* clamps, but it is a marketing-site change, not only an app one. To opt out, re-pin the token in apps/landing/global.css's own @theme (it already overrides --font-sans that way, and its @theme is imported after this package's, so it wins).

  • Palette: brand / info / lavender / lilac / success / error / mist / neutral / navy, plus shadcn semantic tokens mapped to the Aset brand. Brand #5423e7.

  • Patterns: Stat, Meter, Fact line, Waterfall, Ledger — flat / hairline, anti-over-boxing (no cards or fills).

Import order (this matters)

Each app's global.css must import in this exact order:

css
/* apps/web/global.css */
@import '@aset/ds-editorial/fonts.css';   /* 1. font loading — must be first */
@import '@aset/ds-editorial/tokens.css';  /* 2. tokens */
@import 'tailwindcss';                     /* 3. */
css
/* apps/admin-web/global.css */
@import '@aset/ds-editorial/fonts.css';                /* 1. font loading — must be first */
@import '@shard-lab/finance-dashboard-kit/styles';     /* 2. the kit */
@import '@aset/ds-editorial/tokens.css';               /* 3. AFTER the kit — Aset palette wins */
@import 'tailwindcss';                                  /* 4. */

Two ordering rules

  1. fonts.css must be first. CSS requires @import url() before any real rules; admin loads the kit (which has real rules), so isolating the font @import and placing it first keeps it valid.
  2. In admin, tokens.css must come after the kit. @theme blocks merge last-wins, so importing after finance-dashboard-kit lets the Aset palette override the kit defaults. This is what unifies admin's colors with web.

Result: web and admin render one identical palette. Each global.css keeps only its app-local bits (web: radius, shadcn :root HSL triplets; admin: the kit's sidebar tokens).

Breakpoints and the page column v3-129

Tailwind's default breakpoints are used as-is — no --breakpoint-* overrides, since redefining them would silently move every existing md:/lg: in three apps. They are named here because the product reasons about device classes, not raw numbers:

RungWidthDevice classWhat switches here
base<640phone
sm≥640large phone / small tablet
md≥768tabletweb swaps bottom tabs for header nav
lg≥1024laptopadmin swaps the drawer for the sticky rail
xl≥1280desktopweb gutters stop growing (64px)
2xl≥1536monitorthe column opens up — see below

The monitor rung is where web and admin move in opposite directions, which is the part that is easy to get backwards:

  • web capped its column at 80rem (1280px), so a 2560px display spent half its width on empty gutter. It widens to --container-monitor (105rem = 1680px). 1680 is not arbitrary: it is four browse cards at the 368px they already had.

    ⚠️ The browse grid takes no viewport breakpoints above lg. AppShell puts a 240px sidebar left of the routed content at lg+, and that sidebar collapses at runtime, so the space the cards occupy is simply not the viewport. Every viewport-keyed column step there was wrong in the same direction: xl:grid-cols-3 fired at 1280 while the column held 912px and dealt 288px cards, and a 2xl:grid-cols-4 dealt 274px — both narrower than the 368px the previous column count was already giving. The grid is intrinsic instead:

    grid-cols-1 sm:grid-cols-2 lg:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]

    As many 300px-or-wider tracks as actually fit, so a card never drops below 300 from lg up (BROWSE_GRID in routes/pools.tsx, shared by the skeleton and the loaded list). auto-fill, not auto-fit: with fewer pools than tracks, auto-fit collapses the empty tracks and stretches two cards to ~760px each. Phone and tablet keep the explicit 1-up / 2-up steps, where the viewport is the width.

    300px is the one knob, and it trades a minimum card width against how early a column arrives. Raising it widens the band where a column is lost relative to the old ladder; lowering it approaches the 288px this was fixing. 300 is the setting in place, chosen to keep the lost-column band narrow:

    FloorMin card (lg+)Band losing a column4th column from
    300px3001280–13151640px
    330px3301280–14051760px
    368px3681024–15191912px

    Grids with a fixed item count stay on plain columns: portfolio's "Discover more" renders .slice(0, 3), so an auto-fill track it can never fill would just narrow three cards and leave a slot empty.

  • admin had no cap at all — main ran to the window edge. The list routes are wide tables (Pools carries seven columns), so the same display stretched a row across ~2496px and pairing a pool name on the far left with its chain on the far right became an eye-trip. It gains the same --container-monitor cap.

One token, two directions, so the pair stays a single decision. Detail/create/edit routes in admin keep their own tighter max-w-[1200px], which still wins inside the cap.

web's column is a utility, not a component. page-shell (in apps/web/global.css) owns centring, the gutter ramp and the cap; it replaced the same string copy-pasted into nine routes plus a second, tighter variant in the header, footer and skeletons. Two ramps meant the header logo sat 32px inside the content's left edge at xl — a shell that did not line up with the page under it. Vertical rhythm is deliberately not in the utility: routes legitimately differ (py-10 lg:py-12 normally, py-16 for empty states, pt-10 pb-24 where the bottom nav overlaps). One route (pool.$id) spells the column out instead, because it left-anchors at lg+ (lg:mx-0) and the utility centres unconditionally.

tailwind-merge silently deletes these tokens inside kit components

Every kit / shadcn component composes its class list with cn(...) = twMerge(clsx(...)). tailwind-merge's default config only recognises its own font-size scale (text-sm, text-2xl, text-[13px], …). A custom theme key like text-lead matches none of those patterns, so it is filed under text-color instead — and the moment the same className also carries a real colour, the two "conflict" and the later one wins:

twMerge('text-lead font-bold text-neutral-700')  →  'font-bold text-neutral-700'   ← size gone
twMerge('text-2xl font-bold text-neutral-700')   →  unchanged                      ← built-in scale survives

This repo already solved it — in two of three places. Both packages/ds-editorial/src/cn.ts and apps/web/app/shared/lib/utils.ts build their cn with extendTailwindMerge, registering all eight tokens under font-size:

ts
const twMerge = extendTailwindMerge({
  extend: { classGroups: { 'font-size': [{ text: ['label','data','read','stat','lead','dstat','hnum','hero'] }] } },
});

So the shared pattern components and every web shadcn component merge correctly, and the tokens are safe there.

⚠️ apps/admin-web has no cn of its own. Its Card / Dialog / Button come from @shard-lab/finance-dashboard-kit, which bundles a plain twMerge and does the merging inside the package — so an extended cn on the admin side would not be consulted. That is the whole exposure: kit components in admin, and nothing else.

For those, write the size as a length, which even a plain tailwind-merge classifies as font-size:

text-(length:--text-lead) leading-tight font-bold text-neutral-700

This form carries the font-size only — the paired --text-*--line-height does not come with it, so add an explicit leading-*.

Why it is worth a callout despite the existing guard: the failure is invisible to every gate. Seven admin CardTitles rendered at inherited size (24px → 16px) with tsc -b, both production builds and the copy guard all green (two DialogTitles had been broken the same way since before this work, at 18px, and the same fix corrected them) — the class is valid, compiles, and sits in the CSS; it is deleted at runtime. A measured getComputedStyle in a real browser was the only thing that caught it, and a DOM sweep looking for elements carrying the token cannot: the class is gone from the element it was meant to size.

⚠️ Pre-existing instances, admin only: pool-controls.tsx:639 and nav-change-dialog.tsx:173 (DialogDescription, text-read dropped → renders 14px instead of 16px). Web's PoolStatusAlert.tsx:216-217 is not affected, despite looking identical: it goes through web's extended cn.

Axis utilities when a ramp meets a one-off

Tailwind emits variants after base utilities, so p-4 sm:p-6 lg:p-8 pb-20 does not keep its pb-20 — the responsive p-* outranks it at every width above sm and quietly cuts the bottom padding to the ramp's value. Ramp the axes instead: px-4 pt-4 pb-20 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8 (admin page-layout.tsx).

How to change a token

  • Change a color or font: edit packages/ds-editorial/src/tokens.css once → both apps reflect it on next build.
  • Change font loading (add a weight, swap a family): edit fonts.css (loading) and the matching --font-* token in tokens.css.
  • Change a pattern component: edit patterns.tsx → both apps and the /styleguide update.

Living styleguide — /styleguide

showcase.tsx renders the real tokens and pattern components (not a copy), so it cannot drift. It is mounted in both apps, outside auth (no data), at:

  • web → /styleguide
  • admin → /styleguide

Because both apps share one palette, the page looks identical in either — viewing it in both is itself the parity check.

Deprecated references

Do not use these — superseded by `/styleguide`

Two earlier hand-built design references duplicated these values and have already drifted (they still show the pre-swap Fraunces/Inter fonts). They are deprecated; use the in-app /styleguide instead.

  • claude.ai artifact "Pool Detail — Component Vocabulary"
  • Google Drive emergefi-color-system.html

Verification

Both apps build with the shared package, and both bundles load the fonts and resolve the brand color:

pnpm --filter @aset/web build
pnpm --filter @aset/admin-web build

Checked in each build/client/assets/*.css: Quicksand + DM Serif Display @import present, --font-sans = Quicksand, brand #5423e7 present, and the /styleguide route chunk is emitted.