Design System (Frontend)
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
| Concern | File (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 + cn | patterns.tsx (via index.ts) | @aset/ds-editorial |
Showcase — the living /styleguide page | showcase.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.cssoverrides--font-sansto 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 bareCardTitle, so they inherited Quicksand and read as unstyled next to every other admin page title; they now usefont-serif font-semibold, matchingpage-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-only —stat18→20,lead20→24,dstat24→32,hnum28→40,hero32→48, each ramping over a 375→1280px viewport (v3-129).⚠️ The fluid tokens reach
apps/landingtoo, which imports the sametokens.css. Fourteen call sites there usetext-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 inapps/landing/global.css's own@theme(it already overrides--font-sansthat way, and its@themeis 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:
/* 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. *//* 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
fonts.cssmust be first. CSS requires@import url()before any real rules; admin loads the kit (which has real rules), so isolating the font@importand placing it first keeps it valid.- In admin,
tokens.cssmust come after the kit.@themeblocks merge last-wins, so importing afterfinance-dashboard-kitlets 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:
| Rung | Width | Device class | What switches here |
|---|---|---|---|
| base | <640 | phone | — |
sm | ≥640 | large phone / small tablet | — |
md | ≥768 | tablet | web swaps bottom tabs for header nav |
lg | ≥1024 | laptop | admin swaps the drawer for the sticky rail |
xl | ≥1280 | desktop | web gutters stop growing (64px) |
2xl | ≥1536 | monitor | the 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.AppShellputs a 240px sidebar left of the routed content atlg+, 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-3fired at 1280 while the column held 912px and dealt 288px cards, and a2xl:grid-cols-4dealt 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
lgup (BROWSE_GRIDinroutes/pools.tsx, shared by the skeleton and the loaded list).auto-fill, notauto-fit: with fewer pools than tracks,auto-fitcollapses 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:
Floor Min card ( lg+)Band losing a column 4th column from 300px 300 1280–1315 1640px 330px 330 1280–1405 1760px 368px 368 1024–1519 1912px Grids with a fixed item count stay on plain columns:
portfolio's "Discover more" renders.slice(0, 3), so anauto-filltrack it can never fill would just narrow three cards and leave a slot empty.admin had no cap at all —
mainran 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-monitorcap.
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 survivesThis 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:
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-700This 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.cssonce → 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 intokens.css. - Change a pattern component: edit
patterns.tsx→ both apps and the/styleguideupdate.
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 buildChecked 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.