|
| 1 | +# 4. Adopt Untitled Serif via the Fonts API stylesheet endpoint |
| 2 | + |
| 3 | +Date: 2026-07-28 |
| 4 | + |
| 5 | +## Status |
| 6 | + |
| 7 | +Accepted. Supersedes [ADR-0001](0001-revert-brand-fonts-pending-licensing.md) **in |
| 8 | +part** — for Untitled Serif only. |
| 9 | + |
| 10 | +## Context |
| 11 | + |
| 12 | +ADR-0001 reverted the SDK's brand fonts and set one condition for bringing one back: |
| 13 | +load it through the `/v1/fonts/{font_id}/stylesheet` endpoint rather than a hardcoded |
| 14 | +`@font-face`, so the SDK never ships or hosts the file itself. |
| 15 | + |
| 16 | +Untitled Serif meets that condition. `GET /v1/fonts/1/stylesheet` accepts the app key as |
| 17 | +either an `X-YVP-App-Key` header or an `?app_key=` query parameter, and returns `401` |
| 18 | +without one. |
| 19 | + |
| 20 | +The sans stack is unchanged and stays `'Inter', sans-serif` — this is a serif-only |
| 21 | +change. |
| 22 | + |
| 23 | +YPE-1350 (BibleReader renders Untitled Serif) and YPE-1910 (`--yv-font-serif` becomes |
| 24 | +`Untitled Serif → Source Serif 4 → serif`, covering `BibleText` and the Bible card, not |
| 25 | +just the reader) both depend on this. |
| 26 | + |
| 27 | +## Decision |
| 28 | + |
| 29 | +Adopt Untitled Serif as the SDK's serif face, loaded by the SDK itself from the |
| 30 | +stylesheet endpoint. |
| 31 | + |
| 32 | +**1. Font stack (YPE-1910).** Both serif declarations become |
| 33 | +`'Untitled Serif', 'Source Serif 4', serif`: |
| 34 | + |
| 35 | +- `packages/core/src/styles/theme.css` — `--yv-font-serif` |
| 36 | +- `packages/ui/src/styles/global.css` — `--font-serif`, inside `@theme inline` |
| 37 | + |
| 38 | +These are literal duplicates in two packages, not aliases: core cannot import Tailwind, |
| 39 | +and `@theme inline` values are inlined into utilities rather than emitted as runtime |
| 40 | +custom properties. `packages/ui/src/styles/font-tokens.test.ts` reads both files off |
| 41 | +disk and fails if they drift apart. Source Serif 4 stays loaded from Google Fonts as the |
| 42 | +fallback, so nothing regresses when Untitled Serif is unavailable — and because the |
| 43 | +stack names Untitled Serif *first*, a host that loads its own copy wins regardless of |
| 44 | +who fetched it. That is YPE-1910's explicit requirement. |
| 45 | + |
| 46 | +The change is SDK-wide, not reader-only: the version-picker abbreviation tile, |
| 47 | +footnotes, `Verse.Text` at `lg`, chapter headings, the Bible card, and the `lg` Verse of |
| 48 | +the Day card all follow the token. |
| 49 | + |
| 50 | +**2. Delivery (YPE-1350).** A new `<YvFonts />` (`packages/ui/src/lib/yv-fonts.tsx`), |
| 51 | +sibling to `<YvStyles />`, rendered from `YouVersionProvider` in the normal branch only |
| 52 | +(no app key, no font): |
| 53 | + |
| 54 | +```tsx |
| 55 | +<link |
| 56 | + rel="stylesheet" |
| 57 | + href={`https://${apiHost}/v1/fonts/1/stylesheet?app_key=${encodeURIComponent(appKey)}`} |
| 58 | + precedence="yv-sdk-fonts" |
| 59 | +/> |
| 60 | +``` |
| 61 | + |
| 62 | +React 19 hoists it to `<head>` and dedupes by `href`, so multiple providers still yield |
| 63 | +one link and SSR streaming works. `@font-face` is not subject to `@layer`, so cascade |
| 64 | +position is irrelevant; `precedence` is only there to opt into the hoist and dedupe. |
| 65 | + |
| 66 | +This is the SDK's first runtime-value-dependent stylesheet, and it has to be. The build |
| 67 | +pipeline freezes `global.css` into the `__YV_STYLES__` string literal at `pnpm |
| 68 | +build:css`, with no access to a consumer's app key — a React-rendered `<link>` is the |
| 69 | +only seam that has one. |
| 70 | + |
| 71 | +`font_id` is hardcoded to `1` (slug `untitled-serif`) rather than discovered via |
| 72 | +`GET /v1/fonts`. Discovery would add a request waterfall in front of first paint to |
| 73 | +guard against an id change that would itself be a breaking change on YouVersion's own |
| 74 | +service. The constant is named and comment-linked to this ADR so it is greppable if the |
| 75 | +API ever renumbers. `packages/core/src/schemas/font.ts` stays unwired; no `FontsClient` |
| 76 | +and no `useFonts` hook are built. |
| 77 | + |
| 78 | +`apiHost` threads through the same way `ApiClient` does (`config.apiHost ?? |
| 79 | +'api.youversion.com'`) so staging environments keep working. |
| 80 | + |
| 81 | +**3. Reader picker (YPE-1350).** `UNTITLED_SERIF_FONT` becomes the reader's default font |
| 82 | +family and the right-hand picker button, labelled **"Untitled Serif"** per the ticket's |
| 83 | +explicit wording. `SOURCE_SERIF_FONT` stays exported as `@deprecated` solely so the |
| 84 | +hydration path can recognize it: a reader who chose serif before this shipped has the |
| 85 | +old stack in `localStorage`, and without mapping it forward they would hydrate to a |
| 86 | +value matching neither picker button. The mapping is deliberately narrow rather than |
| 87 | +full validation, because `FontFamily` is an open type on purpose and a host passing |
| 88 | +`defaultFontFamily="Georgia"` must keep round-tripping. |
| 89 | + |
| 90 | +**4. No opt-out.** There is no `disableBrandFonts` prop, consistent with `<YvStyles />`, |
| 91 | +which has none. Strict-CSP consumers get documented CSP entries |
| 92 | +(`packages/ui/README.md`) rather than an escape hatch. Adding a prop later is |
| 93 | +non-breaking if the need turns out to be real. |
| 94 | + |
| 95 | +## Consequences |
| 96 | + |
| 97 | +- Every serif surface in the SDK renders the YouVersion brand serif for the first time. |
| 98 | + The abbreviation tile and reader body text are now an exact brand match rather than |
| 99 | + ADR-0001's closest available substitute. |
| 100 | +- **A new outbound request per consumer app**, to |
| 101 | + `https://api.youversion.com/v1/fonts/1/stylesheet`, and the woff2 fetches that follow |
| 102 | + it from `cdn.youversion.com`. Both are `cache-control: public` (86400s and 3600s |
| 103 | + respectively) and CORS-open. Consumers with a strict CSP must allowlist |
| 104 | + `api.youversion.com` in `style-src` and `cdn.youversion.com` in `font-src`; without |
| 105 | + them the SDK falls back to Source Serif 4 with no layout break. |
| 106 | +- **The app key appears in a URL query string.** It is already public browser-side (it |
| 107 | + ships in request headers on every API call), and the gateway accepts it on this route, |
| 108 | + but it will now also land in CDN/proxy access logs and `Referer` headers. Accepted |
| 109 | + knowingly. |
| 110 | +- **`<link rel="stylesheet" precedence>` can suspend the commit of the component that |
| 111 | + renders it while the sheet loads.** Verified that a plain synchronous mount commits its |
| 112 | + children immediately (`yv-fonts.test.tsx`). A mount that happens inside a transition — |
| 113 | + a Next.js App Router client navigation, for example — is not covered by that test and |
| 114 | + may hold the commit until the request settles. Failures settle too, so this is a |
| 115 | + latency risk rather than a hang. Revisit if a consumer reports a slow first navigation. |
| 116 | +- The default reader font changes from Source Serif 4 to Untitled Serif for new users, |
| 117 | + and returning serif readers are migrated on hydrate. No public API is removed or |
| 118 | + retyped; the change ships as a `minor` across all three packages. |
| 119 | +- The serif stack is declared twice and must stay in sync by hand. |
| 120 | + `font-tokens.test.ts` is the guard — it is the first test in the repo to assert a font |
| 121 | + token's literal value. |
| 122 | +- The revert, if it is ever needed, is small and local: drop `<YvFonts />` from |
| 123 | + `YouVersionProvider` and remove `'Untitled Serif'` from the two stacks. Everything |
| 124 | + else — the picker label, the migration, the CSP docs — degrades to Source Serif 4 on |
| 125 | + its own. |
0 commit comments