Typography

Montserrat, how to load it, the rem ladder on a 15/17/19px root, headings, rhythm, numbers and mono.

One face, one ladder, one rhythm. Montserrat is the only font in any Saga product (DS-009). Its wide, geometric letterforms are why Saga screens read as light and open at modest weights; do not compensate with bold.

Loading Montserrat#

Two recipes, one per environment. Pick the one that matches where the file runs.

Bundled apps (SvelteKit, Vite, anything with node_modules):

pnpm add @fontsource-variable/montserrat
// in the root layout or main.ts, before any component CSS
import '@fontsource-variable/montserrat';

The variable package registers the family name 'Montserrat Variable', which is the first entry in --font-sans. Only the weights you use are kept by the browser; no subset configuration is needed.

Single-file prototypes, Claude artifacts, anything served without a build:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">

Use exactly this tag. The full variable range costs nothing extra on Google Fonts, and display=swap means the page paints in the system fallback and swaps, rather than staying blank. A page that loads no font at all renders in Arial and looks like somebody else's product; it is the most common prototype defect in the inventory.

Mono is --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace. No app loads JetBrains Mono today and none has to; the system mono is fine. Load it only when a page is mostly code.

Scale#

The root size steps with the viewport (DS-010) and with the user's text-size preference (DS-020). base.css does the arithmetic:

html { font-size: calc(var(--base-font-size) * var(--text-scale)); }
Viewport --base-font-size With data-text-size="large" (1.3) "larger" (1.7)
under 1400px 15px 19.5px 25.5px
1400px and up 17px 22.1px 28.9px
1800px and up 19px 24.7px 32.3px

Everything else is in rem, so it follows. Set the root on html, never on body: dash set it on body and its rem ladder quietly resolved against a 16px root, which is the bug DS-010 fixes.

The ladder, with the pixel size at each root step:

Token rem at 15px at 17px at 19px Use
--text-xs 0.75rem 11.25 12.75 14.25 Badges, chip labels, h4 small caps, table meta
--text-sm 0.875rem 13.1 14.9 16.6 Helper text, dense tables, captions, secondary buttons
--text-base 1rem 15 17 19 Body, inputs, buttons, h3
--text-lg 1.125rem 16.9 19.1 21.4 Card titles, stat labels
--text-xl 1.25rem 18.75 21.25 23.75 h2, section headings
--text-2xl 1.5rem 22.5 25.5 28.5 h1, page title in the top bar
--text-3xl 2rem 30 34 38 Stat card numbers, hero figures

Shell chrome is the exception: --tab-font-size is a fixed 15px and the top bar is a fixed 64px, so the frame does not grow with the content (DS-007).

Line heights: --leading-tight (1.25) for headings and stat numbers, --leading-normal (1.375) for UI text and tables, --leading-relaxed (1.625) for paragraphs longer than two lines.

Weights#

Token Weight Use
--weight-regular 400 Body, table cells, h2
--weight-medium 500 Emphasis inside body text, nav items, tab labels
--weight-semibold 600 h1, h3, h4, buttons, card titles, stat labels, column headers
--weight-bold 700 Stat numbers and almost nothing else

Use numeric weights only. The Figma export has its 400 and 500 labels swapped, so a weight named "Medium" in a comp may be either.

Headings#

base.css styles the four heading levels (DS-022). Use them for structure, not for size: if you need a smaller h2, you need an h3.

Level Size Weight Notes
h1 --text-2xl 600 One per page. In the shell it is the page title in the top bar, colored --color-primary
h2 --text-xl 400 Section headings. Regular weight is deliberate; the size carries it
h3 --text-base 600 Card titles, form groups ("Define Tutoring Patterns", "Questions")
h4 --text-sm 600 Uppercase, letter-spacing: 0.04em, --color-text-secondary. Field labels, nav group labels ("CONFIGURE"), "DURATION"
<section class="saga-card">
  <h3>Define Tutoring Patterns</h3>
  <p class="text-secondary">Tutoring periods meet on the same days each week.</p>
</section>

Headings are --color-text, except the h1 page title (blue) and headings on navy chrome (white). Never color a heading to decorate it.

Rhythm#

One step. Headings, paragraphs, lists, tables and figures all carry margin: 0 0 var(--space-md) (16px) and the last child in any container drops it. That is the whole system: a card with a heading, two paragraphs and a table needs no margin rules of its own.

<div class="saga-card">
  <h3>Roster upload</h3>
  <p>Upload a CSV with one student per row.</p>
  <p>Existing students are matched on Student ID.</p>
  <!-- last child: no bottom margin, card padding does the rest -->
  <table class="saga-table">…</table>
</div>

A section break inside long content gets --space-xl (32px) above the heading. A heading never gets extra space below it; the 16px step is already there.

Do:

Don't:

Numbers#

Data screens are mostly numbers, and numbers that jiggle as they change are the first thing a user notices. Any column of figures, any stat card, any clock uses tabular figures (DS-028):

.saga-table td.num, .saga-card--stat .value, .clock { font-variant-numeric: tabular-nums; }

Mono#

--font-mono at 0.9em (base.css sets code, kbd, pre), for IDs, keys, file names, code and anything a user might copy exactly. Student IDs in a roster are a borderline case: dash renders them in the sans face, and that stays the default; switch to mono only when the column is meant to be scanned character by character.

Never use mono as a "technical look" for labels or headings. The wireframe style some comps use is a placeholder, not a design.

Line length#

Prose (help text, notice bodies, empty states, onboarding) wraps at 60 to 80 characters: max-width: 72ch on the paragraph or its container. Survey questions in the Reports screen are a good example at about 70 characters.

UI text (labels, cells, chips) does not wrap by width rules; it truncates with an ellipsis and exposes the full text in a tooltip, or it wraps naturally inside a sized column. Never let a label grow a control.

Text on navy chrome (nav items, header strips) is --text-base or 15px and medium weight; Montserrat at 400 on dark backgrounds reads thin.