Skip to content

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 baseline 0.4.18)
  • Protocol: v11 (unchanged; the pi-desktop/app/systemFonts channel is untouched)
  • Storage schema: v19 (unchanged; AppSettings.fontFamily keeps 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 ​

  1. Nothing is bundled. apps/desktop/src/assets/fonts/ (the four woff2 files and their licenses/ texts), apps/desktop/src/styles/fonts.css, and the @import "./fonts.css"; line in apps/desktop/src/styles/globals.css are deleted. BUNDLED_FONTS, the BundledFont type, the license field on FontOption, the settings-font-item-license badge, and the settings.fontBundled key in all eight shipped locale catalogs are deleted with them.

  2. The picker keeps two kinds of option. buildFontOptions returns System default — persisted as the empty string "", which every consumer treats as the built-in --font-sans token stack — followed by installed system families. Enumeration is exactly ADR 0083 §3 and is unchanged: Electron main uses platform tooling only (osascript JXA CoreText on macOS with system_profiler as a fallback, PowerShell on Windows, fc-list on Linux), deduplicated, filtered, sorted, cached 60 s, and reached through the additive allowlisted channel pi-desktop/app/systemFonts. A stored stack that matches no option is still re-added first under the existing Saved (custom) group.

  3. 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.

  4. Stored stacks are preserved, not migrated. A AppSettings.fontFamily written 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, and settings.set semantics, the protocol version, and the schema version are untouched.

Consequences ​

  • out/renderer no longer emits an application font face; the only woff2 files left are KaTeX's math glyphs. Measured from a clean pnpm install --frozen-lockfile, the woff2 group fell from 23 files / 15.71 MiB to 19 files / 0.24 MiB and the out/renderer total 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 woff2 beyond 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.

Local-first · Model-agnostic · Plugin-powered. AIUO.NET