The tokens in app/globals.css are the source of truth. This file explains what
each one is for, so the answer to "what size is a heading" or "what colour is a
warning" lives next to the code rather than in a slide deck.
Background and rationale: plans/plan-design-system.md.
Use tokens, never raw palette classes. bg-amber-100, text-green-600 and
border-blue-500 are all bugs — not because they look wrong, but because a fund
that white-labels the app still gets amber locks and blue chips. Every colour
must resolve through a CSS variable so themeCssVars() (lib/theme.ts) can
repoint it.
This was migrated in bulk: 996 raw palette usages across 105 files became 23,
and lib/design-tokens.test.ts now fails the build on new ones. The 23 that
remain are colour used categorically (relationship types, compliance
categories, LP activity kinds) rather than as status; they are allowlisted by
file, with a reason, and need a categorical palette rather than a status token.
Warm-tinted, not pure greyscale — paper is #fdfdfc, ink is #1c1a17. The cast
is under 1% chroma: it reads as considered rather than coloured, and it is the
main thing separating this from a default shadcn install.
| Token | Use |
|---|---|
--background |
The page. Tinted paper |
--foreground |
Body text |
--card |
Pure white — cards lift off the paper without needing a border |
--muted / --muted-foreground |
Secondary surfaces and secondary text |
--border / --input |
Hairlines. 1.34:1 against paper — visible, not loud |
--accent |
shadcn's subtle hover surface. NOT the brand accent |
--accent keeps its shadcn meaning because renaming it would touch every
hover:bg-accent in the app. The brand accent is --brand.
Hue 48, the same highlighter yellow as hemrock.com and nevermodel-site
(switched from evergreen in Sept 2026). --brand is #fcd535 in light and
#fde68a in dark, over eleven stops --brand-50 … --brand-950. The ramp is
hand-tuned, not generated — RAMP_STOPS in lib/theme.ts would grey a yellow
out — and lib/theme.test.ts pins it against globals.css.
| Role | Token | Contrast |
|---|---|---|
| CTA fill | bg-brand text-brand-foreground |
ink on yellow, 12.4:1 |
| CTA hover | hover:bg-brand/90 |
|
| Accent text on paper | brand-700 #80680a |
5.27:1 |
| Dark-mode accent text | brand-400 |
12.45:1 on the dark surface |
| Focus ring | ring-ring (ink) |
|
| Tinted surfaces | brand-50 / brand-100 (dark: brand-950 / brand-900) |
|
| Borders on tinted surfaces | brand-200 (dark: brand-800) |
Yellow is a fill, not a text colour. The fill is 1.4:1 on paper, so
text-brand, border-brand and ring-brand are unreadable. Text on the fill
is always ink. Accent text, when genuinely needed, is the pair
text-brand-700 dark:text-brand-400 — 700 alone is 3.47:1 on the dark surface.
Never put a dark stop (brand-700/800) behind the fill's ink text.
Two different jobs, and the distinction is load-bearing:
--primaryis this deployment's action colour. Neutral by default; the per-fund theme overrides it. A fund's buttons should be the fund's colour.--brandis Hemrock's colour. The marketing site is not under a fund theme, so it always renders the yellow default.
Inside the app, themeCssVars() regenerates the whole --brand-* ramp from the
fund's accent hue, so tints and hairlines follow the fund rather than falling
back to the yellow default. The fund's chosen value stays the fill — it is not
relocated onto stop 700, because amber pushed to 700 is brown.
Amber is a warning, not a brand colour. That is the whole reason a separate accent exists. Each status token is verified both as text on paper and as a fill with white text (≥ 4.5:1 either way).
| Token | Meaning | Light |
|---|---|---|
--success |
Completed, reconciled, passing | #1f7a50 |
--warning |
Needs attention, locked, beta, stale | #a56112 |
--info |
Neutral notice | #1f61ad |
--destructive |
Failed, deleting, irreversible | #bc2424 |
Each has a -foreground (text on the fill) and a -subtle (tinted background).
Use bg-warning-subtle text-warning for a callout, bg-warning text-warning-foreground for a badge.
For identity: telling one kind of thing from another. Compliance categories, relationship tags, chart series. Not for state — that's what the status tokens are, and they are reserved.
Two rules, both load-bearing:
- Fixed order, never cycled. Slot N is always the same hue. A category keeps its colour regardless of which categories are on screen — colour follows the entity, never its rank. Past the last slot, fold into "Other"; never generate a hue.
- Respect the series ceiling. It depends on the chart form, because it depends on which pairs a reader compares:
| Form | Pairs compared | Ceiling |
|---|---|---|
| Stacked bars, lines, chips, legends | adjacent | 8 (all slots) |
| Pie, scatter, bubble — anything where any two marks sit together | all | 4, and only slots 1,4,5,6 |
These were computed with the dataviz skill's validator against this app's own
surfaces (#ffffff light card, #262422 dark card), not chosen by eye. Re-run
it before changing a slot:
node <skill>/scripts/validate_palette.js "#2a78d6,#eb6834,…" --mode light --surface "#ffffff"
Slots 3, 4 and 5 sit under 3:1 on white. Anything using them must carry a visible label — which is why category chips are neutral with a coloured dot, and relationship tags are ink text on a 15% tint, rather than coloured text on a coloured fill.
Dark mode is a selected set of steps for the dark surface, not an automatic flip of the light values.
--chart-1 … --chart-5 are aliases onto --cat-1 … --cat-5, kept as their
own names because fund-detail-view and metric-chart already reference them.
The stacked bars use the adjacent-pairs ceiling (5 of 8 is fine); the pie uses
the all-pairs subset and folds the tail into "Other".
Inter for everything — body (--font-sans) and display (--font-display,
font-display in Tailwind). The app ships with no serif.
--font-display is still a separate axis, and that is the point. Every heading
that should change when a display face is chosen already carries font-display:
the marketing hero and section headings, report covers, letter mastheads,
statement headers, and the PDF templates. Picking a serif in
DISPLAY_FONT_OPTIONS (lib/theme.ts) repoints all of them at once — the
design work is done, the default is simply "no serif". Source Serif 4,
Newsreader and Libre Caslon Display ship as options and load only when selected.
That list is kept out of FONT_OPTIONS because FONT_OPTIONS drives
--font-sans — the body font for the whole app. A serif there would land on
every dense financial table, which is not what a display face is for.
Because the default is Inter, the PDF pipeline emits no PDFDisplay
@font-face at all: PDF_DISPLAY lists 'Inter' second, so it falls through to
the copy already embedded as the body face. Choosing a serif embeds that one
face and nothing else (lib/pdf-fonts.ts).
Inter's figures are proportional by default with tnum available, which is the
profile this product wants: proportional in prose, tabular on demand in tables.
Worth knowing if the body font is ever revisited — Hanken Grotesk and Plus
Jakarta Sans are both loaded here, and Hanken Grotesk's figures are
permanently tabular (all ten digits share one advance width), so it can't do
proportional numerals in running text.
FONT_OPTIONS (the per-fund "UI font" in Appearance) also offers Geist,
DM Sans, Inter Tight and Instrument Sans. Geist, Inter Tight and Instrument
Sans carry tnum, so tabular-nums keeps working in the tables. DM Sans
does not — its only figures are proportional, so number columns will not
align in it; the picker says so next to the option. They load with
preload: false and are fetched only when a theme names them. The UI font never reaches the
server-rendered PDFs, which stay on embedded Inter whatever the fund chose.
The public marketing page is not a tenant surface, so it has its own font
(site_content.font, chosen in Settings → Marketing), Inter by default, matching hemrock.com.
app/(public)/layout.tsx sets --font-sans and --font-display on a
wrapper — the page has no separate report face, so headings follow the body —
and the fund theme never applies out there.
Named steps, defined in tailwind.config.ts. Each carries its own line-height
and tracking, so an eyebrow can't be reassembled wrongly by hand:
| Class | Size | Use |
|---|---|---|
text-display |
clamp 42 → 68px | Marketing hero. Weight 400 |
text-title |
clamp 30 → 46px | Marketing section headings. Weight 400 |
text-heading |
clamp 20 → 24px | Sub-headings, prices |
text-lede |
18px / 1.65 | Hero subtitle, section intros |
text-label |
13px | Dense UI labels |
text-caption |
12px | Fine print |
text-eyebrow |
11px / 700 / uppercase / .09em | Section eyebrows |
Tailwind's text-xs … text-2xl still work and remain correct for dense app UI.
The named steps above are for marketing — their clamps are wrong for dense app chrome. In-app, use Tailwind's plain steps, in five rungs:
| Rung | Class | Use |
|---|---|---|
| Page title | text-2xl font-semibold tracking-tight |
One <h1> per page |
| Major section | text-lg |
An <h2> leading a whole page region |
| Section heading | text-base |
Ordinary <h2>/<h3> |
| Body | text-sm |
Paragraphs, table cells, controls |
| Caption | text-xs |
Form labels, hints, timestamps |
The middle rung is the one that used to be missing: 79 headings sat at text-sm
or text-xs, the same size as the paragraph beneath them, so hierarchy rested
entirely on font-weight. lib/design-tokens.test.ts fails on an <h2>/<h3> at
body size or smaller.
Errors and warnings are text-sm or larger, never text-xs — the one thing
a user must read should not be the smallest type on screen. Also guarded.
Note that text-xs is not mainly a table style here: only ~36 of its uses are
in <td>. It is mostly labels and hints, which is what it's for.
Display weight follows the size and the face, and both changed when
--font-display became Inter.
| Where | Size | Weight |
|---|---|---|
| Marketing headings — hero, sections, stat totals | text-display / text-title (46–68px) |
font-semibold |
| Document headings — LP letters, snapshots, report cards | text-heading (20–24px) |
font-normal |
The old rule was a flat "display type is light, never semibold", written when
the display face was a serif. Serifs carry visual weight a UI sans doesn't, so
Inter at 400 reads thin at 68px rather than light, and the marketing page
needs 600 — which also matches the h1 on hemrock.com, so the two properties
read as one product. At text-heading the type is near body size, Inter 400 is
already right, and the LP-facing documents keep it.
If you point --font-display at one of the serif options, the marketing
headings want font-normal again.
Use tabular-nums, not font-mono. Column alignment needs tabular figures;
Inter ships them. font-mono costs letterform quality and makes money look like
code. This applies to PDF templates too — they use
font-variant-numeric: tabular-nums rather than a monospaced family.
font-mono is for content a machine cares about literally: code blocks, IDs,
API keys, GA/Fathom IDs, model names, account codes, version strings, OTP
inputs. lib/design-tokens.test.ts fails on text-right combined with
font-mono, which is the signature of a financial table cell.
--radius: 0.25rem for controls, --radius-card: 0.5rem for cards (matching
hemrock.com). Use rounded-card for card surfaces, rounded-lg/md/sm for
controls. A per-fund radius overrides --radius and derives --radius-card
one step softer.
Restrained by design. shadow-sm on cards; the tinted paper does the separating.
Shadows don't read in dark mode, so pair them: shadow-sm dark:shadow-none dark:border.
--ease-out (ease-out-soft) for most transitions, --ease-expo (ease-expo)
for larger movements. Keep durations at or under 200ms.
| Marketing container | 1100px |
| Prose measure | 640px |
| Marketing section rhythm | 80–112px (mb-20 md:mb-28) |
Two app page widths, and only two.
| Class | Width | Use |
|---|---|---|
max-w-page |
1280px | The app-wide cap. Applied once, on the wrapper in app/(app)/layout.tsx |
max-w-readable |
46rem | Forms and prose, where a full-width line is harder to read |
Don't add a third. A page that wants to be narrower than page uses readable;
pages previously picked max-w-6xl, max-w-7xl and max-w-screen-xl more or
less at random, which is why nothing lined up between routes.
max-w-* on a card, modal, popover or truncated label is a different thing and
is fine — this rule is about page containers.
components/ui/metric.tsx is the KPI tile: an eyebrow label over a large
tabular-figure value. Use it rather than hand-rolling — six near-identical copies
had already drifted apart on size, label position and whether the value carried
tabular figures.
A figure is what the reader came for, so it gets the promotion the rest of the dense UI doesn't: it's the largest type on most pages. That's the shape of the type hierarchy generally — promote the few things that carry meaning, and leave 12/14px as the dense-table default. Making everything bigger is not the goal.
The published guide is accurate on colour — its Ink / Paper / Dark / Muted / Border values match this repo's tokens exactly. It needs these additions to describe what actually ships:
- Typeface. Already correct (Inter), now also true here — this repo previously defaulted to the system stack.
- Radius. Add the
0.25remcontrol /0.5remcard pair. The guide is currently silent. - Tinted neutrals. Paper is
#fdfdfcand ink#1c1a17, not#FFFFFFand#0A0A0A. The guide's values are the untinted originals. - "Monochrome by design" needs amending. The mark stays monochrome — those logo rules are good and unchanged. The product now has one accent (the highlighter yellow) plus a defined status set.
- Everything under Typography, Status, Radius, Elevation and Motion above is absent from the guide entirely. That is why nobody used it.