From 69560eb20db6324c25b6033e38a37aec8e752417 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 6 May 2026 12:36:12 -0500 Subject: [PATCH 01/11] feat(ui): abstract Bible reader theme settings for RN Export BibleThemeSettingsContent, add Toolbar onOpenBibleThemeSettings override with semantic font actions, and add unit tests. Co-authored-by: Cursor --- .../ui/src/components/bible-reader.test.tsx | 204 ++++++++++++ packages/ui/src/components/bible-reader.tsx | 297 ++++++++++++------ packages/ui/src/components/index.ts | 10 +- 3 files changed, 416 insertions(+), 95 deletions(-) create mode 100644 packages/ui/src/components/bible-reader.test.tsx diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx new file mode 100644 index 00000000..caa0989c --- /dev/null +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -0,0 +1,204 @@ +/** + * @vitest-environment jsdom + */ +// We stub ResizeObserver for jsdom (used by Radix/@floating-ui). The stub methods are intentionally no-ops. +/* eslint-disable @typescript-eslint/no-empty-function */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { BibleBook, BibleVersion, Language } from '@youversion/platform-core'; +import { + useBooks, + useFilteredVersions, + useLanguage, + useLanguages, + useTheme, + useVersion, + useVersions, +} from '@youversion/platform-react-hooks'; +import { BibleReader, type BibleThemeSettingsData } from './bible-reader'; +import { INTER_FONT, SOURCE_SERIF_FONT } from '@/lib/verse-html-utils'; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + +vi.mock('@youversion/platform-react-hooks', async () => { + const actual = await vi.importActual('@youversion/platform-react-hooks'); + return { + ...actual, + useBooks: vi.fn(), + useFilteredVersions: vi.fn(), + useLanguage: vi.fn(), + useLanguages: vi.fn(), + useTheme: vi.fn(), + useVersion: vi.fn(), + useVersions: vi.fn(), + }; +}); + +const mockBooks: BibleBook[] = [ + { + id: 'JHN', + title: 'John', + full_title: 'The Gospel According to John', + canon: 'new_testament', + abbreviation: 'John', + chapters: [ + { id: '1', title: '1', passage_id: 'JHN.1' }, + { id: '2', title: '2', passage_id: 'JHN.2' }, + ], + }, +]; + +const mockVersion = { + id: 3034, + localized_abbreviation: 'BSB', + abbreviation: 'BSB', + title: 'Berean Standard Bible', + language_tag: 'en', +} as BibleVersion; + +function setupDefaultMocks() { + vi.mocked(useTheme).mockReturnValue('light'); + vi.mocked(useBooks).mockReturnValue({ + books: { data: [...mockBooks], next_page_token: null }, + loading: false, + error: null, + refetch: vi.fn(), + }); + vi.mocked(useVersion).mockReturnValue({ + version: mockVersion, + loading: false, + error: null, + refetch: vi.fn(), + }); + vi.mocked(useLanguages).mockReturnValue({ + languages: { data: [] as Language[], next_page_token: null }, + loading: false, + error: null, + refetch: vi.fn(), + }); + vi.mocked(useLanguage).mockReturnValue({ + language: { id: 'en', language: 'English', display_names: { en: 'English' } } as Language, + loading: false, + error: null, + refetch: vi.fn(), + }); + vi.mocked(useVersions).mockReturnValue({ + versions: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: vi.fn(), + }); + vi.mocked(useFilteredVersions).mockReturnValue([]); +} + +describe('BibleReader theme settings', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + setupDefaultMocks(); + }); + + it('opens the default Reader Settings popover and updates font settings', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + + await user.click(screen.getByTestId('increase-font-size')); + await waitFor(() => { + expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18'); + }); + + await user.click(screen.getByRole('button', { name: /inter/i })); + await waitFor(() => { + expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT); + }); + }); + + it('calls onOpenBibleThemeSettings with current settings/actions and skips popover content', async () => { + const user = userEvent.setup(); + const onOpenBibleThemeSettings = vi.fn(); + + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(onOpenBibleThemeSettings).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Reader Settings')).not.toBeInTheDocument(); + + const data = onOpenBibleThemeSettings.mock.calls[0]![0] as BibleThemeSettingsData; + expect(data).toMatchObject({ + fontSize: 18, + fontFamily: INTER_FONT, + minFontSize: 12, + maxFontSize: 20, + }); + expect(data.onFontIncreased).toEqual(expect.any(Function)); + expect(data.onFontDecreased).toEqual(expect.any(Function)); + expect(data.onFontSelected).toEqual(expect.any(Function)); + }); + + it('returns next settings when override actions update SDK-owned state', async () => { + const user = userEvent.setup(); + const onOpenBibleThemeSettings = vi.fn(); + + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + const data = onOpenBibleThemeSettings.mock.calls[0]![0] as BibleThemeSettingsData; + + expect(data.onFontIncreased()).toEqual({ + fontSize: 18, + fontFamily: SOURCE_SERIF_FONT, + }); + expect(data.onFontIncreased()).toEqual({ + fontSize: 20, + fontFamily: SOURCE_SERIF_FONT, + }); + expect(data.onFontIncreased()).toEqual({ + fontSize: 20, + fontFamily: SOURCE_SERIF_FONT, + }); + expect(data.onFontDecreased()).toEqual({ + fontSize: 18, + fontFamily: SOURCE_SERIF_FONT, + }); + expect(data.onFontSelected(INTER_FONT)).toEqual({ + fontSize: 18, + fontFamily: INTER_FONT, + }); + + await waitFor(() => { + expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18'); + expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT); + }); + }); +}); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 00db3011..590f0856 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -16,7 +16,9 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, useState, + type ReactElement, } from 'react'; import { cn } from '@/lib/utils'; import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core'; @@ -84,6 +86,32 @@ export type RootProps = { const MIN_FONT_SIZE = 12; const MAX_FONT_SIZE = 20; const DEFAULT_FONT_SIZE = 16; +const FONT_SIZE_STEP = 2; + +export type BibleThemeSettingsValues = { + fontSize: number; + fontFamily: FontFamily; +}; + +export type BibleThemeSettingsData = BibleThemeSettingsValues & { + minFontSize: number; + maxFontSize: number; + onFontIncreased: () => BibleThemeSettingsValues; + onFontDecreased: () => BibleThemeSettingsValues; + onFontSelected: (fontFamily: FontFamily) => BibleThemeSettingsValues; +}; + +export type BibleThemeSettingsContentProps = { + fontSize: number; + fontFamily: FontFamily; + onFontSelected: (fontFamily: FontFamily) => void; + onFontIncreased: () => void; + onFontDecreased: () => void; +}; + +function clampFontSize(fontSize: number): number { + return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize)); +} function Root({ book: controlledBook, @@ -332,7 +360,98 @@ function UserMenu() { ); } -function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) { +export function BibleThemeSettingsContent({ + fontSize, + fontFamily, + onFontSelected, + onFontIncreased, + onFontDecreased, +}: BibleThemeSettingsContentProps): ReactElement { + return ( +
+
+ + +
+ +
+ + +
+
+ ); +} + +export type BibleReaderToolbarProps = { + border?: 'top' | 'bottom'; + onOpenBibleThemeSettings?: (data: BibleThemeSettingsData) => void; +}; + +function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolbarProps) { const { book, chapter, @@ -344,10 +463,65 @@ function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) { booksLoading, currentFontFamily, setCurrentFontFamily, + currentFontSize, setCurrentFontSize, background, } = useBibleReaderContext(); const yvContext = useContext(YouVersionContext); + const settingsValuesRef = useRef({ + fontSize: currentFontSize, + fontFamily: currentFontFamily, + }); + + settingsValuesRef.current = { + fontSize: currentFontSize, + fontFamily: currentFontFamily, + }; + + const applySettings = (settings: BibleThemeSettingsValues): BibleThemeSettingsValues => { + const nextSettings = { + fontSize: clampFontSize(settings.fontSize), + fontFamily: settings.fontFamily, + }; + + settingsValuesRef.current = nextSettings; + setCurrentFontSize(nextSettings.fontSize); + setCurrentFontFamily(nextSettings.fontFamily); + + return nextSettings; + }; + + const handleFontIncreased = (): BibleThemeSettingsValues => { + const settings = settingsValuesRef.current; + return applySettings({ + ...settings, + fontSize: settings.fontSize + FONT_SIZE_STEP, + }); + }; + + const handleFontDecreased = (): BibleThemeSettingsValues => { + const settings = settingsValuesRef.current; + return applySettings({ + ...settings, + fontSize: settings.fontSize - FONT_SIZE_STEP, + }); + }; + + const handleFontSelected = (fontFamily: FontFamily): BibleThemeSettingsValues => { + return applySettings({ + ...settingsValuesRef.current, + fontFamily, + }); + }; + + const buildBibleThemeSettingsPayload = (): BibleThemeSettingsData => ({ + ...settingsValuesRef.current, + minFontSize: MIN_FONT_SIZE, + maxFontSize: MAX_FONT_SIZE, + onFontIncreased: handleFontIncreased, + onFontDecreased: handleFontDecreased, + onFontSelected: handleFontSelected, + }); const prevResult = getAdjacentChapter(booksData, book, chapter, 'previous'); const nextResult = getAdjacentChapter(booksData, book, chapter, 'next'); @@ -475,99 +649,34 @@ function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) { - - - - - - -
-
- - -
- -
- - -
-
-
-
+ {onOpenBibleThemeSettings ? ( + + ) : ( + + + + + + + + + + )} ); diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index cdcd1491..fc9a9b87 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -6,7 +6,15 @@ export { type BibleChapterPickerContentProps, type BibleChapterPickerPressData, } from './bible-chapter-picker'; -export { BibleReader, type BibleReaderRootProps } from './bible-reader'; +export { + BibleReader, + BibleThemeSettingsContent, + type BibleReaderRootProps, + type BibleReaderToolbarProps, + type BibleThemeSettingsData, + type BibleThemeSettingsContentProps, + type BibleThemeSettingsValues, +} from './bible-reader'; export { BibleVersionPicker, type BibleVersionPickerRootProps, From cb402c5e3057a153e64434c3eb0651888f9f3565 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 6 May 2026 13:44:38 -0500 Subject: [PATCH 02/11] refactor(ui): rename applySettings to applyThemeSettings in Toolbar for clarity --- packages/ui/src/components/bible-reader.tsx | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 590f0856..0827967c 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -478,22 +478,24 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba fontFamily: currentFontFamily, }; - const applySettings = (settings: BibleThemeSettingsValues): BibleThemeSettingsValues => { - const nextSettings = { - fontSize: clampFontSize(settings.fontSize), - fontFamily: settings.fontFamily, + const applyThemeSettings = ( + themeSettings: BibleThemeSettingsValues, + ): BibleThemeSettingsValues => { + const nextThemeSettings = { + fontSize: clampFontSize(themeSettings.fontSize), + fontFamily: themeSettings.fontFamily, }; - settingsValuesRef.current = nextSettings; - setCurrentFontSize(nextSettings.fontSize); - setCurrentFontFamily(nextSettings.fontFamily); + settingsValuesRef.current = nextThemeSettings; + setCurrentFontSize(nextThemeSettings.fontSize); + setCurrentFontFamily(nextThemeSettings.fontFamily); - return nextSettings; + return nextThemeSettings; }; const handleFontIncreased = (): BibleThemeSettingsValues => { const settings = settingsValuesRef.current; - return applySettings({ + return applyThemeSettings({ ...settings, fontSize: settings.fontSize + FONT_SIZE_STEP, }); @@ -501,14 +503,14 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba const handleFontDecreased = (): BibleThemeSettingsValues => { const settings = settingsValuesRef.current; - return applySettings({ + return applyThemeSettings({ ...settings, fontSize: settings.fontSize - FONT_SIZE_STEP, }); }; const handleFontSelected = (fontFamily: FontFamily): BibleThemeSettingsValues => { - return applySettings({ + return applyThemeSettings({ ...settingsValuesRef.current, fontFamily, }); From 74d1d2f22771cccf7d54a84ed694eff73b105780 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 6 May 2026 13:45:14 -0500 Subject: [PATCH 03/11] feat(ui): expose BibleThemeSettingsContent and add onOpenBibleThemeSettings to Toolbar --- .changeset/reader-theme-settings-rn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/reader-theme-settings-rn.md diff --git a/.changeset/reader-theme-settings-rn.md b/.changeset/reader-theme-settings-rn.md new file mode 100644 index 00000000..56332f7e --- /dev/null +++ b/.changeset/reader-theme-settings-rn.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': minor +--- + +Expose `BibleThemeSettingsContent` and add optional `onOpenBibleThemeSettings` on `BibleReader.Toolbar` so hosts (e.g. React Native) can open native settings UI while applying font changes through the same semantic actions as the web reader. From bd231fb898b316f1234bf90e7ec9ab03fef2e2dd Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 6 May 2026 13:45:59 -0500 Subject: [PATCH 04/11] refactor(ui): rename settingsValuesRef to themesSettingsValuesRef in Toolbar for consistency --- packages/ui/src/components/bible-reader.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 0827967c..a99b893e 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -468,12 +468,12 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba background, } = useBibleReaderContext(); const yvContext = useContext(YouVersionContext); - const settingsValuesRef = useRef({ + const themesSettingsValuesRef = useRef({ fontSize: currentFontSize, fontFamily: currentFontFamily, }); - settingsValuesRef.current = { + themesSettingsValuesRef.current = { fontSize: currentFontSize, fontFamily: currentFontFamily, }; @@ -486,7 +486,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba fontFamily: themeSettings.fontFamily, }; - settingsValuesRef.current = nextThemeSettings; + themesSettingsValuesRef.current = nextThemeSettings; setCurrentFontSize(nextThemeSettings.fontSize); setCurrentFontFamily(nextThemeSettings.fontFamily); @@ -494,7 +494,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba }; const handleFontIncreased = (): BibleThemeSettingsValues => { - const settings = settingsValuesRef.current; + const settings = themesSettingsValuesRef.current; return applyThemeSettings({ ...settings, fontSize: settings.fontSize + FONT_SIZE_STEP, @@ -502,7 +502,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba }; const handleFontDecreased = (): BibleThemeSettingsValues => { - const settings = settingsValuesRef.current; + const settings = themesSettingsValuesRef.current; return applyThemeSettings({ ...settings, fontSize: settings.fontSize - FONT_SIZE_STEP, @@ -511,13 +511,13 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba const handleFontSelected = (fontFamily: FontFamily): BibleThemeSettingsValues => { return applyThemeSettings({ - ...settingsValuesRef.current, + ...themesSettingsValuesRef.current, fontFamily, }); }; const buildBibleThemeSettingsPayload = (): BibleThemeSettingsData => ({ - ...settingsValuesRef.current, + ...themesSettingsValuesRef.current, minFontSize: MIN_FONT_SIZE, maxFontSize: MAX_FONT_SIZE, onFontIncreased: handleFontIncreased, From 8f1caa5e6c82540fe2e31feb48a4ef5bcccc0e03 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 6 May 2026 13:52:52 -0500 Subject: [PATCH 05/11] fix(ui): add theme prop to BibleThemeSettingsContent for dynamic theming --- packages/ui/src/components/bible-reader.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index a99b893e..57cf44a8 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -102,6 +102,7 @@ export type BibleThemeSettingsData = BibleThemeSettingsValues & { }; export type BibleThemeSettingsContentProps = { + theme: 'light' | 'dark'; fontSize: number; fontFamily: FontFamily; onFontSelected: (fontFamily: FontFamily) => void; @@ -361,6 +362,7 @@ function UserMenu() { } export function BibleThemeSettingsContent({ + theme, fontSize, fontFamily, onFontSelected, @@ -368,7 +370,7 @@ export function BibleThemeSettingsContent({ onFontDecreased, }: BibleThemeSettingsContentProps): ReactElement { return ( -
+
+ + + + + ); + } - const data = onOpenBibleThemeSettings.mock.calls[0]![0] as BibleThemeSettingsData; + render(); - expect(data.onFontIncreased()).toEqual({ - fontSize: 18, - fontFamily: SOURCE_SERIF_FONT, - }); - expect(data.onFontIncreased()).toEqual({ - fontSize: 20, - fontFamily: SOURCE_SERIF_FONT, - }); - expect(data.onFontIncreased()).toEqual({ - fontSize: 20, - fontFamily: SOURCE_SERIF_FONT, - }); - expect(data.onFontDecreased()).toEqual({ - fontSize: 18, - fontFamily: SOURCE_SERIF_FONT, - }); - expect(data.onFontSelected(INTER_FONT)).toEqual({ - fontSize: 18, - fontFamily: INTER_FONT, - }); + await user.click(screen.getByRole('button', { name: 'Settings' })); + expect(onOpen).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'simulate-native-apply' })); await waitFor(() => { - expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18'); - expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT); + expect(localStorage.getItem('youversion-platform:reader:font-size')).toBeNull(); + expect(localStorage.getItem('youversion-platform:reader:font-family')).toBeNull(); }); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + const nextSnap = onOpen.mock.calls[1]![0] as BibleThemeSettingsSnapshot; + expect(nextSnap.fontSize).toBe(14); + expect(nextSnap.fontFamily).toBe(INTER_FONT); }); }); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 57cf44a8..d7bf6a7c 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -17,7 +17,6 @@ import { useLayoutEffect, useMemo, useRef, - useState, type ReactElement, } from 'react'; import { cn } from '@/lib/utils'; @@ -74,8 +73,14 @@ export type RootProps = { versionId?: number; defaultVersionId?: number; onVersionChange?: (versionId: number) => void; - fontFamily?: FontFamily; + /** Controlled font size; omit with `defaultFontSize` for uncontrolled (persists via localStorage on web). */ fontSize?: number; + defaultFontSize?: number; + onFontSizeChange?: (fontSize: number) => void; + /** Controlled font family; omit with `defaultFontFamily` for uncontrolled (persists via localStorage on web). */ + fontFamily?: FontFamily; + defaultFontFamily?: FontFamily; + onFontFamilyChange?: (fontFamily: FontFamily) => void; lineHeight?: number; showVerseNumbers?: boolean; background?: 'light' | 'dark'; @@ -83,22 +88,28 @@ export type RootProps = { children?: ReactNode; }; -const MIN_FONT_SIZE = 12; -const MAX_FONT_SIZE = 20; -const DEFAULT_FONT_SIZE = 16; -const FONT_SIZE_STEP = 2; +/** Bounds and defaults for reader typography (stable across Web and Expo DOM hosts). */ +export const BIBLE_READER_FONT = { + MIN: 12, + MAX: 20, + DEFAULT: 16, + STEP: 2, +} as const; + +const MIN_FONT_SIZE = BIBLE_READER_FONT.MIN; +const MAX_FONT_SIZE = BIBLE_READER_FONT.MAX; +const DEFAULT_FONT_SIZE = BIBLE_READER_FONT.DEFAULT; +const FONT_SIZE_STEP = BIBLE_READER_FONT.STEP; export type BibleThemeSettingsValues = { fontSize: number; fontFamily: FontFamily; }; -export type BibleThemeSettingsData = BibleThemeSettingsValues & { +/** Serializable settings state for `onOpenBibleThemeSettings` (Expo DOM / native-safe). */ +export type BibleThemeSettingsSnapshot = BibleThemeSettingsValues & { minFontSize: number; maxFontSize: number; - onFontIncreased: () => BibleThemeSettingsValues; - onFontDecreased: () => BibleThemeSettingsValues; - onFontSelected: (fontFamily: FontFamily) => BibleThemeSettingsValues; }; export type BibleThemeSettingsContentProps = { @@ -110,10 +121,51 @@ export type BibleThemeSettingsContentProps = { onFontDecreased: () => void; }; -function clampFontSize(fontSize: number): number { +export function clampBibleReaderFontSize(fontSize: number): number { return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize)); } +/** Initial / seeded font size: out-of-range values fall back to default (matches legacy reader behavior). */ +function normalizeReaderFontSizeForInitial(size: number): number { + if (size > MAX_FONT_SIZE || size < MIN_FONT_SIZE) { + return DEFAULT_FONT_SIZE; + } + return size; +} + +export function nextBibleReaderFontSizeUp(current: number): number { + return clampBibleReaderFontSize(current + FONT_SIZE_STEP); +} + +export function nextBibleReaderFontSizeDown(current: number): number { + return clampBibleReaderFontSize(current - FONT_SIZE_STEP); +} + +/** + * Builds the three handler props for {@link BibleThemeSettingsContent} from host-owned font state. + * Use this on the same side as `setFontSize` / `setFontFamily` (e.g. React Native before passing + * **top-level** props into a `BibleThemeSettingsContent` Expo DOM wrapper). Expo allows functions as + * top-level DOM props; do not nest these inside a {@link BibleThemeSettingsSnapshot}. + */ +export function createBibleThemeSettingsContentHandlers(options: { + getFontSize: () => number; + getFontFamily: () => FontFamily; + setFontSize: (size: number) => void; + setFontFamily: (fontFamily: FontFamily) => void; +}): Pick { + return { + onFontIncreased: () => { + options.setFontSize(nextBibleReaderFontSizeUp(options.getFontSize())); + }, + onFontDecreased: () => { + options.setFontSize(nextBibleReaderFontSizeDown(options.getFontSize())); + }, + onFontSelected: (fontFamily) => { + options.setFontFamily(fontFamily); + }, + }; +} + function Root({ book: controlledBook, defaultBook = 'JHN', @@ -124,8 +176,12 @@ function Root({ versionId: controlledVersionId, defaultVersionId = DEFAULT_LICENSE_FREE_BIBLE_VERSION, onVersionChange, - fontFamily = SOURCE_SERIF_FONT, - fontSize = DEFAULT_FONT_SIZE, + fontSize: fontSizeProp, + defaultFontSize = DEFAULT_FONT_SIZE, + onFontSizeChange, + fontFamily: fontFamilyProp, + defaultFontFamily = SOURCE_SERIF_FONT, + onFontFamilyChange, lineHeight, showVerseNumbers = true, background, @@ -150,36 +206,64 @@ function Root({ onChange: onVersionChange, }); - const validatedFontSize = - fontSize > MAX_FONT_SIZE || fontSize < MIN_FONT_SIZE ? DEFAULT_FONT_SIZE : fontSize; + const validatedDefaultFontSize = + defaultFontSize > MAX_FONT_SIZE || defaultFontSize < MIN_FONT_SIZE + ? DEFAULT_FONT_SIZE + : defaultFontSize; + + const isFontSizeControlled = onFontSizeChange !== undefined; + const isFontFamilyControlled = onFontFamilyChange !== undefined; + + const defaultPropFontSize = normalizeReaderFontSizeForInitial( + fontSizeProp ?? validatedDefaultFontSize, + ); + + const [currentFontSize, setCurrentFontSize] = useControllableState({ + prop: isFontSizeControlled ? fontSizeProp : undefined, + defaultProp: defaultPropFontSize, + onChange: onFontSizeChange, + }); + + const defaultPropFontFamily = fontFamilyProp ?? defaultFontFamily; - const [currentFontSize, setCurrentFontSize] = useState(validatedFontSize); - const [currentFontFamily, setCurrentFontFamily] = useState(fontFamily); + const [currentFontFamily, setCurrentFontFamily] = useControllableState({ + prop: isFontFamilyControlled ? fontFamilyProp : undefined, + defaultProp: defaultPropFontFamily, + onChange: onFontFamilyChange, + }); - // Load saved preferences from localStorage before paint (avoids flash of default values) + // Load saved preferences from localStorage before paint (uncontrolled axis only; avoids flash of defaults) useLayoutEffect(() => { - const savedFontSize = localStorage.getItem('youversion-platform:reader:font-size'); - if (savedFontSize) { - const parsed = parseInt(savedFontSize); - if (parsed >= MIN_FONT_SIZE && parsed <= MAX_FONT_SIZE) { - setCurrentFontSize(parsed); + if (!isFontSizeControlled) { + const savedFontSize = localStorage.getItem('youversion-platform:reader:font-size'); + if (savedFontSize) { + const parsed = parseInt(savedFontSize); + if (parsed >= MIN_FONT_SIZE && parsed <= MAX_FONT_SIZE) { + setCurrentFontSize(parsed); + } } } - const savedFontFamily = localStorage.getItem('youversion-platform:reader:font-family'); - if (savedFontFamily) { - setCurrentFontFamily(savedFontFamily); + if (!isFontFamilyControlled) { + const savedFontFamily = localStorage.getItem('youversion-platform:reader:font-family'); + if (savedFontFamily) { + setCurrentFontFamily(savedFontFamily); + } } - }, []); + }, [isFontFamilyControlled, isFontSizeControlled, setCurrentFontFamily, setCurrentFontSize]); - // Save preferences to localStorage when they change + // Save preferences to localStorage when they change (uncontrolled axis only) useEffect(() => { - localStorage.setItem('youversion-platform:reader:font-size', currentFontSize.toString()); - }, [currentFontSize]); + if (!isFontSizeControlled) { + localStorage.setItem('youversion-platform:reader:font-size', currentFontSize.toString()); + } + }, [currentFontSize, isFontSizeControlled]); useEffect(() => { - localStorage.setItem('youversion-platform:reader:font-family', currentFontFamily); - }, [currentFontFamily]); + if (!isFontFamilyControlled) { + localStorage.setItem('youversion-platform:reader:font-family', currentFontFamily); + } + }, [currentFontFamily, isFontFamilyControlled]); const providerTheme = useTheme(); const theme = background || providerTheme; @@ -450,7 +534,7 @@ export function BibleThemeSettingsContent({ export type BibleReaderToolbarProps = { border?: 'top' | 'bottom'; - onOpenBibleThemeSettings?: (data: BibleThemeSettingsData) => void; + onOpenBibleThemeSettings?: (snapshot: BibleThemeSettingsSnapshot) => void; }; function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolbarProps) { @@ -484,7 +568,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba themeSettings: BibleThemeSettingsValues, ): BibleThemeSettingsValues => { const nextThemeSettings = { - fontSize: clampFontSize(themeSettings.fontSize), + fontSize: clampBibleReaderFontSize(themeSettings.fontSize), fontFamily: themeSettings.fontFamily, }; @@ -499,7 +583,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba const settings = themesSettingsValuesRef.current; return applyThemeSettings({ ...settings, - fontSize: settings.fontSize + FONT_SIZE_STEP, + fontSize: nextBibleReaderFontSizeUp(settings.fontSize), }); }; @@ -507,7 +591,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba const settings = themesSettingsValuesRef.current; return applyThemeSettings({ ...settings, - fontSize: settings.fontSize - FONT_SIZE_STEP, + fontSize: nextBibleReaderFontSizeDown(settings.fontSize), }); }; @@ -518,13 +602,10 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba }); }; - const buildBibleThemeSettingsPayload = (): BibleThemeSettingsData => ({ + const buildBibleThemeSettingsSnapshot = (): BibleThemeSettingsSnapshot => ({ ...themesSettingsValuesRef.current, minFontSize: MIN_FONT_SIZE, maxFontSize: MAX_FONT_SIZE, - onFontIncreased: handleFontIncreased, - onFontDecreased: handleFontDecreased, - onFontSelected: handleFontSelected, }); const prevResult = getAdjacentChapter(booksData, book, chapter, 'previous'); @@ -560,10 +641,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba > {({ chapterLabel, currentBook, loading }) => ( -
+
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index fc9a9b87..4b8a276c 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -7,12 +7,17 @@ export { type BibleChapterPickerPressData, } from './bible-chapter-picker'; export { + BIBLE_READER_FONT, BibleReader, BibleThemeSettingsContent, + clampBibleReaderFontSize, + createBibleThemeSettingsContentHandlers, + nextBibleReaderFontSizeDown, + nextBibleReaderFontSizeUp, type BibleReaderRootProps, type BibleReaderToolbarProps, - type BibleThemeSettingsData, type BibleThemeSettingsContentProps, + type BibleThemeSettingsSnapshot, type BibleThemeSettingsValues, } from './bible-reader'; export { From ac51da5d16781174114bec85135eeae7f38db2e2 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Thu, 7 May 2026 11:26:18 -0500 Subject: [PATCH 07/11] refactor(ui): clean up BibleReader component by removing unused comments and renaming functions --- packages/ui/src/components/bible-reader.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index d7bf6a7c..369597d5 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -73,11 +73,9 @@ export type RootProps = { versionId?: number; defaultVersionId?: number; onVersionChange?: (versionId: number) => void; - /** Controlled font size; omit with `defaultFontSize` for uncontrolled (persists via localStorage on web). */ fontSize?: number; defaultFontSize?: number; onFontSizeChange?: (fontSize: number) => void; - /** Controlled font family; omit with `defaultFontFamily` for uncontrolled (persists via localStorage on web). */ fontFamily?: FontFamily; defaultFontFamily?: FontFamily; onFontFamilyChange?: (fontFamily: FontFamily) => void; @@ -88,7 +86,6 @@ export type RootProps = { children?: ReactNode; }; -/** Bounds and defaults for reader typography (stable across Web and Expo DOM hosts). */ export const BIBLE_READER_FONT = { MIN: 12, MAX: 20, @@ -106,7 +103,6 @@ export type BibleThemeSettingsValues = { fontFamily: FontFamily; }; -/** Serializable settings state for `onOpenBibleThemeSettings` (Expo DOM / native-safe). */ export type BibleThemeSettingsSnapshot = BibleThemeSettingsValues & { minFontSize: number; maxFontSize: number; @@ -125,8 +121,7 @@ export function clampBibleReaderFontSize(fontSize: number): number { return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize)); } -/** Initial / seeded font size: out-of-range values fall back to default (matches legacy reader behavior). */ -function normalizeReaderFontSizeForInitial(size: number): number { +function normalizeReaderFontSizeForInitialization(size: number): number { if (size > MAX_FONT_SIZE || size < MIN_FONT_SIZE) { return DEFAULT_FONT_SIZE; } @@ -214,7 +209,7 @@ function Root({ const isFontSizeControlled = onFontSizeChange !== undefined; const isFontFamilyControlled = onFontFamilyChange !== undefined; - const defaultPropFontSize = normalizeReaderFontSizeForInitial( + const defaultPropFontSize = normalizeReaderFontSizeForInitialization( fontSizeProp ?? validatedDefaultFontSize, ); @@ -232,7 +227,6 @@ function Root({ onChange: onFontFamilyChange, }); - // Load saved preferences from localStorage before paint (uncontrolled axis only; avoids flash of defaults) useLayoutEffect(() => { if (!isFontSizeControlled) { const savedFontSize = localStorage.getItem('youversion-platform:reader:font-size'); @@ -252,7 +246,6 @@ function Root({ } }, [isFontFamilyControlled, isFontSizeControlled, setCurrentFontFamily, setCurrentFontSize]); - // Save preferences to localStorage when they change (uncontrolled axis only) useEffect(() => { if (!isFontSizeControlled) { localStorage.setItem('youversion-platform:reader:font-size', currentFontSize.toString()); From 4bc911aadfded389493a43a9b369ec1979528c95 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Thu, 7 May 2026 11:54:06 -0500 Subject: [PATCH 08/11] fix(ui): prevent multiple theme settings hydration in BibleReader component --- packages/ui/src/components/bible-reader.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 369597d5..28eb2832 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -227,7 +227,12 @@ function Root({ onChange: onFontFamilyChange, }); + const didHydrateThemeSettingsRef = useRef(false); + useLayoutEffect(() => { + if (didHydrateThemeSettingsRef.current) return; + didHydrateThemeSettingsRef.current = true; + if (!isFontSizeControlled) { const savedFontSize = localStorage.getItem('youversion-platform:reader:font-size'); if (savedFontSize) { From 3fff2724048685e7eb4c075e731c7f1e7b8afaf2 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Thu, 7 May 2026 11:56:38 -0500 Subject: [PATCH 09/11] fix(ui): disable theme font-size controls at bounds Co-authored-by: Cursor --- packages/ui/src/components/bible-reader.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 28eb2832..d4af5025 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -460,6 +460,7 @@ export function BibleThemeSettingsContent({ size="lg" variant="secondary" data-testid="decrease-font-size" + disabled={fontSize <= MIN_FONT_SIZE} aria-disabled={fontSize <= MIN_FONT_SIZE} > A @@ -470,6 +471,7 @@ export function BibleThemeSettingsContent({ size="lg" variant="secondary" data-testid="increase-font-size" + disabled={fontSize >= MAX_FONT_SIZE} aria-disabled={fontSize >= MAX_FONT_SIZE} > A From d72888b7abf011982a2d2a8938a4b7533225ccfc Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Thu, 7 May 2026 11:57:56 -0500 Subject: [PATCH 10/11] test(ui): cover onFontDecreased handler in BibleReader Co-authored-by: Cursor --- packages/ui/src/components/bible-reader.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index cfd34629..f983ce18 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -141,6 +141,9 @@ describe('createBibleThemeSettingsContentHandlers', () => { handlers.onFontIncreased(); expect(setFontSize).toHaveBeenCalledWith(18); + handlers.onFontDecreased(); + expect(setFontSize).toHaveBeenLastCalledWith(16); + handlers.onFontSelected(INTER_FONT); expect(setFontFamily).toHaveBeenCalledWith(INTER_FONT); }); From 816a716b892bbad23a5baa85e4813ca65dd59184 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Thu, 7 May 2026 12:09:27 -0500 Subject: [PATCH 11/11] test(ui): ensure font-size controls are disabled at bounds in BibleReader stories --- packages/ui/src/components/bible-reader.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index de054da5..b8589e0c 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -114,16 +114,16 @@ export const Default: Story = { await userEvent.click(increaseFontButton); await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18'); await userEvent.click(increaseFontButton); - await userEvent.click(increaseFontButton); await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('20'); + await expect(increaseFontButton).toBeDisabled(); await userEvent.click(decreaseFontButton); await userEvent.click(decreaseFontButton); await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('16'); await userEvent.click(decreaseFontButton); await userEvent.click(decreaseFontButton); - await userEvent.click(decreaseFontButton); await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('12'); + await expect(decreaseFontButton).toBeDisabled(); const interButton = screen.getByRole('button', { name: /inter/i }); const sourceSerifButton = screen.getByRole('button', { name: /source serif/i });