diff --git a/.changeset/untitled-serif-via-fonts-api.md b/.changeset/untitled-serif-via-fonts-api.md
new file mode 100644
index 00000000..786c52f1
--- /dev/null
+++ b/.changeset/untitled-serif-via-fonts-api.md
@@ -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 `` 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.
diff --git a/docs/adr/0001-revert-brand-fonts-pending-licensing.md b/docs/adr/0001-revert-brand-fonts-pending-licensing.md
index 656ef217..ded94c62 100644
--- a/docs/adr/0001-revert-brand-fonts-pending-licensing.md
+++ b/docs/adr/0001-revert-brand-fonts-pending-licensing.md
@@ -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`.
diff --git a/docs/adr/0004-adopt-untitled-serif-via-fonts-api.md b/docs/adr/0004-adopt-untitled-serif-via-fonts-api.md
new file mode 100644
index 00000000..a5dde3ee
--- /dev/null
+++ b/docs/adr/0004-adopt-untitled-serif-via-fonts-api.md
@@ -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 `` (`packages/ui/src/lib/yv-fonts.tsx`),
+sibling to ``, rendered from `YouVersionProvider` in the normal branch only
+(no app key, no font):
+
+```tsx
+
+```
+
+React 19 hoists it to `
` 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 `` 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 ``,
+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.
+- **`` 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 `` 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.
diff --git a/packages/core/src/styles/theme.css b/packages/core/src/styles/theme.css
index 8fda5aeb..4cc241bf 100644
--- a/packages/core/src/styles/theme.css
+++ b/packages/core/src/styles/theme.css
@@ -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'] {
diff --git a/packages/ui/README.md b/packages/ui/README.md
index 5cbb732f..39af9d7e 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -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
diff --git a/packages/ui/src/components/YouVersionProvider.tsx b/packages/ui/src/components/YouVersionProvider.tsx
index e5b4a691..d00f1597 100644
--- a/packages/ui/src/components/YouVersionProvider.tsx
+++ b/packages/ui/src/components/YouVersionProvider.tsx
@@ -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' {
@@ -57,6 +58,9 @@ export function YouVersionProvider(
return (
+ {/* Only in this branch — the missing-app-key guard above has no key, and
+ without a key the gated Fonts API request would 401. */}
+
{props.children}
);
diff --git a/packages/ui/src/components/bible-card.tsx b/packages/ui/src/components/bible-card.tsx
index 3203d05f..3b5070e0 100644
--- a/packages/ui/src/components/bible-card.tsx
+++ b/packages/ui/src/components/bible-card.tsx
@@ -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';
@@ -181,7 +181,7 @@ export function BibleCard({
{
+ it('chapter number buttons render at 16px bold', () => {
renderContent();
const chapterButton = screen.getByText('2').closest('button');
@@ -258,7 +258,7 @@ describe('BibleChapterPicker - typography (matches Figma sizing; sans inherited)
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');
diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx
index 5f9c44d8..083c28b7 100644
--- a/packages/ui/src/components/bible-reader.stories.tsx
+++ b/packages/ui/src/components/bible-reader.stories.tsx
@@ -1,5 +1,5 @@
import { expandPassageId } from '@/lib/highlight-projection';
-import { INTER_FONT, SOURCE_SERIF_FONT } from '@/lib/verse-html-utils';
+import { INTER_FONT, SOURCE_SERIF_FONT, UNTITLED_SERIF_FONT } from '@/lib/verse-html-utils';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { Highlight } from '@youversion/platform-core';
import { delay, http, HttpResponse } from 'msw';
@@ -56,7 +56,7 @@ const meta: Meta = {
},
fontFamily: {
control: 'select',
- options: [SOURCE_SERIF_FONT, INTER_FONT],
+ options: [UNTITLED_SERIF_FONT, INTER_FONT],
description: 'Font family',
},
showVerseNumbers: {
@@ -134,11 +134,11 @@ export const Default: Story = {
await expect(decreaseFontButton).toBeDisabled();
const interButton = screen.getByRole('button', { name: /inter/i });
- const sourceSerifButton = screen.getByRole('button', { name: /source serif/i });
+ const untitledSerifButton = screen.getByRole('button', { name: /untitled/i });
- await userEvent.click(sourceSerifButton);
+ await userEvent.click(untitledSerifButton);
await expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(
- SOURCE_SERIF_FONT,
+ UNTITLED_SERIF_FONT,
);
await userEvent.click(interButton);
@@ -179,7 +179,7 @@ export const CustomStyling: Story = {
defaultVersionId: 111,
fontSize: 18,
lineSpacing: 2.0,
- fontFamily: SOURCE_SERIF_FONT,
+ fontFamily: UNTITLED_SERIF_FONT,
showVerseNumbers: false,
},
render: (args) => (
@@ -209,7 +209,7 @@ export const FontSizeOutOfRange: Story = {
defaultVersionId: 111,
fontSize: 28,
lineSpacing: 2.0,
- fontFamily: SOURCE_SERIF_FONT,
+ fontFamily: UNTITLED_SERIF_FONT,
showVerseNumbers: false,
},
render: (args) => (
@@ -511,7 +511,9 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = {
},
beforeEach: () => {
localStorage.clear();
- // Pre-populate localStorage with saved preferences
+ // Pre-populate localStorage with saved preferences. The font family is the
+ // legacy Source Serif stack, so this story also covers the hydrate-time
+ // migration to Untitled Serif.
localStorage.setItem('youversion-platform:reader:font-size', '18');
localStorage.setItem('youversion-platform:reader:font-family', SOURCE_SERIF_FONT);
},
@@ -537,8 +539,12 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = {
'[data-slot="yv-bible-renderer"]',
)!;
await expect(verseContainer.style.getPropertyValue('--yv-reader-font-size')).toBe('18px');
+ // The legacy Source Serif value seeded above is migrated forward on hydrate.
await expect(verseContainer.style.getPropertyValue('--yv-reader-font-family')).toBe(
- SOURCE_SERIF_FONT,
+ UNTITLED_SERIF_FONT,
+ );
+ await expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(
+ UNTITLED_SERIF_FONT,
);
// Open settings and verify the correct font family button is active
@@ -549,8 +555,8 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = {
await expect(await screen.findByText('Reader Settings')).toBeInTheDocument();
});
- const sourceSerifButton = screen.getByRole('button', { name: /source serif/i });
- await expect(sourceSerifButton).toHaveClass('yv:bg-primary');
+ const untitledSerifButton = screen.getByRole('button', { name: /untitled/i });
+ await expect(untitledSerifButton).toHaveClass('yv:bg-primary');
const interButton = screen.getByRole('button', { name: /inter/i });
await expect(interButton).not.toHaveClass('yv:bg-primary');
diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx
index 64b39c88..96b98308 100644
--- a/packages/ui/src/components/bible-reader.test.tsx
+++ b/packages/ui/src/components/bible-reader.test.tsx
@@ -28,7 +28,12 @@ import {
nextBibleReaderFontSizeUp,
type BibleThemeSettingsSnapshot,
} from './bible-reader';
-import { INTER_FONT, SOURCE_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils';
+import {
+ INTER_FONT,
+ SOURCE_SERIF_FONT,
+ UNTITLED_SERIF_FONT,
+ type FontFamily,
+} from '@/lib/verse-html-utils';
class ResizeObserverMock {
observe() {}
@@ -140,7 +145,7 @@ describe('BibleReader font helpers', () => {
describe('createBibleThemeSettingsContentHandlers', () => {
it('updates font size, family, and line spacing via host-owned setters', () => {
let fontSize = 16;
- let fontFamily: FontFamily = SOURCE_SERIF_FONT;
+ let fontFamily: FontFamily = UNTITLED_SERIF_FONT;
let lineSpacing: number = BIBLE_READER_SPACING.DEFAULT;
const setFontSize = vi.fn((n: number) => {
fontSize = n;
@@ -171,6 +176,9 @@ describe('createBibleThemeSettingsContentHandlers', () => {
handlers.onFontSelected(INTER_FONT);
expect(setFontFamily).toHaveBeenCalledWith(INTER_FONT);
+ handlers.onFontSelected(UNTITLED_SERIF_FONT);
+ expect(setFontFamily).toHaveBeenLastCalledWith(UNTITLED_SERIF_FONT);
+
// Cycles DEFAULT -> LG -> SM -> DEFAULT
handlers.onChangeLineSpacing();
expect(setLineSpacing).toHaveBeenLastCalledWith(BIBLE_READER_SPACING.LG);
@@ -210,6 +218,50 @@ describe('BibleReader theme settings', () => {
await waitFor(() => {
expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT);
});
+
+ await user.click(screen.getByRole('button', { name: /untitled/i }));
+ await waitFor(() => {
+ expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(
+ UNTITLED_SERIF_FONT,
+ );
+ });
+ });
+
+ it('migrates the legacy Source Serif preference to Untitled Serif on hydrate', async () => {
+ const user = userEvent.setup();
+
+ localStorage.setItem('youversion-platform:reader:font-family', SOURCE_SERIF_FONT);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(
+ UNTITLED_SERIF_FONT,
+ );
+ });
+
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
+ expect(await screen.findByText('Reader Settings')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { name: /untitled/i }).className).toContain('yv:bg-primary');
+ });
+
+ it('leaves a non-legacy stored font family untouched on hydrate', async () => {
+ localStorage.setItem('youversion-platform:reader:font-family', INTER_FONT);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT);
+ });
});
it('cycles line spacing and resizes the line-spacing button icon gap on click', async () => {
@@ -293,7 +345,7 @@ describe('BibleReader theme settings', () => {
function ControlledHost() {
const [fontSize, setFontSize] = useState(16);
- const [fontFamily, setFontFamily] = useState(SOURCE_SERIF_FONT);
+ const [fontFamily, setFontFamily] = useState(UNTITLED_SERIF_FONT);
return (
<>
diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx
index 86ae2616..7410ff66 100644
--- a/packages/ui/src/components/bible-reader.tsx
+++ b/packages/ui/src/components/bible-reader.tsx
@@ -4,7 +4,12 @@ import i18n from '@/i18n';
import { IS_PRODUCTION } from '@/lib/constants';
import { useDelayedLoading } from '@/lib/use-delayed-loading';
import { cn } from '@/lib/utils';
-import { INTER_FONT, SOURCE_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils';
+import {
+ INTER_FONT,
+ SOURCE_SERIF_FONT,
+ UNTITLED_SERIF_FONT,
+ type FontFamily,
+} from '@/lib/verse-html-utils';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import type { BibleBook, Highlight } from '@youversion/platform-core';
import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core';
@@ -358,7 +363,7 @@ function Root({
defaultFontSize = DEFAULT_FONT_SIZE,
onFontSizeChange,
fontFamily: fontFamilyProp,
- defaultFontFamily = SOURCE_SERIF_FONT,
+ defaultFontFamily = UNTITLED_SERIF_FONT,
onFontFamilyChange,
lineSpacing: lineSpacingProp,
defaultLineSpacing,
@@ -470,7 +475,13 @@ function Root({
if (!isFontFamilyControlled) {
const savedFontFamily = localStorage.getItem('youversion-platform:reader:font-family');
if (savedFontFamily) {
- setCurrentFontFamily(savedFontFamily);
+ // Readers who picked serif before Untitled Serif shipped stored the old
+ // stack; map it forward so the picker still shows serif as active.
+ // Deliberately not full validation — `FontFamily` is open on purpose, so
+ // a host-supplied `defaultFontFamily="Georgia"` must still round-trip.
+ setCurrentFontFamily(
+ savedFontFamily === SOURCE_SERIF_FONT ? UNTITLED_SERIF_FONT : savedFontFamily,
+ );
}
}
@@ -1144,18 +1155,18 @@ export function BibleThemeSettingsContent({
diff --git a/packages/ui/src/lib/verse-html-utils.ts b/packages/ui/src/lib/verse-html-utils.ts
index 51839218..6d549c17 100644
--- a/packages/ui/src/lib/verse-html-utils.ts
+++ b/packages/ui/src/lib/verse-html-utils.ts
@@ -1,3 +1,9 @@
export const INTER_FONT = '"Inter", sans-serif' as const;
+export const UNTITLED_SERIF_FONT = '"Untitled Serif", "Source Serif 4", serif' as const;
+/** @deprecated retained for the localStorage migration; not offered in the picker */
export const SOURCE_SERIF_FONT = '"Source Serif 4", serif' as const;
-export type FontFamily = typeof INTER_FONT | typeof SOURCE_SERIF_FONT | (string & {});
+export type FontFamily =
+ | typeof INTER_FONT
+ | typeof UNTITLED_SERIF_FONT
+ | typeof SOURCE_SERIF_FONT
+ | (string & {});
diff --git a/packages/ui/src/lib/yv-fonts.test.tsx b/packages/ui/src/lib/yv-fonts.test.tsx
new file mode 100644
index 00000000..d63ec87e
--- /dev/null
+++ b/packages/ui/src/lib/yv-fonts.test.tsx
@@ -0,0 +1,138 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { act } from 'react';
+import { YouVersionProvider } from '@/components/YouVersionProvider';
+import { YvFonts } from '@/lib/yv-fonts';
+
+vi.mock('@youversion/platform-react-hooks', () => {
+ function PassthroughProvider({ children }: { children: React.ReactNode }) {
+ return <>{children}>;
+ }
+ return {
+ YouVersionProvider: PassthroughProvider,
+ useYVAuth: vi.fn(),
+ };
+});
+
+const FONT_LINK_SELECTOR = 'link[rel="stylesheet"][data-precedence="yv-sdk-fonts"]';
+
+function fontLinks(): HTMLLinkElement[] {
+ return Array.from(document.head.querySelectorAll(FONT_LINK_SELECTOR));
+}
+
+function hrefsFor(appKey: string): string[] {
+ return fontLinks()
+ .map((link) => link.getAttribute('href') ?? '')
+ .filter((href) => href.includes(`app_key=${appKey}`));
+}
+
+// React hoists these into keyed by href and keeps its own resource map, so
+// a removed node is never re-inserted for the same href. Every test therefore uses
+// a distinct app key and asserts only on its own link.
+describe('Font stylesheet injection via YouVersionProvider', () => {
+ it('renders a to the gated Fonts API stylesheet with the app key', () => {
+ render(
+
+
hello
+ ,
+ );
+
+ expect(hrefsFor('key-basic')).toEqual([
+ 'https://api.youversion.com/v1/fonts/1/stylesheet?app_key=key-basic',
+ ]);
+
+ // React can suspend a commit on until the
+ // sheet loads, and jsdom never fires `load` for it. Assert children still
+ // commit, so a plain synchronous mount can't blank a consumer's tree. This
+ // covers the sync path only — a mount inside a transition is not exercised
+ // here; see the caveat in yv-fonts.tsx.
+ expect(document.body.querySelector('[data-testid="child"]')?.textContent).toBe('hello');
+ });
+
+ it('percent-encodes the app key', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(hrefsFor('key%20encode%2F%2Bchars')).toEqual([
+ 'https://api.youversion.com/v1/fonts/1/stylesheet?app_key=key%20encode%2F%2Bchars',
+ ]);
+ });
+
+ it('respects a custom apiHost', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(hrefsFor('key-host')).toEqual([
+ 'https://api-staging.youversion.com/v1/fonts/1/stylesheet?app_key=key-host',
+ ]);
+ });
+
+ it('renders no font when the app key is missing', () => {
+ // A whitespace-only key trips the provider's missing-app-key guard, which
+ // renders alone — is never reached. YvFonts' own
+ // guard is covered separately below.
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const before = fontLinks().length;
+
+ render(
+
+
+ ,
+ );
+
+ expect(fontLinks()).toHaveLength(before);
+ consoleError.mockRestore();
+ });
+
+ it('deduplicates the font when multiple providers are rendered', () => {
+ const container1 = document.createElement('div');
+ const container2 = document.createElement('div');
+ document.body.appendChild(container1);
+ document.body.appendChild(container2);
+
+ const root1 = createRoot(container1);
+ const root2 = createRoot(container2);
+
+ act(() => {
+ root1.render({null});
+ root2.render({null});
+ });
+
+ expect(hrefsFor('key-dedupe')).toHaveLength(1);
+
+ act(() => {
+ root1.unmount();
+ root2.unmount();
+ });
+ container1.remove();
+ container2.remove();
+ });
+});
+
+// The provider guard means YvFonts is normally only reached with a real key, so
+// its own defensive guard needs direct coverage — otherwise a regression there
+// would only surface as a 401 in a consumer's network tab.
+describe('YvFonts', () => {
+ it.each([
+ ['undefined', undefined],
+ ['empty', ''],
+ ['whitespace-only', ' '],
+ ])('renders nothing for an %s app key', (_label, appKey) => {
+ const before = fontLinks().length;
+
+ render();
+
+ expect(fontLinks()).toHaveLength(before);
+ });
+});
diff --git a/packages/ui/src/lib/yv-fonts.tsx b/packages/ui/src/lib/yv-fonts.tsx
new file mode 100644
index 00000000..14fe0f8c
--- /dev/null
+++ b/packages/ui/src/lib/yv-fonts.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+
+/**
+ * Untitled Serif — font_id 1 / slug 'untitled-serif'.
+ * See docs/adr/0004-adopt-untitled-serif-via-fonts-api.md.
+ */
+const UNTITLED_SERIF_FONT_ID = 1;
+
+export interface YvFontsProps {
+ /** The consumer's app key. Without one there is nothing to authorize the request. */
+ appKey?: string;
+ /** Mirrors `ApiClient`'s `config.apiHost ?? 'api.youversion.com'` so staging keeps working. */
+ apiHost?: string;
+}
+
+/**
+ * Loads the YouVersion brand serif (Untitled Serif) from the Fonts API stylesheet
+ * endpoint, which requires an app key.
+ *
+ * Sibling to `` and the SDK's first runtime-value-dependent
+ * stylesheet: the URL needs the app key, which is only known at render time, so
+ * it cannot live in the build-time-frozen `__YV_STYLES__` blob.
+ *
+ * React 19 hoists `` into `` and dedupes
+ * by `href`, so multiple providers still yield one link. `@font-face` is not
+ * subject to `@layer`, so cascade position is irrelevant here — `precedence` is
+ * only there to opt into the hoist + dedupe.
+ *
+ * Caveat: React also suspends the component that renders a `precedence` stylesheet
+ * while the sheet loads. `yv-fonts.test.tsx` verifies that a plain synchronous
+ * mount still commits its children; a mount inside a transition (a Next.js App
+ * Router client navigation, say) is not covered by that test and may hold the
+ * commit until the request settles. Errors settle too, so the worst case is added
+ * latency, not a hang.
+ */
+export function YvFonts({
+ appKey,
+ apiHost = 'api.youversion.com',
+}: YvFontsProps): React.ReactElement | null {
+ if (!appKey?.trim()) return null;
+
+ const href = `https://${apiHost}/v1/fonts/${UNTITLED_SERIF_FONT_ID}/stylesheet?app_key=${encodeURIComponent(appKey)}`;
+
+ return ;
+}
diff --git a/packages/ui/src/styles/font-tokens.test.ts b/packages/ui/src/styles/font-tokens.test.ts
new file mode 100644
index 00000000..c0546a26
--- /dev/null
+++ b/packages/ui/src/styles/font-tokens.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+/**
+ * The serif font stack is declared TWICE — once as a runtime custom property in
+ * core's `theme.css` (`--yv-font-serif`) and once as a Tailwind theme key in ui's
+ * `global.css` (`--font-serif`). They are literal duplicates, not aliases: core
+ * cannot import Tailwind, and `@theme inline` values are inlined into utilities
+ * rather than emitted as runtime custom properties, so `--font-serif` cannot
+ * reference `--yv-font-serif` without invalidating the value.
+ *
+ * Nothing else catches drift between them. jsdom never loads either sheet, so
+ * asserting against the CSS source on disk (the pattern from `verse.test.tsx`) is
+ * the only way to check a token's literal value.
+ *
+ * Resolve relative to this file so it works whether the suite runs from the ui
+ * package (the filtered command) or the repo root (turbo).
+ */
+const themeCss = readFileSync(
+ resolve(import.meta.dirname, '../../../core/src/styles/theme.css'),
+ 'utf8',
+);
+const globalCss = readFileSync(resolve(import.meta.dirname, './global.css'), 'utf8');
+
+function extractStack(css: string, property: string): string | undefined {
+ // Skip comments so a font name mentioned in prose can't be mistaken for the
+ // declaration.
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
+ const matches = Array.from(
+ withoutComments.matchAll(new RegExp(`(?:^|[;{\\s])${property}:\\s*([^;]+);`, 'g')),
+ );
+ expect(matches, `expected exactly one \`${property}\` declaration`).toHaveLength(1);
+ return matches[0]![1]!.trim();
+}
+
+describe('serif font token (core theme.css ↔ ui global.css)', () => {
+ const coreSerif = extractStack(themeCss, '--yv-font-serif');
+ const uiSerif = extractStack(globalCss, '--font-serif');
+
+ it('names Untitled Serif first in core theme.css', () => {
+ expect(coreSerif).toBe("'Untitled Serif', 'Source Serif 4', serif");
+ });
+
+ it('keeps the two duplicate declarations byte-identical', () => {
+ expect(uiSerif).toBe(coreSerif);
+ });
+
+ it('keeps Source Serif 4 in the stack as the fallback', () => {
+ // Untitled Serif is fetched at runtime from the gated Fonts API and can fail
+ // (no app key, blocked request, offline). Source Serif 4 must stay so the
+ // fallback is a serif we actually load, not the platform default.
+ expect(coreSerif).toContain("'Source Serif 4'");
+ expect(globalCss).toContain('Source+Serif+4');
+ });
+});
+
+describe('sans font token (core theme.css ↔ ui global.css)', () => {
+ // The sans stack is unchanged by the serif work. Guarded here so it can't drift
+ // alongside the serif change either.
+ it('keeps the two duplicate declarations byte-identical on Inter', () => {
+ const coreSans = extractStack(themeCss, '--yv-font-sans');
+ const uiSans = extractStack(globalCss, '--font-sans');
+ expect(coreSans).toBe("'Inter', sans-serif");
+ expect(uiSans).toBe(coreSans);
+ });
+});
diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css
index e8f51920..01568998 100644
--- a/packages/ui/src/styles/global.css
+++ b/packages/ui/src/styles/global.css
@@ -45,10 +45,13 @@ layer(yv-sdk-fonts);
@import '@youversion/platform-core/browser/styles/bible-reader.css' layer(yv-sdk-bible-reader);
@import 'tw-animate-css';
-/* Brand fonts (Aktiv Grotesk App / Untitled Serif @font-face) removed pending licensing —
- see docs/adr/0001-revert-brand-fonts-pending-licensing.md. Sans/serif now resolve to
- Inter / Source Serif 4 (loaded via the Google Fonts @import above). Brand-font
- implementation parked on branch feat/youversion-brand-fonts. */
+/* Untitled Serif has no @font-face here on purpose. Its stylesheet URL needs the
+ consumer's app key, which this file cannot know — it is frozen into __YV_STYLES__ at
+ build time. It is loaded instead by (src/lib/yv-fonts.tsx), rendered from
+ YouVersionProvider, from the gated /v1/fonts/1/stylesheet endpoint. See
+ docs/adr/0004-adopt-untitled-serif-via-fonts-api.md.
+ Sans stays Inter, and Source Serif 4 (Google Fonts @import above) stays loaded as the
+ serif fallback. */
/* #region shadcn UI theme */
@custom-variant dark (&:is([data-yv-sdk][data-yv-theme='dark'] *));
@@ -56,10 +59,15 @@ layer(yv-sdk-fonts);
@layer yv-sdk-theme {
[data-yv-sdk] {
@theme inline {
- /* Brand fonts reverted to Inter / Source Serif 4 pending licensing — see
- docs/adr/0001-revert-brand-fonts-pending-licensing.md. */
+ /* Untitled Serif is the brand serif, loaded from the gated Fonts API stylesheet
+ endpoint by — see docs/adr/0004-adopt-untitled-serif-via-fonts-api.md.
+ NOTE: the serif stack is duplicated verbatim in
+ packages/core/src/styles/theme.css (--yv-font-serif). It cannot be an alias:
+ core can't import Tailwind, and @theme inline values are inlined into utilities
+ rather than emitted as runtime custom properties. Edit both together —
+ font-tokens.test.ts guards drift. */
--font-sans: 'Inter', sans-serif;
- --font-serif: 'Source Serif 4', serif;
+ --font-serif: 'Untitled Serif', 'Source Serif 4', serif;
--color-background: var(--yv-background);
--color-foreground: var(--yv-foreground);
--color-card: var(--yv-card);