sprig and prose docs classic docs
Reference› ADRs› ADR001 FontPairing

ADR001 FontPairing

Title: Font pairing for the beta field guide UI

  • Status: Accepted
  • Decided: D2026-08-16
  • Superseded by: ADR005 ProseSans
  • Affects: Ui

Context

The beta UI splits type two ways on purpose: a serif carries the author's describe text and a sans carries everything the tool says about it. The split is the typographic statement of the whole design — description is the content, structure is scaffolding — and it was inherited from the bake-off prototype along with the rest of the stylesheet.

What was inherited was the split, not a decision about which families fill it. The prototype was built under a one-file, no-network, no-dependencies constraint, so it could only name generic system stacks:

--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, …;
--serif: ui-serif, Georgia, "Iowan Old Style", "Palatino Linotype", …;

On the development machine, fc-match reports that Georgia, Iowan Old Style, Palatino Linotype, Segoe UI and Roboto are all absent. Both stacks were therefore collapsing to DejaVu Serif and DejaVu Sans — faces nobody chose, which happen to be wide and characterless. Any Linux reader without Microsoft's core fonts gets the same.

Two constraints were real and had to survive:

  1. It must work with no network at all. This is why the prototype used system fonts, and it is not negotiable.
  2. The sans has to hold at 11–12px. That is where this interface actually lives: .sub is 11px, .rmeta and .brand-kind are 11.5px, counts are 12px. It also asks for weights of 560, 600 and 620, which only a variable face can satisfy exactly.

One constraint turned out not to be real. "System faces only" was a consequence of the one-file rule, not a value in itself. A self-hosted face bundled into the build and served from the same origin satisfies the offline requirement fully, while removing the dependency on what the reader happens to have installed. Recognising that is what made the decision possible.

Decision

IBM Plex Serif for prose, Inter for the interface, both self-hosted as latin-only woff2. The monospace face is left as a system stack.

The pairing was chosen by reading real universes in each candidate, not by comparing specimens. Eight candidates were bundled behind a temporary row of numbered buttons in the top bar, with no selection falling back to the system stack as a control, and the choice was made over several days of actual use.

Two implementation details are load-bearing:

  • The two families are declared in src/lib/fonts/fonts.css, which is loaded after tokens.css and overrides --serif and --sans there. tokens.css is a verbatim extract of the prototype's stylesheet and is deliberately kept diffable against the original; putting the one intentional deviation in a separate, obviously-named file keeps that property intact.
  • The system stack is retained as the tail of each stack, so a face that fails to load degrades to what the prototype shipped rather than to nothing.

Consequences

Cost. Four font files, about 140KB, added to dist. Two requests on first load, from localhost. Two npm dependencies (@fontsource/ibm-plex-serif, @fontsource-variable/inter).

The serif needs only two faces. Nothing in this interface sets the serif bold. The only three rules that use var(--serif) are .prose, .note.serif and .rbody; none sets a weight, and .undescribed adds the italic. So an upright and an italic is the complete requirement, verified in the browser rather than assumed — no element renders serif above weight 400, which also means the absence of a bold cannot produce a synthesised one.

Inter is variable, so the odd weights land exactly. 560 and 620 are real positions on its axis rather than roundings. Verified in the browser.

"System faces only" is no longer true and has been rewritten in the package README and in the TypeFaces concept of the package's own prose universe, both of which previously claimed there were no web fonts anywhere.

Latin only. Importing the Fontsource stylesheets pulls a face per subset — cyrillic, greek, vietnamese and the rest. Across ten candidate families that was 12.5MB emitted into dist to deliver the 870KB a browser would actually request. The faces are therefore hand-declared against the specific latin files. The cost is that the filenames are pinned, so a Fontsource major version could break them; the build fails loudly if it does, because Vite cannot resolve the URL.

Non-latin text will fall through to the system stack. Acceptable today, since prose universes in the wild are latin. If that changes, the fix is to add the needed subsets with their unicode-range, not to import the whole stylesheet.

Reversibility is cheap. The pairing is two lines in one file. Swapping either face is a package install and an edit.

Alternatives considered

System stacks only. Rejected because it does not answer the question: the comparison is meaningless on any machine where the named families are absent, and it leaves the interface's appearance dependent on the reader's font directory. The offline argument for it turned out to be satisfiable another way.

Superfamilies — Source Serif 4 with Source Sans 3, and IBM Plex Serif with Plex Sans. Both are single families drawn on one skeleton and therefore cannot clash. Rejected because their virtue is blending, and blending is precisely what this seam does not want: the reader should be able to tell at a glance whose words they are reading. Source Sans 3 also has the smallest x-height of the candidate sans, which makes it the weakest at the sizes this interface uses.

Literata with Inter. The runner-up, and the option originally recommended. Literata is purpose-built for long-form screen reading and pairs a strongly bookish serif against a neutral interface sans, which is the sharpest available statement of the author's-voice / tool's-voice distinction. Rejected on two grounds after extended reading. Plex Serif measures a taller x-height than Literata, so it is the more legible of the two by the criterion originally used to argue for Literata. And Literata has more personality — bulbous terminals, a distinctive y and g — which over many paragraphs is a bet on the reader's taste, where Plex Serif's evenness simply recedes. For prose read at length, receding is the better property.

Charis SIL with Public Sans. A sound workhorse; Charis descends from Charter, drawn to survive low-resolution output. Not chosen; nothing wrong with it, it was simply less pleasant to read for long stretches than the two finalists.

Newsreader with Atkinson Hyperlegible. Included specifically to test whether legibility-by-design beats legibility-by-taste, since Atkinson was drawn by the Braille Institute to pull apart characters that collapse into each other — I against l against 1, O against 0 — which is directly relevant to an interface full of dotted identifiers. Rejected because the pairing is internally inconsistent: Atkinson is the most legible sans in the set, and Newsreader has the smallest x-height of any serif measured, so the prose reads a size down. Atkinson is also static, with only 400 and 700, so 560, 600 and 620 all round up to bold.

Literata with Libre Franklin. Proposed as a warmer execution of the Literata-and-Inter idea. Not chosen, and it also surfaced the one hard defect in the exercise: Libre Franklin ships no tabular figures at all. Investigating how much that mattered found that it did not, here — of the nine places the stylesheet asks for tabular-nums, the only one where digits form a vertical column is .rnum, which uses var(--mono) and is unaffected; the rest are lone values or right-aligned. Recorded because the general lesson is worth keeping: check tabular-nums before adopting a sans for an interface full of counts.

Faustina with Inter. Faustina is drawn for small sizes on screen and has the highest lowercase-to-caps ratio measured, at 76%, plus by far the most economical setting. Not chosen; a reasonable option if the interface ever needs to fit substantially more prose per line.

IBM Plex Mono for the monospace. Deferred, not rejected. It would tie the ids back to the serif for a coherent trio, at about 30KB. Held back because the monospace was deliberately fixed during the comparison so the only variable was the serif against the sans, and there is no reason to bundle that decision with this one.

Evidence

Measured in Chrome via canvas TextMetrics, at font-size: 100px, so x-height and cap-height are directly readable as percentages of the em. "Label width" is the advance width of 19 connections to 18 neighbours, a representative piece of interface text, in the same units — lower is more economical.

Sans

Family x-height cap x/cap Label width Tabular figures
DejaVu Sans (previous default) 55 73 75% 1646 even by default
Inter (chosen) 54 73 74% 1522 yes
Libre Franklin 53 74 72% 1507 none
Public Sans 52 73 71% 1485 yes
IBM Plex Sans 52 71 73% 1474 even by default
Atkinson Hyperlegible 51 68 75% 1428 yes
Source Sans 3 49 66 74% 1350 even by default

Serif

Family x-height cap x/cap Label width Tabular figures
IBM Plex Serif (chosen) 52 70 74% 1538 even by default
DejaVu Serif (previous default) 52 73 71% 1662 even by default
Literata 51 70 73% 1491 yes
Source Serif 4 50 68 74% 1524 even by default
Faustina 50 66 76% 1322 yes
Charis SIL 49 68 72% 1448 even by default
Newsreader 44 68 65% 1390 even by default

The chosen pair is the tallest x-height in each column, which is the metric that governs whether small text holds its shape. It is worth noting that the previous default was not illegible — DejaVu Sans has the largest x-height in the table. Its problems were width and characterlessness: it needs roughly 8% more horizontal room than Inter for the same words.

Two expectations the measurements contradicted

Recorded because they are the reason the numbers were taken at all, and because both were assertions made confidently before being checked.

Newsreader was described as having "a generous x-height" when it was proposed. It has the smallest of any serif measured, at 44 against a field of 49–52, and an x-to-cap ratio of 65% where everything else sits at 71–76%.

Libre Franklin was proposed on the strength of being an Inter-class sans with more warmth, which the measurements support — 53 x-height, the tallest caps in the table, marginally more economical than Inter. It also has no tabular figures whatsoever, which no amount of reputation would have revealed.

Verification of the shipped result

  • Exactly four faces declared, zero load failures.
  • .prose, .prose p and .note.serif all compute to IBM Plex Serif at weight 400; no serif element computes above 400, so no synthesised bold.
  • h1.title and .word compute to Inter Variable at 600, .seqname at 560.
  • Both italics and the 600 and 620 weights resolve on demand.
  • .idpath still computes to ui-monospace, untouched.
  • dist fonts: 4 files, 144KB. No non-latin subset and no legacy .woff.