ADR 0298: The app ships no fonts
- Status: Accepted
- Date: 2026-09-20
- Deciders: PI-Desktop UX and desktop maintainers
- Baseline:
0.15.1-beta.7(frozen baseline0.4.18) - Protocol: v11 (unchanged; the
pi-desktop/app/systemFontschannel is untouched) - Storage schema: v19 (unchanged;
AppSettings.fontFamilykeeps its shape and semantics) - Amends: ADR 0083 — its §2 bundled families, its
@font-face/offline-rendering clauses, and its installer-size consequence — and D232 - Related: D598, E2E-126, E2E-092, 04-ux/06-settings-ia.md · 04-ux/07-ui-design-system.md · 06-delivery/06-release-runbook.md
Context
ADR 0083 shipped the global UI font picker together with four bundled woff2 families — Geist, Inter, Noto Sans SC, and LXGW WenKai — all under the SIL Open Font License 1.1, with their license texts under apps/desktop/src/assets/fonts/licenses/ and a @font-face layer in apps/desktop/src/styles/fonts.css imported before the token layer. Every custom stack was prefixed with Noto Sans SC so Chinese text stayed readable offline, and the picker gained a Bundled group whose rows carried the license badge.
Those files are the bulk of the renderer's remaining weight: ADR 0125 measured woff2 at 15.71 MiB in out/renderer, 7.6 MiB of it lxgw-wenkai.woff2 and 7.4 MiB noto-sans-sc.woff2 (06-delivery/06-release-runbook.md). Every platform the desktop targets already resolves the same coverage from its own fonts — PingFang SC on macOS, Microsoft YaHei on Windows, and fontconfig CJK families on Linux — so the app paid for a duplicate copy in every installer and carried the OFL notice obligations that come with redistributing font files, while offering only four faces next to the hundreds the OS already installs. The product wants the picker, not the payload.
Decision
Nothing is bundled.
apps/desktop/src/assets/fonts/(the fourwoff2files and theirlicenses/texts),apps/desktop/src/styles/fonts.css, and the@import "./fonts.css";line inapps/desktop/src/styles/globals.cssare deleted.BUNDLED_FONTS, theBundledFonttype, thelicensefield onFontOption, thesettings-font-item-licensebadge, and thesettings.fontBundledkey in all eight shipped locale catalogs are deleted with them.The picker keeps two kinds of option.
buildFontOptionsreturns System default — persisted as the empty string"", which every consumer treats as the built-in--font-sanstoken stack — followed by installed system families. Enumeration is exactly ADR 0083 §3 and is unchanged: Electron main uses platform tooling only (osascriptJXA CoreText on macOS withsystem_profileras a fallback, PowerShell on Windows,fc-liston Linux), deduplicated, filtered, sorted, cached 60 s, and reached through the additive allowlisted channelpi-desktop/app/systemFonts. A stored stack that matches no option is still re-added first under the existing Saved (custom) group.The CJK fallback tier is system-only. Every stack generated by the picker appends
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif. No bundled family precedes it, so Chinese text resolves to fonts the operating system provides and no selection can depend on a file the app no longer ships.Stored stacks are preserved, not migrated. A
AppSettings.fontFamilywritten while a bundled option existed (for example"Geist", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif) stays byte for byte. It matches no known option any more, so the picker shows the stored value first under Saved, and at render time the first family is simply unknown to the platform and the cascade falls through to the system fallback tier. No storage migration runs, no stored value changes shape, andsettings.setsemantics, the protocol version, and the schema version are untouched.
Consequences
out/rendererno longer emits an application font face; the onlywoff2files left are KaTeX's math glyphs. Measured from a cleanpnpm install --frozen-lockfile, thewoff2group fell from 23 files / 15.71 MiB to 19 files / 0.24 MiB and theout/renderertotal from 25.36 MiB to 9.89 MiB — a 15.47 MiB drop that is the four deleted faces (06-delivery/06-release-runbook.md).- A face the app used to supply now comes from the operating system. A user who wants Geist, Inter, or LXGW WenKai installs it on the OS, after which the picker lists it like any other installed family; LXGW WenKai ships on no platform by default, so that face is no longer available out of the box.
- Chinese text still renders on every supported platform through the fallback tier, and no part of it reads a file the app ships.
- The app no longer redistributes OFL-licensed font files, so the four shipped license texts and their notice obligations leave the bundle with the faces.
- The picker's option list becomes "System default plus whatever this machine has", which is the same list every other System-style picker in the app shows; the license badge and the Bundled group heading go away.
- E2E-126 describes the two-group picker and the saved-stack fallback, and E2E-092's packaging checks expect no application
woff2beyond KaTeX.
Alternatives considered
Keep the bundled families and subset them
Rejected: subsetting would drop glyphs from user-supplied content, which is why ADR 0125 kept the CJK faces unsubset in the first place, and it leaves the @font-face layer, the license tree, and the build wiring in place for a saving the platform fonts already provide.
Keep the bundled families behind a build flag or setting
Rejected: the files would still be built and shipped, so the installer cost and the notice obligations stay, and the app would gain a second font configuration surface for a four-item list.
Keep Noto Sans SC at the head of the CJK fallback tier
Rejected: the tier must not name a family the app pretends to provide. It would only take effect on machines where the user installed Noto Sans SC separately, and it would silently change which face a Chinese glyph resolves to on exactly those machines.
Migrate stored bundled stacks to a system equivalent
Rejected: there is no honest mapping from a removed face to a system face, and rewriting the value would change the user's rendering without their action. Keeping the stored stack lets the picker show what was chosen under Saved and lets the cascade resolve it, and the user can pick a replacement at any time.