# Typography

> Two families. Three sizes and three weights per component. That constraint is not minimalism for its own sake — it is what forces hierarchy to come from contrast rather than from variety.

Source: https://lopifi.com/docs/foundations/05-typography
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Typography

Two families. Three sizes and three weights per component. That constraint is not minimalism for its own sake — it is what forces hierarchy to come from *contrast* rather than from *variety*.

---

## The families

### Inter — every word
`Inter Variable`, the interface face: labels, prose, headings, buttons, display sizes, and the landing accent (its true italic). Display is Inter at display sizes — tighter tracking, the optical-size axis doing the rest — not a second text family.

Why Inter: a variable optical-size axis, `case`-sensitive forms (which we depend on — see below), excellent hinting at 13–14px, and character-level disambiguation (`cv05`, `cv08`) that matters when someone is reading an account number.

```css
--font-sans: var(--font-inter, "Inter Variable"), "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font-sans);
```

### Geist Mono — every figure
The figure face. Every amount, rate, ratio and reference number resolves to `--font-numeric` and to nothing else — and so do code, keys and identifiers, so the system still renders in exactly two families.

```css
--font-numeric: var(--font-geist-mono, "Geist Mono"), ui-monospace, "SF Mono", "Menlo", "Consolas", monospace;
```

Why a mono for figures: Inter's `tnum` aligns a column, but its digits still carry the proportional rhythm of the text around them. In a 40-column grid the reader scans *shapes* down a column, not left to right. Geist Mono sits on a fixed advance with an unambiguous 1/7 and a zero that never reads as an O, so a column forms a hard vertical edge and a mistyped digit shows as a shape error before it is read as a number. Its digits sit optically large against Inter, so `.numeric` scales them by `--numeric-size-adjust` (0.94) to hold the line.

### The display-figure law

> **A figure may leave the figure face only when it never aligns with another figure.**

`Figure family="display"` sets a solitary hero numeral in Inter at display size. A parent that stacks figures (`KpiRow`, a table) forces the figure face rather than trusting the caller.

| Allowed in display | Forbidden in display |
|---|---|
| The single hero balance on an account page | Any column of figures |
| A large standalone KPI figure in a card, when it's the only figure in that card | Table cells, statement rows, ledger amounts |
| Marketing / onboarding numbers | Anything comparative — two KPIs side by side |

**Test:** if a second figure could ever appear beside or beneath it, it's the figure face.

### No third family
No serif, no second mono, no icon font. A consumer who wants figures in Inter sets `--font-numeric: var(--font-sans)` and every component follows, because none of them name a family directly.

---

## The scale

Sizes are chosen for legibility; **line-heights are chosen to land on the 8px grid.** That's what keeps type aligned to layout.

### Text

| Token | Size / Line | Weight | Tracking | Use |
|---|---|---|---|---|
| `display-lg` | 48 / 56 | 400 | −0.02em | Marketing, onboarding only |
| `display-md` | 32 / 40 | 400 | −0.02em | Page hero, section opener |
| `title-lg` | 24 / 32 | 500 | −0.01em | Page title |
| `title-md` | 20 / 28 | 500 | −0.01em | Card title, dialog title |
| `title-sm` | 16 / 24 | 500 | 0 | Section heading, list group header |
| `body-lg` | 16 / 24 | 400 | 0 | Long-form prose, dialog body |
| `body` | 14 / 20 | 400 | 0 | **Default.** UI text, table cells, form values |
| `label` | 13 / 16 | 500 | 0 | Field labels, table headers, buttons, tabs |
| `caption` | 12 / 16 | 400 | 0 | Metadata, timestamps, helper text |
| `overline` | 11 / 16 | 600 | +0.06em | Uppercase eyebrows. **Sparingly** — see below |

### Figures

A separate scale, because numbers have different optical requirements. All of these are Inter, all `tabular-nums`, all `-0.01em` tracking (tabular figures are slightly wide; a touch of negative tracking restores rhythm).

| Token | Size / Line | Weight | Use |
|---|---|---|---|
| `figure-hero` | 40 / 48 | 500 | The single most important number on a page |
| `figure-lg` | 28 / 32 | 500 | KPI card primary value |
| `figure-md` | 20 / 28 | 500 | Card values, statement totals |
| `figure-sm` | 14 / 20 | 400 | **Table cells, ledger rows, statement lines** — the workhorse |
| `figure-xs` | 12 / 16 | 400 | Dense/compact mode, chart labels, sub-values |

Figures use weight 500, text uses 400. A figure at the same weight as its label loses to the label, because the label usually has more glyphs.

### Weights — only three

```
400  Regular   body, figures in tables, prose
500  Medium    labels, buttons, headings, prominent figures
600  Semibold  emphasis within a heading, the one overline, active nav
```

**No 300. No 700+.** Inter at 300 fails contrast at 13–14px on a light background. Inter at 700 in a dense table is shouting. Three weights is genuinely enough — if you want more emphasis than 600 gives, the answer is size or color, not weight.

### Tracking
Do not touch it. The scale above already bakes in the only two adjustments the system permits:
- Negative tracking on large sizes (≥ 24px), because Inter's default spacing is set for text sizes.
- Positive tracking on `overline` only, because uppercase always needs it.

Everything else is `0`. `tracking-tight` on body text is one of the clearest markers of AI-generated UI.

---

## The 3×3 rule

> **Max 3 font sizes and max 3 font weights per component.**

Per component, not per page. Worked example:

```
BalanceCard
  eyebrow  "Operating account"     label    13/16  w500  text-secondary
  figure   "$248,190.24"           hero     40/48  w500  text-primary
  delta    "+$12,400 (5.3%) MTD"   caption  12/16  w400  positive
                                   ─────────────────────
                                   3 sizes, 2 weights ✓
```

Add a fourth ("last synced 2m ago") and you must reuse `caption/400`, or remove something. Reusing is nearly always right — it becomes a metadata row, which is a *fourth slot at the same rank*, which is legitimate. A fourth *rank* is not.

**Why this works:** hierarchy is relative. Three levels are unambiguous — big, medium, small. Five levels force the eye to compare, and comparison is slower than recognition.

---

## OpenType features for money

This is where a finance system earns its name. These are `font-feature-settings`, they cost nothing, and essentially no product ships them.

```css
.numeric {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1, "case" 1;
  letter-spacing: -0.01em;
}
```

### `tnum` — tabular figures
Every digit gets the same advance width, so decimal points align down a column and a value doesn't reflow as it changes. **Mandatory on every number in the system.** Not just tables — a live-updating balance that shifts horizontally as digits change is the most distracting thing a finance UI can do.

Note `0` and `1` are the digits that differ most between proportional and tabular. If a column looks slightly wrong, `tnum` isn't on.

### `case` — case-sensitive forms
The detail nobody ships, and the reason this system is worth building.

`case` raises parentheses, brackets, hyphens and dashes to align with **lining figures** rather than lowercase text. So accounting negatives:

```
without case:   (1,234.00)     ← parens sit low, centered on x-height
with case:      (1,234.00)     ← parens centered on the figures
```

At 14px it's a sub-pixel difference on one glyph. Down a column of forty negative numbers it's the difference between a statement and a spreadsheet dump. Apply `case` to **every element that can contain a parenthesized negative or a minus sign**.

It also fixes the minus sign: `−` (U+2212) with `case` sits at the vertical center of the figures. A hyphen-minus `-` does not, ever. Use the real character.

### `zero` — slashed zero
**Off** for monetary values. On for account numbers, routing numbers, reference IDs, and anything a human will transcribe.

```css
.reference { font-feature-settings: "zero" 1, "ss02" 1; }
```
`ss02` is Inter's disambiguation set — it separates `1` / `l` / `I` and `0` / `O`. Exactly right for an IBAN, wrong for a balance.

### `frac` — fractions
Off. If you're rendering fractions in a finance UI, use decimals.

### Feature summary by context

| Context | Features |
|---|---|
| Monetary value | `"tnum" 1, "case" 1` |
| Percentage, ratio, count | `"tnum" 1, "case" 1` |
| Account / routing / reference number | `"tnum" 1, "zero" 1, "ss02" 1` |
| Body prose | none (default proportional figures read better in a sentence) |
| Chart axis labels | `"tnum" 1` |
| Dates | `"tnum" 1` |

Note the last one on prose: **inside a sentence, use proportional figures.** "We processed 1,284 payments" reads better proportional. Tabular is for alignment; a sentence has nothing to align to.

---

## The currency symbol

Set the symbol lighter than the value. Stripe and Mercury converged on this independently, and the reason is structural: the symbol is a **unit label**, not data.

```tsx
<span className="text-tertiary font-normal">$</span>
<span className="text-primary">248,190</span>
<span className="text-tertiary">.24</span>
```

Three decisions in that snippet:

1. **Symbol at `--text-tertiary`.** Present, legible, subordinate.
2. **Cents at `--text-tertiary` for large display figures only** (`figure-hero`, `figure-lg`). At 40px, `.24` at full weight competes with the dollars for no reason. In tables — `figure-sm` and below — cents stay at full color, because at 14px the reduced contrast hurts legibility and the alignment already does the work.
3. **4px gap between symbol and figure**, never a hard-coded space character (which is a proportional advance and breaks tabular alignment).

**Never superscript the cents.** It's a retail-pricing device that reads as a discount, which is the opposite of the register a finance product wants.

Symbol placement follows the locale via `Intl.NumberFormat`, not a hard-coded prefix — see [numerics](06-numerics.md).

---

## Text behavior

Per baseline-ui, and all of these are one-line fixes that almost nothing ships:

- **`text-wrap: balance` on every heading.** Prevents a one-word orphan line on a card title. Cheap, always right for ≤ 4 lines.
- **`text-wrap: pretty` on every paragraph.** Prevents orphans and improves rag.
- **`tabular-nums` on all data.** Restated because it's the most-missed one.
- **`truncate` or `line-clamp-N` in dense UI**, with the full value in a `title` attribute *and* an accessible tooltip. A truncated merchant name with no way to see it is a dead end.
- **Never truncate a number.** Ever. See [numerics](06-numerics.md#overflow) for what to do instead.
- **`hyphens: none` on labels and values.** Hyphenating "Accounts Receivable" is worse than wrapping it.
- **`overflow-wrap: anywhere` on user-generated strings** — merchant names, memo lines, and email addresses will otherwise blow out a table cell.

---

## The overline problem

`overline` (11px uppercase, +0.06em) is in the scale because eyebrow labels are genuinely useful above a hero figure. It is also the single most abused style in dashboard design.

**Rules:** at most one per card, at most three per screen, never on a table header (use `label` at `--text-secondary` instead — uppercase table headers in a dense table are noise), never longer than three words.

---

## Loading fonts

```css
@font-face {
  font-family: "Inter Variable";
  src: url("/fonts/InterVariable.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
  font-named-instance: "Regular";
}
```

- **`font-display: swap`** with a metric-matched fallback so the layout doesn't shift. Inter's metrics are close enough to `system-ui` that CLS is negligible with `size-adjust` tuning; measure it and set `size-adjust`/`ascent-override` on an `@font-face` fallback declaration.
- **Load exactly two faces.** Inter (normal + italic, `opsz` axis) and Geist Mono at 400/500 — the two weights figures use. Anything else on the wire is a third family by the back door.
- **Subset aggressively.** Latin + the currency block (U+20A0–20BF) + arrows + the math minus (U+2212). Full Inter Variable is ~350KB; a proper subset is under 40KB.
- **Self-host.** No third-party font CDN in a financial product — it's a data-leak and an availability dependency.
