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:
- It must work with no network at all. This is why the prototype used system fonts, and it is not negotiable.
- The sans has to hold at 11–12px. That is where this interface actually
lives:
.subis 11px,.rmetaand.brand-kindare 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 aftertokens.cssand overrides--serifand--sansthere.tokens.cssis 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 pand.note.serifall compute toIBM Plex Serifat weight 400; no serif element computes above 400, so no synthesised bold.h1.titleand.wordcompute toInter Variableat 600,.seqnameat 560.- Both italics and the 600 and 620 weights resolve on demand.
.idpathstill computes toui-monospace, untouched.distfonts: 4 files, 144KB. No non-latin subset and no legacy.woff.