Skip to content
Open
15 changes: 15 additions & 0 deletions .changeset/untitled-serif-via-fonts-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@youversion/platform-core': minor
'@youversion/platform-react-hooks': minor
'@youversion/platform-react-ui': minor
---

Swap the SDK's serif face from Source Serif 4 to **Untitled Serif**, YouVersion's brand serif, delivered from the YouVersion Fonts API (YPE-1350, YPE-1910).

- The serif stack is now `'Untitled Serif', 'Source Serif 4', serif` in both token declarations (`--yv-font-serif` in core, `--font-serif` in the UI theme), so every serif surface follows: BibleReader body text, the Bible card, `BibleText`, the version-picker abbreviation tile, footnotes, chapter headings, and the `lg` Verse of the Day card. Untitled Serif is named first, so a host that loads its own copy takes priority regardless of who fetched it.
- `YouVersionProvider` now loads the font for you. It renders a hoisted `<link rel="stylesheet">` to `https://api.youversion.com/v1/fonts/1/stylesheet`, using the app key you already supply — **a new outbound request** to `api.youversion.com`, plus woff2 fetches from `cdn.youversion.com`. No new prop and no setup; there is no opt-out. No font file ships in any package.
- **Strict CSP consumers:** allowlist `https://api.youversion.com` in `style-src` and `https://cdn.youversion.com` in `font-src`. If they are blocked, serif text falls back to Source Serif 4 (still loaded from Google Fonts) with no layout break.
- **The BibleReader's default font changes** from Source Serif 4 to Untitled Serif, and the font picker button now reads "Untitled" instead of "Source Serif". Readers whose saved preference is the old Source Serif stack are migrated on load, so the picker still shows serif as active. Any other `fontFamily` value you pass or persist is left untouched.
- The internal `SOURCE_SERIF_FONT` constant is deprecated (retained for that migration) and `sourceSerifFontName` is gone from the SDK's private locale files. Neither is part of the public API; nothing is removed or retyped.

See `docs/adr/0004-adopt-untitled-serif-via-fonts-api.md` for the delivery rationale.
71 changes: 26 additions & 45 deletions docs/adr/0001-revert-brand-fonts-pending-licensing.md
Original file line number Diff line number Diff line change
@@ -1,67 +1,48 @@
# 1. Revert brand fonts to Inter / Source Serif 4 pending licensing
# 1. Revert brand fonts to Inter / Source Serif 4

Date: 2026-06-24

## Status

Accepted
Accepted. **Superseded in part** by
[ADR-0004](0004-adopt-untitled-serif-via-fonts-api.md) (2026-07-28), for **Untitled
Serif only**: it now loads from the `/v1/fonts/1/stylesheet` endpoint, which is the
condition below. The sans stack is unchanged and stays `'Inter', sans-serif`.

## Context

The SDK had begun shipping YouVersion brand fonts to consumer apps:
The SDK had begun shipping brand fonts to consumer apps via hardcoded `@font-face`
blocks pointing at CDN woff2 files — for the sans default (`--yv-font-sans`), the serif
default (`--yv-font-serif`), and the Bible Version picker abbreviation tile. None of it
reached a published release.

- **Aktiv Grotesk App** (Dalton Maag) as the sans default (`--yv-font-sans`),
loaded via a hardcoded `@font-face` pointing at a public CDN woff2.
- **Untitled Serif** (Klim Type Foundry) as the serif default (`--yv-font-serif`)
and the Bible Version picker abbreviation tile, same hardcoded `@font-face` pattern.
A hardcoded `@font-face` is the wrong delivery pattern for this SDK. Its purpose is to
render inside **third-party developer apps**, so it puts the font file in front of third
parties with nothing in the SDK able to gate it — access control is enforced server-side
(API gateway + CDN), not in the SDK.

Both create the same exposure: the SDK's purpose is to render fonts inside
**third-party developer apps**, so the font files are delivered to, and
downloadable by, third parties.

- **Aktiv Grotesk (Dalton Maag):** the licence is breached the moment a
third-party developer uses their app key and gains access to the actual font
file (`.woff`/`.woff2`/`.otf`/`.ttf`). No licence tier we hold covers serving
this font to arbitrary third parties. CORS / file-level protection is
enforced server-side (YouVersion API gateway + CDN), not in the SDK — the SDK
cannot make the file un-downloadable.
- **Untitled Serif (Klim):** an Enterprise licence may permit third-party use
if developers qualify as a "partner" (the licence enumerates affiliates,
agencies, partners, vendors, contractors, freelancers). Whether a Platform
developer is a "partner" is an **open legal question**.

A "browser-consumable stylesheet" endpoint exists
(`GET /v1/fonts/{font_id}/stylesheet`, accepts `app_key`, gateway injects the
app-id header). It is the correct future consumption pattern, but it does **not**
by itself resolve licensing: the woff2 it references still sits at a public CDN
URL, so switching to it does not make the font file un-downloadable.
A browser-consumable stylesheet endpoint exists
(`GET /v1/fonts/{font_id}/stylesheet`, accepts an app key; the gateway injects the
app-id header). It is the correct consumption pattern for a font the SDK serves.

## Decision

Revert **both** brand fonts to the prior fallbacks for the shipping PR:
Revert both font tokens to the prior fallbacks for the shipping PR:

- `--yv-font-sans` → `'Inter', sans-serif`
- `--yv-font-serif` → `'Source Serif 4', serif`

Remove both brand `@font-face` blocks, the `--font-aktiv` / `--font-untitled-serif`
aliases, the `yv:font-aktiv` / `yv:font-untitled-serif` usages, and the brand
options in the Bible Reader font picker. The abbreviation-tile redesign and all
other Figma layout/typography work, the `useOrganizations` hooks, and publisher
Remove the brand `@font-face` blocks, their `--font-*` aliases and `yv:font-*` usages,
and the brand options in the Bible Reader font picker. The abbreviation-tile redesign
and all other Figma layout/typography work, the `useOrganizations` hooks, and publisher
names are retained — only the font **family** is reverted.

The brand-font implementation is parked on branch `feat/youversion-brand-fonts`
(snapshot at the pre-revert HEAD) for re-application once licensing clears.

## Consequences

- The SDK ships no licence-restricted font files to third parties. Defensible
legal state.
- The abbreviation tile and serif body text render in **Source Serif 4** (the
serif fallback) rather than Untitled Serif — closest legal match to the Figma
- The SDK ships no font files to third parties.
- The abbreviation tile and serif body text render in **Source Serif 4** (the serif
fallback) rather than the brand serif — the closest available substitute for the Figma
serif intent; exact brand match is deferred.
- Re-introducing brand fonts requires: (1) legal sign-off on Untitled Serif's
"partner" classification and/or a resolved Aktiv licence path, and (2) loading
via the gated `/v1/fonts/{font_id}/stylesheet` endpoint rather than hardcoded
`@font-face`. Untitled Serif is `font_id` 1 / slug `untitled-serif`.
- Re-application path: cherry-pick the font hunks from `feat/youversion-brand-fonts`
onto then-current `main`.
- Introducing a brand font requires loading it via the
`/v1/fonts/{font_id}/stylesheet` endpoint rather than a hardcoded `@font-face`.
Untitled Serif is `font_id` 1 / slug `untitled-serif`.
125 changes: 125 additions & 0 deletions docs/adr/0004-adopt-untitled-serif-via-fonts-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 4. Adopt Untitled Serif via the Fonts API stylesheet endpoint

Date: 2026-07-28

## Status

Accepted. Supersedes [ADR-0001](0001-revert-brand-fonts-pending-licensing.md) **in
part** — for Untitled Serif only.

## Context

ADR-0001 reverted the SDK's brand fonts and set one condition for bringing one back:
load it through the `/v1/fonts/{font_id}/stylesheet` endpoint rather than a hardcoded
`@font-face`, so the SDK never ships or hosts the file itself.

Untitled Serif meets that condition. `GET /v1/fonts/1/stylesheet` accepts the app key as
either an `X-YVP-App-Key` header or an `?app_key=` query parameter, and returns `401`
without one.

The sans stack is unchanged and stays `'Inter', sans-serif` — this is a serif-only
change.

YPE-1350 (BibleReader renders Untitled Serif) and YPE-1910 (`--yv-font-serif` becomes
`Untitled Serif → Source Serif 4 → serif`, covering `BibleText` and the Bible card, not
just the reader) both depend on this.

## Decision

Adopt Untitled Serif as the SDK's serif face, loaded by the SDK itself from the
stylesheet endpoint.

**1. Font stack (YPE-1910).** Both serif declarations become
`'Untitled Serif', 'Source Serif 4', serif`:

- `packages/core/src/styles/theme.css` — `--yv-font-serif`
- `packages/ui/src/styles/global.css` — `--font-serif`, inside `@theme inline`

These are literal duplicates in two packages, not aliases: core cannot import Tailwind,
and `@theme inline` values are inlined into utilities rather than emitted as runtime
custom properties. `packages/ui/src/styles/font-tokens.test.ts` reads both files off
disk and fails if they drift apart. Source Serif 4 stays loaded from Google Fonts as the
fallback, so nothing regresses when Untitled Serif is unavailable — and because the
stack names Untitled Serif *first*, a host that loads its own copy wins regardless of
who fetched it. That is YPE-1910's explicit requirement.

The change is SDK-wide, not reader-only: the version-picker abbreviation tile,
footnotes, `Verse.Text` at `lg`, chapter headings, the Bible card, and the `lg` Verse of
the Day card all follow the token.

**2. Delivery (YPE-1350).** A new `<YvFonts />` (`packages/ui/src/lib/yv-fonts.tsx`),
sibling to `<YvStyles />`, rendered from `YouVersionProvider` in the normal branch only
(no app key, no font):

```tsx
<link
rel="stylesheet"
href={`https://${apiHost}/v1/fonts/1/stylesheet?app_key=${encodeURIComponent(appKey)}`}
precedence="yv-sdk-fonts"
/>
```

React 19 hoists it to `<head>` and dedupes by `href`, so multiple providers still yield
one link and SSR streaming works. `@font-face` is not subject to `@layer`, so cascade
position is irrelevant; `precedence` is only there to opt into the hoist and dedupe.

This is the SDK's first runtime-value-dependent stylesheet, and it has to be. The build
pipeline freezes `global.css` into the `__YV_STYLES__` string literal at `pnpm
build:css`, with no access to a consumer's app key — a React-rendered `<link>` is the
only seam that has one.

`font_id` is hardcoded to `1` (slug `untitled-serif`) rather than discovered via
`GET /v1/fonts`. Discovery would add a request waterfall in front of first paint to
guard against an id change that would itself be a breaking change on YouVersion's own
service. The constant is named and comment-linked to this ADR so it is greppable if the
API ever renumbers. `packages/core/src/schemas/font.ts` stays unwired; no `FontsClient`
and no `useFonts` hook are built.

`apiHost` threads through the same way `ApiClient` does (`config.apiHost ??
'api.youversion.com'`) so staging environments keep working.

**3. Reader picker (YPE-1350).** `UNTITLED_SERIF_FONT` becomes the reader's default font
family and the right-hand picker button, labelled **"Untitled"** per the ticket's
explicit wording. `SOURCE_SERIF_FONT` stays exported as `@deprecated` solely so the
hydration path can recognize it: a reader who chose serif before this shipped has the
old stack in `localStorage`, and without mapping it forward they would hydrate to a
value matching neither picker button. The mapping is deliberately narrow rather than
full validation, because `FontFamily` is an open type on purpose and a host passing
`defaultFontFamily="Georgia"` must keep round-tripping.

**4. No opt-out.** There is no `disableBrandFonts` prop, consistent with `<YvStyles />`,
which has none. Strict-CSP consumers get documented CSP entries
(`packages/ui/README.md`) rather than an escape hatch. Adding a prop later is
non-breaking if the need turns out to be real.

## Consequences

- Every serif surface in the SDK renders the YouVersion brand serif for the first time.
The abbreviation tile and reader body text are now an exact brand match rather than
ADR-0001's closest available substitute.
- **A new outbound request per consumer app**, to
`https://api.youversion.com/v1/fonts/1/stylesheet`, and the woff2 fetches that follow
it from `cdn.youversion.com`. Both are `cache-control: public` (86400s and 3600s
respectively) and CORS-open. Consumers with a strict CSP must allowlist
`api.youversion.com` in `style-src` and `cdn.youversion.com` in `font-src`; without
them the SDK falls back to Source Serif 4 with no layout break.
- **The app key appears in a URL query string.** It is already public browser-side (it
ships in request headers on every API call), and the gateway accepts it on this route,
but it will now also land in CDN/proxy access logs and `Referer` headers. Accepted
knowingly.
- **`<link rel="stylesheet" precedence>` can suspend the commit of the component that
renders it while the sheet loads.** Verified that a plain synchronous mount commits its
children immediately (`yv-fonts.test.tsx`). A mount that happens inside a transition —
a Next.js App Router client navigation, for example — is not covered by that test and
may hold the commit until the request settles. Failures settle too, so this is a
latency risk rather than a hang. Revisit if a consumer reports a slow first navigation.
- The default reader font changes from Source Serif 4 to Untitled Serif for new users,
and returning serif readers are migrated on hydrate. No public API is removed or
retyped; the change ships as a `minor` across all three packages.
- The serif stack is declared twice and must stay in sync by hand.
`font-tokens.test.ts` is the guard — it is the first test in the repo to assert a font
token's literal value.
- The revert, if it is ever needed, is small and local: drop `<YvFonts />` from
`YouVersionProvider` and remove `'Untitled Serif'` from the two stacks. Everything
else — the picker label, the migration, the CSP docs — degrades to Source Serif 4 on
its own.
11 changes: 7 additions & 4 deletions packages/core/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,14 @@
--yv-sidebar-border: var(--yv-gray-15);
--yv-sidebar-ring: var(--yv-blue-30);

/* Brand fonts (Aktiv Grotesk App / Untitled Serif) reverted to Inter / Source
Serif 4 pending licensing — see docs/adr/0001-revert-brand-fonts-pending-licensing.md.
Brand-font implementation parked on branch feat/youversion-brand-fonts. */
/* Untitled Serif is the brand serif, loaded from the gated Fonts API stylesheet
endpoint — see docs/adr/0004-adopt-untitled-serif-via-fonts-api.md. Source Serif 4
(Google Fonts) stays in the stack as the fallback, so a host that never loads
Untitled Serif sees no regression.
NOTE: the serif stack is duplicated verbatim in packages/ui/src/styles/global.css
(@theme inline, --font-serif). Edit both together — font-tokens.test.ts guards drift. */
--yv-font-sans: 'Inter', sans-serif;
--yv-font-serif: 'Source Serif 4', serif;
--yv-font-serif: 'Untitled Serif', 'Source Serif 4', serif;
--yv-reader-font-family: var(--yv-font-serif), var(--yv-font-sans);

&[data-yv-theme='dark'] {
Expand Down
11 changes: 7 additions & 4 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,15 @@ All component classes are prefixed with `yv:` to avoid collisions with your app'
The SDK loads webfonts from external origins. If your app sets a strict `Content-Security-Policy`, allowlist these hosts so fonts aren't blocked (without them, components fall back to a system sans-serif):

```
font-src https://fonts.gstatic.com https://storage.googleapis.com;
style-src https://fonts.googleapis.com;
font-src https://fonts.gstatic.com https://cdn.youversion.com;
style-src https://fonts.googleapis.com https://api.youversion.com;
```

- `fonts.gstatic.com` / `fonts.googleapis.com` — Inter and Source Serif (base typography)
- `storage.googleapis.com` — Aktiv Grotesk App, the brand font used by `BibleChapterPicker`
- `fonts.googleapis.com` / `fonts.gstatic.com` — Inter and Source Serif 4 (base typography), loaded from Google Fonts
- `api.youversion.com` — the Fonts API stylesheet (`/v1/fonts/1/stylesheet`), which `YouVersionProvider` requests with your app key
- `cdn.youversion.com` — the Untitled Serif woff2 files that stylesheet points at

Untitled Serif is YouVersion's brand serif and the SDK's default serif face. There is no prop to turn it off. If these hosts are blocked, serif text falls back to Source Serif 4 with no layout break — the stack is `'Untitled Serif', 'Source Serif 4', serif`. If you load Untitled Serif yourself, your copy is used; the stack names it first regardless of who fetched it.

## Theming

Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/YouVersionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
import { YouVersionProvider as BaseYouVersionProvider } from '@youversion/platform-react-hooks';
import { syncBrowserLanguageFromNavigator } from '@/i18n';
import { YvStyles } from '@/lib/yv-styles';
import { YvFonts } from '@/lib/yv-fonts';
import { MissingAppKey } from '@/components/missing-app-key';

function resolveTheme(theme: 'light' | 'dark' | 'system' = 'light'): 'light' | 'dark' {
Expand Down Expand Up @@ -57,6 +58,9 @@ export function YouVersionProvider(
return (
<BaseYouVersionProvider {...props}>
<YvStyles />
{/* Only in this branch — the missing-app-key guard above has no key, and
without a key the gated Fonts API request would 401. */}
<YvFonts appKey={props.appKey} apiHost={props.apiHost} />
{props.children}
</BaseYouVersionProvider>
);
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/bible-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { BibleAppLogoLockup } from './bible-app-logo-lockup';
import { BibleVersionPicker, type BibleVersionPickerPressData } from './bible-version-picker';
import { Button } from './ui/button';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { SOURCE_SERIF_FONT } from '@/lib/verse-html-utils';
import { UNTITLED_SERIF_FONT } from '@/lib/verse-html-utils';
import { useDelayedLoading } from '@/lib/use-delayed-loading';
import { LoaderIcon } from './icons/loader';
import { AnimatedHeight } from './animated-height';
Expand Down Expand Up @@ -181,7 +181,7 @@ export function BibleCard({
<BibleTextView
theme={theme}
fontSize={16}
fontFamily={SOURCE_SERIF_FONT}
fontFamily={UNTITLED_SERIF_FONT}
reference={reference}
versionId={versionNum}
showVerseNumbers={false}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/bible-chapter-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,15 @@ describe('BibleChapterPicker - typography (matches Figma sizing; sans inherited)
expect(genesisTrigger).toHaveClass('yv:data-[state=open]:font-bold');
});

it('chapter number buttons use Aktiv 16px bold', () => {
it('chapter number buttons render at 16px bold', () => {
renderContent();

const chapterButton = screen.getByText('2').closest('button');
expect(chapterButton).not.toBeNull();
expect(chapterButton).toHaveClass('yv:text-base', 'yv:font-bold');
});

it('search input uses Aktiv 16px', () => {
it('search input renders at 16px', () => {
renderContent();

expect(screen.getByPlaceholderText('Search')).toHaveClass('yv:text-base');
Expand Down
Loading
Loading