From 8b5ef3b67a5248cf49ba6f525c8ce445f5d546d2 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 28 Jul 2026 16:51:05 -0500 Subject: [PATCH 1/7] feat(ui): load Untitled Serif from the gated Fonts API (YPE-1350, YPE-1910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serif stack becomes 'Untitled Serif', 'Source Serif 4', serif in both declarations — core's --yv-font-serif and ui's @theme inline --font-serif. They are literal duplicates, not aliases, so font-tokens.test.ts now guards them against drift (the first test to assert a font token's value). Delivery is a new rendered from YouVersionProvider: a React 19 hoisted to /v1/fonts/1/stylesheet, the gated endpoint ADR-0001 named as the required consumption pattern. This is the SDK's first runtime-value- dependent stylesheet — the URL needs the app key, which __YV_STYLES__ cannot know at build time. It renders only in the normal branch; no key, no font. Source Serif 4 stays loaded as the fallback, so nothing regresses when the request is blocked or the host has no key. Reader body text still renders Source Serif 4: Verse.Html sets --yv-reader-font-family inline from the reader's own state, which shadows the token. Phase 2 changes that constant. Co-Authored-By: Claude Opus 5 --- packages/core/src/styles/theme.css | 12 +- .../ui/src/components/YouVersionProvider.tsx | 4 + packages/ui/src/lib/yv-fonts.test.tsx | 115 ++++++++++++++++++ packages/ui/src/lib/yv-fonts.tsx | 38 ++++++ packages/ui/src/styles/font-tokens.test.ts | 68 +++++++++++ packages/ui/src/styles/global.css | 26 ++-- 6 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/lib/yv-fonts.test.tsx create mode 100644 packages/ui/src/lib/yv-fonts.tsx create mode 100644 packages/ui/src/styles/font-tokens.test.ts diff --git a/packages/core/src/styles/theme.css b/packages/core/src/styles/theme.css index 8fda5aeb..14473a8b 100644 --- a/packages/core/src/styles/theme.css +++ b/packages/core/src/styles/theme.css @@ -108,11 +108,15 @@ --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/0003-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. Aktiv Grotesk App remains reverted pending + licensing (docs/adr/0001-revert-brand-fonts-pending-licensing.md), so sans stays Inter. + 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/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/lib/yv-fonts.test.tsx b/packages/ui/src/lib/yv-fonts.test.tsx new file mode 100644 index 00000000..96f8b9dd --- /dev/null +++ b/packages/ui/src/lib/yv-fonts.test.tsx @@ -0,0 +1,115 @@ +/** + * @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'; + +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 mounting the provider can't blank or hang a consumer's tree. + 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', () => { + // The missing-app-key branch renders alone — no key, no font. + vi.spyOn(console, 'error').mockImplementation(() => {}); + const before = fontLinks().length; + + render( + +
+ , + ); + + expect(fontLinks()).toHaveLength(before); + }); + + 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(); + }); +}); diff --git a/packages/ui/src/lib/yv-fonts.tsx b/packages/ui/src/lib/yv-fonts.tsx new file mode 100644 index 00000000..15b948e8 --- /dev/null +++ b/packages/ui/src/lib/yv-fonts.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +/** + * Untitled Serif — font_id 1 / slug 'untitled-serif'. + * See docs/adr/0003-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 gated Fonts API + * stylesheet endpoint. + * + * 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. + */ +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..bc4f7499 --- /dev/null +++ b/packages/ui/src/styles/font-tokens.test.ts @@ -0,0 +1,68 @@ +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)', () => { + // Aktiv Grotesk App is still reverted — see + // docs/adr/0001-revert-brand-fonts-pending-licensing.md. Guarded here so the + // sans stack 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..7a46b465 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -45,10 +45,15 @@ 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/0003-adopt-untitled-serif-via-fonts-api.md. + Aktiv Grotesk App remains reverted pending licensing — see + docs/adr/0001-revert-brand-fonts-pending-licensing.md; that implementation is parked + on branch feat/youversion-brand-fonts. 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 +61,17 @@ 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/0003-adopt-untitled-serif-via-fonts-api.md. + Aktiv Grotesk App remains reverted pending licensing + (docs/adr/0001-revert-brand-fonts-pending-licensing.md), so sans stays Inter. + 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); From 0e7e7b8dac806a8f6851ac8f868330d4d8a79bc7 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 28 Jul 2026 16:58:42 -0500 Subject: [PATCH 2/7] feat(ui): reader font picker offers "Untitled" and migrates stored preference (YPE-1350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader pins its own font: Verse.Html sets --yv-reader-font-family inline from component state, which shadows the CSS token. So the Phase 1 stack change could not reach passage body text until the constant behind that inline value changed. This is that change. - UNTITLED_SERIF_FONT is the new default; SOURCE_SERIF_FONT stays exported as @deprecated because the hydrate-time migration still needs to recognize it. - Returning readers who chose serif before this shipped stored the old stack. Without mapping it forward they would hydrate to a value matching neither picker button — no button active, and Source Serif 4 forever. The migration is deliberately narrow rather than full validation: FontFamily is an open type on purpose, so a host passing defaultFontFamily="Georgia" must still round-trip. A test covers that non-legacy values pass through untouched. - bible-card.tsx had the same inline pin hardcoded; swapped too. YPE-1910 names the Bible card explicitly. - Locale files trade sourceSerifFontName for untitledSerifFontName ("Untitled", per the ticket wording). They are SDK-private, so removal is safe. Verified in Chrome: body text renders Untitled Serif, picker shows "Untitled" active, a seeded legacy value migrates and re-persists, and Inter survives a reload unmigrated. Co-Authored-By: Claude Opus 5 --- packages/ui/src/components/bible-card.tsx | 4 +- .../src/components/bible-reader.stories.tsx | 28 +++++---- .../ui/src/components/bible-reader.test.tsx | 58 ++++++++++++++++++- packages/ui/src/components/bible-reader.tsx | 25 +++++--- packages/ui/src/i18n/locales/en.json | 2 +- packages/ui/src/i18n/locales/es.json | 2 +- packages/ui/src/i18n/locales/fr.json | 2 +- packages/ui/src/lib/verse-html-utils.ts | 8 ++- 8 files changed, 102 insertions(+), 27 deletions(-) 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({ = { }, 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/i18n/locales/en.json b/packages/ui/src/i18n/locales/en.json index aea81a82..48ab6f48 100644 --- a/packages/ui/src/i18n/locales/en.json +++ b/packages/ui/src/i18n/locales/en.json @@ -23,7 +23,7 @@ "readerSettingsHeading": "Reader Settings", "fontLabel": "Font", "interFontName": "Inter", - "sourceSerifFontName": "Source Serif", + "untitledSerifFontName": "Untitled", "searchPlaceholder": "Search", "booksHeading": "Books", "backToBibleVersionsAriaLabel": "Back to Bible versions", diff --git a/packages/ui/src/i18n/locales/es.json b/packages/ui/src/i18n/locales/es.json index 23cb96a0..080670b1 100644 --- a/packages/ui/src/i18n/locales/es.json +++ b/packages/ui/src/i18n/locales/es.json @@ -21,7 +21,7 @@ "readerSettingsHeading": "Configuración del lector", "fontLabel": "Fuente", "interFontName": "Inter", - "sourceSerifFontName": "Source Serif", + "untitledSerifFontName": "Untitled", "searchPlaceholder": "Buscar", "booksHeading": "Libros", "backToBibleVersionsAriaLabel": "Volver a versiones de la Biblia", diff --git a/packages/ui/src/i18n/locales/fr.json b/packages/ui/src/i18n/locales/fr.json index c398fcea..9722f898 100644 --- a/packages/ui/src/i18n/locales/fr.json +++ b/packages/ui/src/i18n/locales/fr.json @@ -21,7 +21,7 @@ "readerSettingsHeading": "Paramètres de lecture", "fontLabel": "Police", "interFontName": "Inter", - "sourceSerifFontName": "Source Serif", + "untitledSerifFontName": "Untitled", "searchPlaceholder": "Rechercher", "booksHeading": "Livres", "backToBibleVersionsAriaLabel": "Retour aux versions de la Bible", 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 & {}); From dcba4b262fcb0a6406ab91d966a441bfd17ece2a Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 28 Jul 2026 17:03:16 -0500 Subject: [PATCH 3/7] docs: record the Untitled Serif licensing decision in ADR-0003 (YPE-1350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0001 is Accepted and names two conditions for re-introducing brand fonts. Shipping the font without touching it would leave the repo's own documentation contradicting its code. ADR-0003 records both conditions as met, and is deliberately blunt about what the access control actually is: gated discovery plus revocable CDN URLs, not file-level authentication. ADR-0001's finding that the woff2 is publicly downloadable is unchanged and still true — what changed is the reading of the licence constraint, which is YouVersion's to interpret. Saying so keeps the two ADRs consistent; the alternative was a quiet contradiction. The sign-off is verbal, from Klim directly, relayed and confirmed by Cameron Pak on 2026-07-28. The ADR names it as verbal rather than implying a paper trail exists, and says to amend if written confirmation surfaces. Aktiv Grotesk stays reverted; ADR-0001 is superseded in part only. Also: the README's CSP section was stale — it still listed storage.googleapis.com for Aktiv Grotesk, which no longer ships. Replaced with the hosts actually used now. That section is the answer for strict-CSP consumers, since there is deliberately no opt-out prop. Changeset is minor across all three packages (core's CSS changes too) and calls out the new outbound request, the CSP entries, and the default font change. Co-Authored-By: Claude Opus 5 --- .changeset/untitled-serif-via-fonts-api.md | 15 ++ ...01-revert-brand-fonts-pending-licensing.md | 9 +- ...0003-adopt-untitled-serif-via-fonts-api.md | 186 ++++++++++++++++++ packages/ui/README.md | 11 +- 4 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 .changeset/untitled-serif-via-fonts-api.md create mode 100644 docs/adr/0003-adopt-untitled-serif-via-fonts-api.md diff --git a/.changeset/untitled-serif-via-fonts-api.md b/.changeset/untitled-serif-via-fonts-api.md new file mode 100644 index 00000000..b2526f4e --- /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 gated 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/0003-adopt-untitled-serif-via-fonts-api.md` for the licensing and 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..435a1d1e 100644 --- a/docs/adr/0001-revert-brand-fonts-pending-licensing.md +++ b/docs/adr/0001-revert-brand-fonts-pending-licensing.md @@ -4,7 +4,14 @@ Date: 2026-06-24 ## Status -Accepted +Accepted. **Superseded in part** by +[ADR-0003](0003-adopt-untitled-serif-via-fonts-api.md) (2026-07-28), for **Untitled +Serif only**: the foundry granted permission directly and the font now loads from the +gated `/v1/fonts/1/stylesheet` endpoint, so both re-introduction conditions below are +met for that face. The **Aktiv Grotesk revert stands** — no licence path has been +resolved and the sans stack remains `'Inter', sans-serif`. The finding that the woff2 +sits at a public, unauthenticated CDN URL is also unchanged; ADR-0003 records why that +is accepted rather than claiming it was fixed. ## Context diff --git a/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md b/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md new file mode 100644 index 00000000..267584dc --- /dev/null +++ b/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md @@ -0,0 +1,186 @@ +# 3. Adopt Untitled Serif via the gated 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. ADR-0001's Aktiv Grotesk revert stands unchanged. + +## Context + +ADR-0001 reverted both YouVersion brand fonts and named two conditions for +re-introducing them: + +> 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`. + +YPE-1350 (BibleReader renders Untitled Serif, served by the Fonts API) 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 those conditions +being met. + +### Condition (1) — licensing + +Met, by direct permission from the foundry. Cam's account, recorded verbatim because +it is the whole of the evidence: + +> This permission was genuinely given by the creators of this font. YouVersion pays a +> pretty penny to be able to have this, and they're totally fine with us using the +> font — as long as it's not just hosting the font file in the GitHub repo so that +> anyone can download it. It's that there is security measures put in place so that +> only authorized users can get the font. + +So the "partner" classification ADR-0001 flagged as an open legal question is moot: +Klim granted the permission directly, under a paid licence, with one operative +constraint — **the font file must be access-controlled, not freely downloadable.** + +**The sign-off is verbal.** There is no email, contract clause, or legal thread to +cite here, and this ADR does not imply one exists. It was relayed and confirmed by +**Cameron Pak on 2026-07-28**, and that is the citation. If written confirmation +surfaces later, amend this section with the reference. + +### Condition (2) — delivery mechanism + +Met by construction. No `.ttf` or `.woff2` ships in any package in this repo; the SDK +loads Untitled Serif from `GET /v1/fonts/1/stylesheet`, the exact endpoint ADR-0001 +named as the correct consumption pattern. + +### How the access control actually works + +The control is **gated discovery plus revocable CDN URLs — not file-level +authentication.** This ADR states that plainly, because ADR-0001 already recorded the +unauthenticated-CDN fact and a contradiction between the two documents would be worse +than either one being incomplete. + +| Layer | Behavior | Evidence (probed 2026-07-28) | +| --- | --- | --- | +| Repo | No font file ships | No `.ttf`/`.woff2` in any package; delivery is a URL | +| API — stylesheet | Gated | `GET /v1/fonts/1/stylesheet` with no key → `401` (`Failed to resolve API Key variable request.header.x-yvp-app-key`) | +| API — metadata | Gated | `GET /v1/fonts` and `/v1/fonts/1` require `X-YVP-App-Key` or `?app_key=` | +| CDN — the woff2 | Unauthenticated, but **revocable** | `GET https://cdn.youversion.com/fonts/untitled-serif/Untitled%20Serif.woff2` with no key → `200`, 54,480 bytes, `access-control-allow-origin: *`; identical response with a foreign `Referer`/`Origin`, and no `Vary: Referer` | + +ADR-0001's finding about that last row is unchanged and still true: switching to the +stylesheet endpoint does not make the woff2 un-downloadable. What changed is the +reading of the licence constraint, and it is YouVersion's licence relationship to +interpret. Cam's rationale, which is what this ADR records: + +- A third party cannot reach the woff2 without first passing through the gated API to + learn its URL. +- If a URL is scraped and redistributed, YouVersion can rotate the CDN path at any + time, which invalidates the leaked copy. + +Revocability is the enforcement mechanism, not per-request auth on the file. That is a +defensible reading of "security measures so only authorized users can get the font" — +but it is a reading, not a technical guarantee, and this ADR is the place that says so. + +**Known gap in the discovery half of the argument, outside this repo's control:** the +public docs page at [developers.youversion.com/api/fonts](https://developers.youversion.com/api/fonts) +publishes the 400-normal CDN URL verbatim in its example response, readable with no +auth. That undercuts "you cannot learn the URL without a key," and rotating the CDN +path would also require updating that example. Routed to whoever owns the API docs; not +a blocker for this change and not fixable from this codebase. + +### Aktiv Grotesk + +Out of scope and unchanged. ADR-0001's Dalton Maag finding stands, no licence path has +been resolved, and the sans stack stays `'Inter', sans-serif`. This is a serif-only +change. The parked implementation on `feat/youversion-brand-fonts` remains parked. + +## Decision + +Adopt Untitled Serif as the SDK's serif face, loaded by the SDK itself from the gated +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. Cam confirmed the permission is understood as +SDK-wide, not literally "Bible Reader only." + +**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`, as ADR-0001 recorded) 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 legal match." +- **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, because `font-display: swap` paints the fallback immediately. +- **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 is designed to accept it + on this route, but it will now also land in CDN/proxy access logs and `Referer` + headers. Accepted knowingly. +- 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 woff2 remains publicly downloadable.** This change does not fix that and does + not claim to. If the licence position ever changes, the revert 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/ui/README.md b/packages/ui/README.md index 5cbb732f..fdca537e 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` and the fonts use `font-display: swap`. If you load Untitled Serif yourself, your copy is used; the stack names it first regardless of who fetched it. ## Theming From 1595a9231d7e524e7bbd6521c3a7ec83afab86b5 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Wed, 29 Jul 2026 09:52:45 -0500 Subject: [PATCH 4/7] docs: keep licensing detail out of the public ADRs; scope the suspense claim This repo is public and both ADRs are published with it. Strip the licensing narrative from ADR-0001 and ADR-0003 down to what the code needs to know: which face is cleared, and that it must be served by the Fonts API rather than shipped in the repo. Licensing specifics are tracked internally. Also drop ADR-0003's CDN probe table, which published an unauthenticated font URL and a compliance argument that don't belong in a public repo. Code changes: - yv-fonts.tsx / yv-fonts.test.tsx: the comments asserted React "only suspends for stylesheets inserted during a transition". React's docs say the component rendering a `precedence` stylesheet suspends while it loads, without scoping that to transitions. The test proves the synchronous path only, so say that and flag the transition path as uncovered. - yv-fonts.test.tsx: the missing-key test goes through the provider's MissingAppKey guard, so YvFonts' own guard was never executed. Cover it directly for undefined / empty / whitespace keys, and restore the console spy it left installed. - README: drop the `font-display: swap` claim. That property comes from a stylesheet the Fonts API authors, not this SDK, so we can't promise it. Co-Authored-By: Claude Opus 5 --- .changeset/untitled-serif-via-fonts-api.md | 4 +- ...01-revert-brand-fonts-pending-licensing.md | 71 ++++---- ...0003-adopt-untitled-serif-via-fonts-api.md | 154 ++++++------------ packages/ui/README.md | 2 +- packages/ui/src/lib/yv-fonts.test.tsx | 29 +++- packages/ui/src/lib/yv-fonts.tsx | 11 +- 6 files changed, 117 insertions(+), 154 deletions(-) diff --git a/.changeset/untitled-serif-via-fonts-api.md b/.changeset/untitled-serif-via-fonts-api.md index b2526f4e..2642a0ed 100644 --- a/.changeset/untitled-serif-via-fonts-api.md +++ b/.changeset/untitled-serif-via-fonts-api.md @@ -4,7 +4,7 @@ '@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 gated Fonts API (YPE-1350, YPE-1910). +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. @@ -12,4 +12,4 @@ Swap the SDK's serif face from Source Serif 4 to **Untitled Serif**, YouVersion' - **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/0003-adopt-untitled-serif-via-fonts-api.md` for the licensing and delivery rationale. +See `docs/adr/0003-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 435a1d1e..8c22a9db 100644 --- a/docs/adr/0001-revert-brand-fonts-pending-licensing.md +++ b/docs/adr/0001-revert-brand-fonts-pending-licensing.md @@ -6,42 +6,31 @@ Date: 2026-06-24 Accepted. **Superseded in part** by [ADR-0003](0003-adopt-untitled-serif-via-fonts-api.md) (2026-07-28), for **Untitled -Serif only**: the foundry granted permission directly and the font now loads from the -gated `/v1/fonts/1/stylesheet` endpoint, so both re-introduction conditions below are -met for that face. The **Aktiv Grotesk revert stands** — no licence path has been -resolved and the sans stack remains `'Inter', sans-serif`. The finding that the woff2 -sits at a public, unauthenticated CDN URL is also unchanged; ADR-0003 records why that -is accepted rather than claiming it was fixed. +Serif only**: licensing cleared for that face and it now loads from the +`/v1/fonts/1/stylesheet` endpoint, so both re-introduction conditions below are met for +it. The **Aktiv Grotesk revert stands** — no licence path has been resolved and the sans +stack remains `'Inter', sans-serif`. ## Context The SDK had begun shipping YouVersion brand fonts to consumer apps: -- **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. - -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. +- **Aktiv Grotesk App** as the sans default (`--yv-font-sans`), loaded via a hardcoded + `@font-face` pointing at a CDN woff2. +- **Untitled Serif** as the serif default (`--yv-font-serif`) and the Bible Version + picker abbreviation tile, same hardcoded `@font-face` pattern. + +Neither font had licensing cleared for that delivery pattern at the time. The SDK's +purpose is to render inside **third-party developer apps**, so a hardcoded `@font-face` +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. + +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 is cleared to +serve. + +Licensing specifics for each font are tracked internally, not in this repo. ## Decision @@ -51,24 +40,22 @@ Revert **both** brand fonts to the prior fallbacks for the shipping PR: - `--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 -names are retained — only the font **family** is reverted. +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 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 brand font files to third parties. +- The abbreviation tile and serif body text render in **Source Serif 4** (the serif + fallback) rather than Untitled 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 +- Re-introducing a brand font requires: (1) licensing cleared for that face, and + (2) loading via the `/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`. diff --git a/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md b/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md index 267584dc..ef4ba2db 100644 --- a/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md +++ b/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md @@ -1,4 +1,4 @@ -# 3. Adopt Untitled Serif via the gated Fonts API stylesheet endpoint +# 3. Adopt Untitled Serif via the Fonts API stylesheet endpoint Date: 2026-07-28 @@ -9,89 +9,31 @@ part** — for Untitled Serif only. ADR-0001's Aktiv Grotesk revert stands uncha ## Context -ADR-0001 reverted both YouVersion brand fonts and named two conditions for -re-introducing them: +ADR-0001 reverted both YouVersion brand fonts and set two conditions for bringing one +back: cleared licensing, and loading through the `/v1/fonts/{font_id}/stylesheet` +endpoint rather than a hardcoded `@font-face`. -> 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 now meets both: -YPE-1350 (BibleReader renders Untitled Serif, served by the Fonts API) 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 those conditions -being met. +- **Licensing is cleared for Untitled Serif**, on the condition that the SDK does not + ship or host the font file itself — it must be served by the Fonts API, which requires + an app key. Licensing details are tracked internally; this ADR records only what that + means for the code. +- **The endpoint exists and works.** `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. -### Condition (1) — licensing +Aktiv Grotesk is unchanged — no licence path, so the sans stack stays +`'Inter', sans-serif`. This is a serif-only change, and the parked implementation on +`feat/youversion-brand-fonts` remains parked. -Met, by direct permission from the foundry. Cam's account, recorded verbatim because -it is the whole of the evidence: - -> This permission was genuinely given by the creators of this font. YouVersion pays a -> pretty penny to be able to have this, and they're totally fine with us using the -> font — as long as it's not just hosting the font file in the GitHub repo so that -> anyone can download it. It's that there is security measures put in place so that -> only authorized users can get the font. - -So the "partner" classification ADR-0001 flagged as an open legal question is moot: -Klim granted the permission directly, under a paid licence, with one operative -constraint — **the font file must be access-controlled, not freely downloadable.** - -**The sign-off is verbal.** There is no email, contract clause, or legal thread to -cite here, and this ADR does not imply one exists. It was relayed and confirmed by -**Cameron Pak on 2026-07-28**, and that is the citation. If written confirmation -surfaces later, amend this section with the reference. - -### Condition (2) — delivery mechanism - -Met by construction. No `.ttf` or `.woff2` ships in any package in this repo; the SDK -loads Untitled Serif from `GET /v1/fonts/1/stylesheet`, the exact endpoint ADR-0001 -named as the correct consumption pattern. - -### How the access control actually works - -The control is **gated discovery plus revocable CDN URLs — not file-level -authentication.** This ADR states that plainly, because ADR-0001 already recorded the -unauthenticated-CDN fact and a contradiction between the two documents would be worse -than either one being incomplete. - -| Layer | Behavior | Evidence (probed 2026-07-28) | -| --- | --- | --- | -| Repo | No font file ships | No `.ttf`/`.woff2` in any package; delivery is a URL | -| API — stylesheet | Gated | `GET /v1/fonts/1/stylesheet` with no key → `401` (`Failed to resolve API Key variable request.header.x-yvp-app-key`) | -| API — metadata | Gated | `GET /v1/fonts` and `/v1/fonts/1` require `X-YVP-App-Key` or `?app_key=` | -| CDN — the woff2 | Unauthenticated, but **revocable** | `GET https://cdn.youversion.com/fonts/untitled-serif/Untitled%20Serif.woff2` with no key → `200`, 54,480 bytes, `access-control-allow-origin: *`; identical response with a foreign `Referer`/`Origin`, and no `Vary: Referer` | - -ADR-0001's finding about that last row is unchanged and still true: switching to the -stylesheet endpoint does not make the woff2 un-downloadable. What changed is the -reading of the licence constraint, and it is YouVersion's licence relationship to -interpret. Cam's rationale, which is what this ADR records: - -- A third party cannot reach the woff2 without first passing through the gated API to - learn its URL. -- If a URL is scraped and redistributed, YouVersion can rotate the CDN path at any - time, which invalidates the leaked copy. - -Revocability is the enforcement mechanism, not per-request auth on the file. That is a -defensible reading of "security measures so only authorized users can get the font" — -but it is a reading, not a technical guarantee, and this ADR is the place that says so. - -**Known gap in the discovery half of the argument, outside this repo's control:** the -public docs page at [developers.youversion.com/api/fonts](https://developers.youversion.com/api/fonts) -publishes the 400-normal CDN URL verbatim in its example response, readable with no -auth. That undercuts "you cannot learn the URL without a key," and rotating the CDN -path would also require updating that example. Routed to whoever owns the API docs; not -a blocker for this change and not fixable from this codebase. - -### Aktiv Grotesk - -Out of scope and unchanged. ADR-0001's Dalton Maag finding stands, no licence path has -been resolved, and the sans stack stays `'Inter', sans-serif`. This is a serif-only -change. The parked implementation on `feat/youversion-brand-fonts` remains parked. +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 gated +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 @@ -103,15 +45,14 @@ stylesheet endpoint. 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 +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. Cam confirmed the permission is understood as -SDK-wide, not literally "Bible Reader only." +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 @@ -134,18 +75,18 @@ 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`, as ADR-0001 recorded) 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. +`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 +**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 @@ -162,25 +103,30 @@ non-breaking if the need turns out to be real. - 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 legal match." -- **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, because `font-display: swap` paints the fallback immediately. + 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 is designed to accept it - on this route, but it will now also land in CDN/proxy access logs and `Referer` - headers. Accepted knowingly. + 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 woff2 remains publicly downloadable.** This change does not fix that and does - not claim to. If the licence position ever changes, the revert 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. +- 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/ui/README.md b/packages/ui/README.md index fdca537e..39af9d7e 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -66,7 +66,7 @@ style-src https://fonts.googleapis.com https://api.youversion.com; - `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` and the fonts use `font-display: swap`. If you load Untitled Serif yourself, your copy is used; the stack names it first regardless of who fetched it. +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/lib/yv-fonts.test.tsx b/packages/ui/src/lib/yv-fonts.test.tsx index 96f8b9dd..d63ec87e 100644 --- a/packages/ui/src/lib/yv-fonts.test.tsx +++ b/packages/ui/src/lib/yv-fonts.test.tsx @@ -7,6 +7,7 @@ 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 }) { @@ -47,7 +48,9 @@ describe('Font stylesheet injection via YouVersionProvider', () => { // React can suspend a commit on until the // sheet loads, and jsdom never fires `load` for it. Assert children still - // commit, so mounting the provider can't blank or hang a consumer's tree. + // 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'); }); @@ -76,8 +79,10 @@ describe('Font stylesheet injection via YouVersionProvider', () => { }); it('renders no font when the app key is missing', () => { - // The missing-app-key branch renders alone — no key, no font. - vi.spyOn(console, 'error').mockImplementation(() => {}); + // 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( @@ -87,6 +92,7 @@ describe('Font stylesheet injection via YouVersionProvider', () => { ); expect(fontLinks()).toHaveLength(before); + consoleError.mockRestore(); }); it('deduplicates the font when multiple providers are rendered', () => { @@ -113,3 +119,20 @@ describe('Font stylesheet injection via YouVersionProvider', () => { 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 index 15b948e8..705337a7 100644 --- a/packages/ui/src/lib/yv-fonts.tsx +++ b/packages/ui/src/lib/yv-fonts.tsx @@ -14,8 +14,8 @@ export interface YvFontsProps { } /** - * Loads the YouVersion brand serif (Untitled Serif) from the gated Fonts API - * stylesheet endpoint. + * 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 @@ -25,6 +25,13 @@ export interface YvFontsProps { * 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, From d3cdb58173e48829125fc2558ea2ab4b045d0cc4 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Wed, 29 Jul 2026 10:20:31 -0500 Subject: [PATCH 5/7] docs: drop Aktiv Grotesk and the licensing framing from the public docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on PR #304. Aktiv Grotesk was never published — it landed in a changeset and was reverted before release — so naming it in the ADRs and style comments documents a decision about a font we do not use and never shipped. Untitled Serif is cleared, so anything implying otherwise is wrong too. - ADR-0001: retitle without "pending licensing", drop the Aktiv Grotesk bullet and the licensing narrative. What remains is the decision the code actually depends on: a hardcoded @font-face is the wrong delivery pattern for an SDK that renders inside third-party apps, so brand fonts load from /v1/fonts/{font_id}/stylesheet. Reduced from two re-introduction conditions to that one. - ADR-0003: drop the Aktiv Grotesk mentions and the "licensing is cleared" paragraph; state the sans stack is simply unchanged. - theme.css / global.css / font-tokens.test.ts: same, in the comments. - bible-chapter-picker.test.tsx: two test names still said "Aktiv" while asserting only size and weight. Renamed to what they check. Filename kept as 0001-revert-brand-fonts-pending-licensing.md so the three published CHANGELOG links keep resolving; those changelogs are release history and are left as-is. Co-Authored-By: Claude Opus 5 --- ...01-revert-brand-fonts-pending-licensing.md | 57 +++++++------------ ...0003-adopt-untitled-serif-via-fonts-api.md | 25 +++----- packages/core/src/styles/theme.css | 3 +- .../components/bible-chapter-picker.test.tsx | 4 +- packages/ui/src/styles/font-tokens.test.ts | 5 +- packages/ui/src/styles/global.css | 8 +-- 6 files changed, 38 insertions(+), 64 deletions(-) diff --git a/docs/adr/0001-revert-brand-fonts-pending-licensing.md b/docs/adr/0001-revert-brand-fonts-pending-licensing.md index 8c22a9db..4d21bd66 100644 --- a/docs/adr/0001-revert-brand-fonts-pending-licensing.md +++ b/docs/adr/0001-revert-brand-fonts-pending-licensing.md @@ -1,4 +1,4 @@ -# 1. Revert brand fonts to Inter / Source Serif 4 pending licensing +# 1. Revert brand fonts to Inter / Source Serif 4 Date: 2026-06-24 @@ -6,56 +6,43 @@ Date: 2026-06-24 Accepted. **Superseded in part** by [ADR-0003](0003-adopt-untitled-serif-via-fonts-api.md) (2026-07-28), for **Untitled -Serif only**: licensing cleared for that face and it now loads from the -`/v1/fonts/1/stylesheet` endpoint, so both re-introduction conditions below are met for -it. The **Aktiv Grotesk revert stands** — no licence path has been resolved and the sans -stack remains `'Inter', sans-serif`. +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** as the sans default (`--yv-font-sans`), loaded via a hardcoded - `@font-face` pointing at a CDN woff2. -- **Untitled Serif** as the serif default (`--yv-font-serif`) and the Bible Version - picker abbreviation tile, same hardcoded `@font-face` pattern. - -Neither font had licensing cleared for that delivery pattern at the time. The SDK's -purpose is to render inside **third-party developer apps**, so a hardcoded `@font-face` -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. +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. 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 is cleared to -serve. - -Licensing specifics for each font are tracked internally, not in this repo. +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 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. +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. ## Consequences -- The SDK ships no brand font files to third parties. +- 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 Untitled Serif — the closest available substitute for the Figma + fallback) rather than the brand serif — the closest available substitute for the Figma serif intent; exact brand match is deferred. -- Re-introducing a brand font requires: (1) licensing cleared for that face, and - (2) loading via the `/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/0003-adopt-untitled-serif-via-fonts-api.md b/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md index ef4ba2db..e7facf09 100644 --- a/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md +++ b/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md @@ -5,27 +5,20 @@ Date: 2026-07-28 ## Status Accepted. Supersedes [ADR-0001](0001-revert-brand-fonts-pending-licensing.md) **in -part** — for Untitled Serif only. ADR-0001's Aktiv Grotesk revert stands unchanged. +part** — for Untitled Serif only. ## Context -ADR-0001 reverted both YouVersion brand fonts and set two conditions for bringing one -back: cleared licensing, and loading through the `/v1/fonts/{font_id}/stylesheet` -endpoint rather than a hardcoded `@font-face`. +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 now meets both: +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. -- **Licensing is cleared for Untitled Serif**, on the condition that the SDK does not - ship or host the font file itself — it must be served by the Fonts API, which requires - an app key. Licensing details are tracked internally; this ADR records only what that - means for the code. -- **The endpoint exists and works.** `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. - -Aktiv Grotesk is unchanged — no licence path, so the sans stack stays -`'Inter', sans-serif`. This is a serif-only change, and the parked implementation on -`feat/youversion-brand-fonts` remains parked. +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 diff --git a/packages/core/src/styles/theme.css b/packages/core/src/styles/theme.css index 14473a8b..c285fb28 100644 --- a/packages/core/src/styles/theme.css +++ b/packages/core/src/styles/theme.css @@ -111,8 +111,7 @@ /* Untitled Serif is the brand serif, loaded from the gated Fonts API stylesheet endpoint — see docs/adr/0003-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. Aktiv Grotesk App remains reverted pending - licensing (docs/adr/0001-revert-brand-fonts-pending-licensing.md), so sans stays Inter. + 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; diff --git a/packages/ui/src/components/bible-chapter-picker.test.tsx b/packages/ui/src/components/bible-chapter-picker.test.tsx index 269581d1..7197164e 100644 --- a/packages/ui/src/components/bible-chapter-picker.test.tsx +++ b/packages/ui/src/components/bible-chapter-picker.test.tsx @@ -250,7 +250,7 @@ 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'); @@ -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/styles/font-tokens.test.ts b/packages/ui/src/styles/font-tokens.test.ts index bc4f7499..c0546a26 100644 --- a/packages/ui/src/styles/font-tokens.test.ts +++ b/packages/ui/src/styles/font-tokens.test.ts @@ -56,9 +56,8 @@ describe('serif font token (core theme.css ↔ ui global.css)', () => { }); describe('sans font token (core theme.css ↔ ui global.css)', () => { - // Aktiv Grotesk App is still reverted — see - // docs/adr/0001-revert-brand-fonts-pending-licensing.md. Guarded here so the - // sans stack can't drift alongside the serif change either. + // 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'); diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index 7a46b465..57751a89 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -50,10 +50,8 @@ layer(yv-sdk-fonts); 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/0003-adopt-untitled-serif-via-fonts-api.md. - Aktiv Grotesk App remains reverted pending licensing — see - docs/adr/0001-revert-brand-fonts-pending-licensing.md; that implementation is parked - on branch feat/youversion-brand-fonts. Sans stays Inter, and Source Serif 4 (Google - Fonts @import above) stays loaded as the serif fallback. */ + 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'] *)); @@ -63,8 +61,6 @@ layer(yv-sdk-fonts); @theme inline { /* Untitled Serif is the brand serif, loaded from the gated Fonts API stylesheet endpoint by — see docs/adr/0003-adopt-untitled-serif-via-fonts-api.md. - Aktiv Grotesk App remains reverted pending licensing - (docs/adr/0001-revert-brand-fonts-pending-licensing.md), so sans stays Inter. 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 From 770fabdc318d1e6e02123c670bf76a154b0e9c84 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Thu, 30 Jul 2026 10:40:58 -0500 Subject: [PATCH 6/7] docs: renumber Untitled Serif ADR 0003 -> 0004 ADR 0003 was taken by the locale-ownership-gate ADR merged in #308. Renames the file and heading, updates cross-references in ADR-0001, the changeset, theme.css, global.css, and yv-fonts.tsx. Co-Authored-By: Claude Opus 4.8 --- .changeset/untitled-serif-via-fonts-api.md | 2 +- docs/adr/0001-revert-brand-fonts-pending-licensing.md | 2 +- ...onts-api.md => 0004-adopt-untitled-serif-via-fonts-api.md} | 2 +- packages/core/src/styles/theme.css | 2 +- packages/ui/src/lib/yv-fonts.tsx | 2 +- packages/ui/src/styles/global.css | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) rename docs/adr/{0003-adopt-untitled-serif-via-fonts-api.md => 0004-adopt-untitled-serif-via-fonts-api.md} (99%) diff --git a/.changeset/untitled-serif-via-fonts-api.md b/.changeset/untitled-serif-via-fonts-api.md index 2642a0ed..786c52f1 100644 --- a/.changeset/untitled-serif-via-fonts-api.md +++ b/.changeset/untitled-serif-via-fonts-api.md @@ -12,4 +12,4 @@ Swap the SDK's serif face from Source Serif 4 to **Untitled Serif**, YouVersion' - **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/0003-adopt-untitled-serif-via-fonts-api.md` for the delivery rationale. +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 4d21bd66..ded94c62 100644 --- a/docs/adr/0001-revert-brand-fonts-pending-licensing.md +++ b/docs/adr/0001-revert-brand-fonts-pending-licensing.md @@ -5,7 +5,7 @@ Date: 2026-06-24 ## Status Accepted. **Superseded in part** by -[ADR-0003](0003-adopt-untitled-serif-via-fonts-api.md) (2026-07-28), for **Untitled +[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`. diff --git a/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md b/docs/adr/0004-adopt-untitled-serif-via-fonts-api.md similarity index 99% rename from docs/adr/0003-adopt-untitled-serif-via-fonts-api.md rename to docs/adr/0004-adopt-untitled-serif-via-fonts-api.md index e7facf09..a5dde3ee 100644 --- a/docs/adr/0003-adopt-untitled-serif-via-fonts-api.md +++ b/docs/adr/0004-adopt-untitled-serif-via-fonts-api.md @@ -1,4 +1,4 @@ -# 3. Adopt Untitled Serif via the Fonts API stylesheet endpoint +# 4. Adopt Untitled Serif via the Fonts API stylesheet endpoint Date: 2026-07-28 diff --git a/packages/core/src/styles/theme.css b/packages/core/src/styles/theme.css index c285fb28..4cc241bf 100644 --- a/packages/core/src/styles/theme.css +++ b/packages/core/src/styles/theme.css @@ -109,7 +109,7 @@ --yv-sidebar-ring: var(--yv-blue-30); /* Untitled Serif is the brand serif, loaded from the gated Fonts API stylesheet - endpoint — see docs/adr/0003-adopt-untitled-serif-via-fonts-api.md. Source Serif 4 + 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 diff --git a/packages/ui/src/lib/yv-fonts.tsx b/packages/ui/src/lib/yv-fonts.tsx index 705337a7..14fe0f8c 100644 --- a/packages/ui/src/lib/yv-fonts.tsx +++ b/packages/ui/src/lib/yv-fonts.tsx @@ -2,7 +2,7 @@ import React from 'react'; /** * Untitled Serif — font_id 1 / slug 'untitled-serif'. - * See docs/adr/0003-adopt-untitled-serif-via-fonts-api.md. + * See docs/adr/0004-adopt-untitled-serif-via-fonts-api.md. */ const UNTITLED_SERIF_FONT_ID = 1; diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index 57751a89..01568998 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -49,7 +49,7 @@ layer(yv-sdk-fonts); 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/0003-adopt-untitled-serif-via-fonts-api.md. + 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. */ @@ -60,7 +60,7 @@ layer(yv-sdk-fonts); [data-yv-sdk] { @theme inline { /* Untitled Serif is the brand serif, loaded from the gated Fonts API stylesheet - endpoint by — see docs/adr/0003-adopt-untitled-serif-via-fonts-api.md. + 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 From a374c35be2a4d1dcb9a5b9ef13523373ed0732d4 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Thu, 30 Jul 2026 10:41:44 -0500 Subject: [PATCH 7/7] chore(ui): drop locale edits; upstream untitledSerifFontName landed via #306 The rename now lives in platform-localization (merged to main in #306), so the feature PR no longer edits the upstream-owned locale files. The picker resolves t('untitledSerifFontName') from main's bundle. Co-Authored-By: Claude Opus 4.8 --- packages/ui/src/i18n/locales/en.json | 2 +- packages/ui/src/i18n/locales/es.json | 2 +- packages/ui/src/i18n/locales/fr.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/i18n/locales/en.json b/packages/ui/src/i18n/locales/en.json index 48ab6f48..aea81a82 100644 --- a/packages/ui/src/i18n/locales/en.json +++ b/packages/ui/src/i18n/locales/en.json @@ -23,7 +23,7 @@ "readerSettingsHeading": "Reader Settings", "fontLabel": "Font", "interFontName": "Inter", - "untitledSerifFontName": "Untitled", + "sourceSerifFontName": "Source Serif", "searchPlaceholder": "Search", "booksHeading": "Books", "backToBibleVersionsAriaLabel": "Back to Bible versions", diff --git a/packages/ui/src/i18n/locales/es.json b/packages/ui/src/i18n/locales/es.json index 080670b1..23cb96a0 100644 --- a/packages/ui/src/i18n/locales/es.json +++ b/packages/ui/src/i18n/locales/es.json @@ -21,7 +21,7 @@ "readerSettingsHeading": "Configuración del lector", "fontLabel": "Fuente", "interFontName": "Inter", - "untitledSerifFontName": "Untitled", + "sourceSerifFontName": "Source Serif", "searchPlaceholder": "Buscar", "booksHeading": "Libros", "backToBibleVersionsAriaLabel": "Volver a versiones de la Biblia", diff --git a/packages/ui/src/i18n/locales/fr.json b/packages/ui/src/i18n/locales/fr.json index 9722f898..c398fcea 100644 --- a/packages/ui/src/i18n/locales/fr.json +++ b/packages/ui/src/i18n/locales/fr.json @@ -21,7 +21,7 @@ "readerSettingsHeading": "Paramètres de lecture", "fontLabel": "Police", "interFontName": "Inter", - "untitledSerifFontName": "Untitled", + "sourceSerifFontName": "Source Serif", "searchPlaceholder": "Rechercher", "booksHeading": "Livres", "backToBibleVersionsAriaLabel": "Retour aux versions de la Bible",