From 5583cbeb140606b1bd8442c8a912131a29060f51 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 11:32:56 -0500 Subject: [PATCH 01/12] feat(ui): add USFM highlight projection helper expandPassageId parses verse and verse-range USFMs (JHN.3.16, JHN.3.16-18) into per-verse numbers, rejecting chapter-scope ids, malformed input, reversed ranges, and implausibly large ranges. deriveHighlightedVerses projects a host-supplied Highlight[] (core API shape) onto the displayed version + chapter as the reader's internal verse->color render map, ignoring entries whose identity does not match and normalizing colors to lowercase. Groundwork for BibleReader controlled highlights mode (YPE-3705). Co-Authored-By: Claude Fable 5 --- .../ui/src/lib/highlight-projection.test.ts | 125 ++++++++++++++++++ packages/ui/src/lib/highlight-projection.ts | 71 ++++++++++ 2 files changed, 196 insertions(+) create mode 100644 packages/ui/src/lib/highlight-projection.test.ts create mode 100644 packages/ui/src/lib/highlight-projection.ts diff --git a/packages/ui/src/lib/highlight-projection.test.ts b/packages/ui/src/lib/highlight-projection.test.ts new file mode 100644 index 00000000..96194cf8 --- /dev/null +++ b/packages/ui/src/lib/highlight-projection.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import type { Highlight } from '@youversion/platform-core'; +import { deriveHighlightedVerses, expandPassageId } from './highlight-projection'; + +describe('expandPassageId', () => { + it('expands a single-verse USFM', () => { + expect(expandPassageId('JHN.3.16')).toEqual({ book: 'JHN', chapter: '3', verses: [16] }); + }); + + it('expands a verse-range USFM into every verse in the range', () => { + expect(expandPassageId('JHN.3.16-18')).toEqual({ + book: 'JHN', + chapter: '3', + verses: [16, 17, 18], + }); + }); + + it('expands a single-verse range (N-N) to one verse', () => { + expect(expandPassageId('PSA.23.1-1')).toEqual({ book: 'PSA', chapter: '23', verses: [1] }); + }); + + it('handles numbered book codes', () => { + expect(expandPassageId('1CO.13.4-7')).toEqual({ + book: '1CO', + chapter: '13', + verses: [4, 5, 6, 7], + }); + }); + + it('returns null for a chapter-scope USFM (not a highlightable unit)', () => { + expect(expandPassageId('JHN.3')).toBeNull(); + }); + + it('returns null for malformed input', () => { + expect(expandPassageId('')).toBeNull(); + expect(expandPassageId('JHN')).toBeNull(); + expect(expandPassageId('JHN.3.')).toBeNull(); + expect(expandPassageId('JHN..16')).toBeNull(); + expect(expandPassageId('.3.16')).toBeNull(); + expect(expandPassageId('JHN.3.abc')).toBeNull(); + expect(expandPassageId('JHN.3.16-')).toBeNull(); + expect(expandPassageId('JHN.3.-18')).toBeNull(); + expect(expandPassageId('JHN.3.16-18-20')).toBeNull(); + expect(expandPassageId('JHN.3.16.18')).toBeNull(); + }); + + it('returns null for a reversed range', () => { + expect(expandPassageId('JHN.3.18-16')).toBeNull(); + }); + + it('returns null for verse 0', () => { + expect(expandPassageId('JHN.3.0')).toBeNull(); + expect(expandPassageId('JHN.3.0-2')).toBeNull(); + }); + + it('returns null for an implausibly large range (defensive cap)', () => { + expect(expandPassageId('JHN.3.1-100000')).toBeNull(); + }); +}); + +describe('deriveHighlightedVerses', () => { + const highlight = (version_id: number, passage_id: string, color: string): Highlight => ({ + version_id, + passage_id, + color, + }); + + it('projects matching per-verse and range entries into a verse->color map', () => { + const map = deriveHighlightedVerses( + [highlight(111, 'JHN.3.16', 'fffe00'), highlight(111, 'JHN.3.18-19', '5dff79')], + 111, + 'JHN', + '3', + ); + expect(map).toEqual({ 16: 'fffe00', 18: '5dff79', 19: '5dff79' }); + }); + + it('ignores entries for other versions', () => { + const map = deriveHighlightedVerses([highlight(222, 'JHN.3.16', 'fffe00')], 111, 'JHN', '3'); + expect(map).toEqual({}); + }); + + it('ignores entries for other books or chapters', () => { + const map = deriveHighlightedVerses( + [ + highlight(111, 'GEN.3.16', 'fffe00'), + highlight(111, 'JHN.4.16', 'fffe00'), + highlight(111, 'JHN.3.16', '00d6ff'), + ], + 111, + 'JHN', + '3', + ); + expect(map).toEqual({ 16: '00d6ff' }); + }); + + it('ignores unexpandable passage ids without dropping valid ones', () => { + const map = deriveHighlightedVerses( + [highlight(111, 'JHN.3', 'fffe00'), highlight(111, 'JHN.3.2', '5dff79')], + 111, + 'JHN', + '3', + ); + expect(map).toEqual({ 2: '5dff79' }); + }); + + it('lets later entries win on verse collisions', () => { + const map = deriveHighlightedVerses( + [highlight(111, 'JHN.3.16-17', 'fffe00'), highlight(111, 'JHN.3.17', 'ff95ef')], + 111, + 'JHN', + '3', + ); + expect(map).toEqual({ 16: 'fffe00', 17: 'ff95ef' }); + }); + + it('normalizes colors to lowercase (uppercase accepted at the boundary)', () => { + const map = deriveHighlightedVerses([highlight(111, 'JHN.3.16', 'FFFE00')], 111, 'JHN', '3'); + expect(map).toEqual({ 16: 'fffe00' }); + }); + + it('returns an empty map for an empty highlights array', () => { + expect(deriveHighlightedVerses([], 111, 'JHN', '3')).toEqual({}); + }); +}); diff --git a/packages/ui/src/lib/highlight-projection.ts b/packages/ui/src/lib/highlight-projection.ts new file mode 100644 index 00000000..d0fc9d19 --- /dev/null +++ b/packages/ui/src/lib/highlight-projection.ts @@ -0,0 +1,71 @@ +import type { Highlight } from '@youversion/platform-core'; + +/** + * Defensive cap on how many verses a single range USFM may expand to. The + * longest chapter in any Bible (Psalm 119) has 176 verses, so any range longer + * than this is malformed data and is rejected rather than expanded. + */ +const MAX_RANGE_LENGTH = 250; + +/** A verse USFM (`JHN.3.16` / `JHN.3.16-18`) split into its parts. */ +export type ExpandedPassageId = { + book: string; + chapter: string; + /** Every verse number covered, ascending (a range expands to each verse). */ + verses: number[]; +}; + +/** + * Expands a verse or verse-range USFM passage id (`JHN.3.16`, `JHN.3.16-18`) + * into its book, chapter, and per-verse numbers. + * + * Returns `null` for anything that is not a highlightable verse unit: a + * chapter-scope USFM (`JHN.3`), malformed input, a reversed range, verse 0, or + * an implausibly large range. + */ +export function expandPassageId(passageId: string): ExpandedPassageId | null { + const parts = passageId.split('.'); + if (parts.length !== 3) return null; + const [book, chapter, versePart] = parts; + if (!book || !chapter || !versePart) return null; + + const match = /^(\d+)(?:-(\d+))?$/.exec(versePart); + if (!match) return null; + + const start = parseInt(match[1]!, 10); + const end = match[2] !== undefined ? parseInt(match[2], 10) : start; + if (start < 1 || end < start || end - start + 1 > MAX_RANGE_LENGTH) return null; + + const verses: number[] = []; + for (let verse = start; verse <= end; verse++) { + verses.push(verse); + } + return { book, chapter, verses }; +} + +/** + * Projects a host-supplied `Highlight[]` (core API shape) onto the displayed + * chapter as the reader's internal render map (verse number -> hex color). + * + * Entries for other versions, books, or chapters are ignored — every entry + * carries its full identity, so stale host data can never mispaint. Range + * passage ids are expanded per verse. Colors are normalized to lowercase (the + * API accepts uppercase at the boundary). Later entries win on collisions. + */ +export function deriveHighlightedVerses( + highlights: readonly Highlight[], + versionId: number, + book: string, + chapter: string, +): Record { + const map: Record = {}; + for (const { version_id, passage_id, color } of highlights) { + if (version_id !== versionId) continue; + const expanded = expandPassageId(passage_id); + if (!expanded || expanded.book !== book || expanded.chapter !== chapter) continue; + for (const verse of expanded.verses) { + map[verse] = color.toLowerCase(); + } + } + return map; +} From fdd0d2c114027da9299b12d80ce9cc3ae2ae1d44 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 11:33:08 -0500 Subject: [PATCH 02/12] feat(ui): controlled highlights mode on BibleReader (YPE-3705) BibleReader.Root gains a controlled highlights mode for hosts that own highlight data themselves (RN Expo DOM hosts keeping the user token out of the WebView): - `highlights?: Highlight[]` (core API shape): presence puts the highlight slice in controlled mode, latched at first mount (dev warning on flips; a transient undefined renders as "no highlights", never re-enabling self-contained behavior). - In controlled mode the reader is a pure projection: the render map derives solely from the prop (filtered by displayed version + chapter, range USFMs expanded per verse); the localStorage highlight store is never read or written; color taps emit intents and paint nothing until the host round-trips an updated prop. - New events: `onVerseSelect` (both modes, fires on every selection change, verses: [] on clear) and `onHighlightApply` / `onHighlightRemove` (controlled mode only; removes are scoped to the selected verses currently showing the tapped color). Payloads are serializable bridge-safe objects with always-per-verse passageIds. - Self-contained behavior with no `highlights` prop is unchanged. - Two Storybook stories: args-driven `Controlled` and `Controlled (fake host)`, the executable reference implementation of the host round-trip contract for RN's YPE-3710. - Changeset: minor bump. Co-Authored-By: Claude Fable 5 --- .changeset/bible-reader-controlled-mode.md | 10 + .../bible-reader-controlled.test.tsx | 436 ++++++++++++++++++ .../src/components/bible-reader.stories.tsx | 109 ++++- packages/ui/src/components/bible-reader.tsx | 186 +++++++- packages/ui/src/components/index.ts | 2 + packages/ui/src/index.ts | 3 + 6 files changed, 730 insertions(+), 16 deletions(-) create mode 100644 .changeset/bible-reader-controlled-mode.md create mode 100644 packages/ui/src/components/bible-reader-controlled.test.tsx diff --git a/.changeset/bible-reader-controlled-mode.md b/.changeset/bible-reader-controlled-mode.md new file mode 100644 index 00000000..066c6652 --- /dev/null +++ b/.changeset/bible-reader-controlled-mode.md @@ -0,0 +1,10 @@ +--- +'@youversion/platform-react-ui': minor +--- + +BibleReader gains a controlled highlights mode (YPE-3705) for hosts that own highlight data themselves (e.g. React Native / Expo DOM hosts keeping the user token out of the WebView). + +- New `BibleReader.Root` props: `highlights?: Highlight[]` (core API shape; presence puts the highlight slice in controlled mode, latched at first mount), `onVerseSelect` (both modes, fires on every selection change with `verses: []` on clear), and `onHighlightApply` / `onHighlightRemove` (controlled mode only; ignored in self-contained mode). Payloads are serializable, bridge-safe objects (`BibleReaderVerseSelection`, `BibleReaderHighlightIntent`) with always-per-verse `passageIds`. +- In controlled mode the reader is a pure projection: highlights render solely from the prop (filtered by displayed version + chapter, range USFMs like `JHN.3.16-18` expanded per verse), color taps emit intents and paint nothing until the host round-trips an updated prop, and the localStorage highlight store is never read or written. No auth surface can originate from the highlight path. +- Self-contained behavior (no `highlights` prop) is unchanged. +- `@youversion/platform-react-ui` now re-exports the `Highlight` type from `@youversion/platform-core`. diff --git a/packages/ui/src/components/bible-reader-controlled.test.tsx b/packages/ui/src/components/bible-reader-controlled.test.tsx new file mode 100644 index 00000000..e7e35c90 --- /dev/null +++ b/packages/ui/src/components/bible-reader-controlled.test.tsx @@ -0,0 +1,436 @@ +/** + * @vitest-environment jsdom + */ +// Controlled-mode (YPE-3705) tests for BibleReader highlights: pure projection +// from the `highlights` prop, provable inertness (no highlight network or +// localStorage traffic), intent events, and mode latching. +// 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, afterEach, type MockInstance } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { BibleBook, BiblePassage, BibleVersion, Highlight } from '@youversion/platform-core'; +import { useBooks, usePassage, useTheme, useVersion } from '@youversion/platform-react-hooks'; +import { BibleReader, type BibleReaderRootProps } from './bible-reader'; +import { HIGHLIGHT_COLORS } from './verse-action-popover'; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + +// jsdom does not implement Element#scrollTo (BibleReader.Content scrolls to +// top on chapter change). +if (!Element.prototype.scrollTo) { + Element.prototype.scrollTo = () => {}; +} + +vi.mock('@youversion/platform-react-hooks', async () => { + const actual = await vi.importActual('@youversion/platform-react-hooks'); + return { + ...actual, + useBooks: vi.fn(), + usePassage: vi.fn(), + useTheme: vi.fn(), + useVersion: vi.fn(), + }; +}); + +const YELLOW = HIGHLIGHT_COLORS[0]; // fffe00 +const GREEN = HIGHLIGHT_COLORS[1]; // 5dff79 + +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: 111, + localized_abbreviation: 'NIV', + abbreviation: 'NIV', + title: 'New International Version', + language_tag: 'en', +} as BibleVersion; + +// Same shape as the real passages API mock data: `.yv-v` markers that the +// Bible HTML transformer expands into per-verse wrappers. +const mockPassage: BiblePassage = { + id: 'JHN.1', + content: + '
' + + '1In the beginning was the Word. ' + + '2He was with God in the beginning. ' + + '3Through him all things were made.' + + '
', + reference: 'John 1', +}; + +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(usePassage).mockReturnValue({ + passage: mockPassage, + loading: false, + error: null, + refetch: vi.fn(), + }); +} + +function renderReader(props: Partial = {}) { + return render( + + + , + ); +} + +function getVerseEl(container: HTMLElement, verse: number): HTMLElement { + const els = container.querySelectorAll(`.yv-v[v="${verse}"]`); + const el = els[els.length - 1]; + if (!el) throw new Error(`Verse ${verse} not rendered`); + return el; +} + +function selectVerse(container: HTMLElement, verse: number) { + fireEvent.click(getVerseEl(container, verse)); +} + +/** rgba() string the reader paints for a highlight fill (0.35 alpha). */ +function fillFor(hex: string): string { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, 0.35)`; +} + +function getApplyButtons() { + return screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); +} + +function getClearButtons() { + return screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); +} + +const selection = (verses: number[]) => ({ + versionId: 111, + book: 'JHN', + chapter: '1', + verses, + passageIds: verses.map((verse) => `JHN.1.${verse}`), +}); + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + setupDefaultMocks(); +}); + +describe('BibleReader controlled mode - pure projection', () => { + it('paints verses from the highlights prop, expanding range USFMs', () => { + const highlights: Highlight[] = [ + { version_id: 111, passage_id: 'JHN.1.1', color: YELLOW }, + { version_id: 111, passage_id: 'JHN.1.2-3', color: GREEN }, + ]; + const { container } = renderReader({ highlights }); + + expect(getVerseEl(container, 1).style.backgroundColor).toBe(fillFor(YELLOW)); + expect(getVerseEl(container, 2).style.backgroundColor).toBe(fillFor(GREEN)); + expect(getVerseEl(container, 3).style.backgroundColor).toBe(fillFor(GREEN)); + }); + + it('ignores entries for other versions and chapters', () => { + const highlights: Highlight[] = [ + { version_id: 222, passage_id: 'JHN.1.1', color: YELLOW }, + { version_id: 111, passage_id: 'JHN.2.2', color: YELLOW }, + { version_id: 111, passage_id: 'GEN.1.3', color: YELLOW }, + ]; + const { container } = renderReader({ highlights }); + + for (const verse of [1, 2, 3]) { + expect(getVerseEl(container, verse).style.backgroundColor).toBe(''); + } + }); + + it('re-projects when the highlights prop changes (host round-trip)', () => { + const { container, rerender } = renderReader({ highlights: [] }); + expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); + + rerender( + + + , + ); + expect(getVerseEl(container, 1).style.backgroundColor).toBe(fillFor(YELLOW)); + }); +}); + +describe('BibleReader controlled mode - provable inertness', () => { + it('never touches the network or localStorage for highlights, even across select/apply/clear', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + try { + const { container } = renderReader({ + highlights: [{ version_id: 111, passage_id: 'JHN.1.2', color: GREEN }], + onHighlightApply: vi.fn(), + onHighlightRemove: vi.fn(), + }); + + // Apply a color to verse 1. + selectVerse(container, 1); + await waitFor(() => expect(getApplyButtons().length).toBeGreaterThan(0)); + fireEvent.click(getApplyButtons()[0]!); + + // Clear the highlight showing on verse 2. + selectVerse(container, 2); + await waitFor(() => expect(getClearButtons()).toHaveLength(1)); + fireEvent.click(getClearButtons()[0]!); + + const highlightKey = (key: unknown) => String(key).includes('highlights'); + expect(getItemSpy.mock.calls.some(([key]) => highlightKey(key))).toBe(false); + expect(setItemSpy.mock.calls.some(([key]) => highlightKey(key))).toBe(false); + expect(fetchSpy.mock.calls.some(([input]) => String(input).includes('/v1/highlights'))).toBe( + false, + ); + } finally { + vi.unstubAllGlobals(); + getItemSpy.mockRestore(); + setItemSpy.mockRestore(); + } + }); + + it('color taps paint nothing (no optimistic echo)', async () => { + const { container } = renderReader({ highlights: [], onHighlightApply: vi.fn() }); + + selectVerse(container, 1); + await waitFor(() => expect(getApplyButtons().length).toBeGreaterThan(0)); + fireEvent.click(getApplyButtons()[0]!); + + expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); + }); +}); + +describe('BibleReader controlled mode - events', () => { + it('emits onVerseSelect on every selection change, [] on clear', () => { + const onVerseSelect = vi.fn(); + const { container } = renderReader({ highlights: [], onVerseSelect }); + + selectVerse(container, 2); + expect(onVerseSelect).toHaveBeenLastCalledWith(selection([2])); + + selectVerse(container, 1); + expect(onVerseSelect).toHaveBeenLastCalledWith(selection([1, 2])); + + selectVerse(container, 2); + expect(onVerseSelect).toHaveBeenLastCalledWith(selection([1])); + + selectVerse(container, 1); + expect(onVerseSelect).toHaveBeenLastCalledWith(selection([])); + expect(onVerseSelect).toHaveBeenCalledTimes(4); + }); + + it('emits onVerseSelect in self-contained mode too', () => { + const onVerseSelect = vi.fn(); + const { container } = renderReader({ onVerseSelect }); + + selectVerse(container, 3); + expect(onVerseSelect).toHaveBeenCalledWith(selection([3])); + }); + + it('emits onHighlightApply with the exact intent payload and clears the selection', async () => { + const onHighlightApply = vi.fn(); + const { container } = renderReader({ highlights: [], onHighlightApply }); + + selectVerse(container, 1); + selectVerse(container, 3); + await waitFor(() => expect(getApplyButtons().length).toBeGreaterThan(0)); + fireEvent.click(getApplyButtons()[0]!); + + expect(onHighlightApply).toHaveBeenCalledTimes(1); + expect(onHighlightApply).toHaveBeenCalledWith({ ...selection([1, 3]), color: YELLOW }); + + // Selection cleared per existing popover behavior. + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + expect(getVerseEl(container, 1).classList.contains('yv-v-selected')).toBe(false); + }); + + it('emits onHighlightRemove scoped to the selected verses showing that color', async () => { + const onHighlightRemove = vi.fn(); + const highlights: Highlight[] = [ + { version_id: 111, passage_id: 'JHN.1.1', color: YELLOW }, + { version_id: 111, passage_id: 'JHN.1.2', color: GREEN }, + ]; + const rerenderWith = (next: Highlight[]) => + rerender( + + + , + ); + const { container, rerender } = renderReader({ highlights, onHighlightRemove }); + + selectVerse(container, 1); + selectVerse(container, 2); + selectVerse(container, 3); + // Two active colors -> two clear circles, ordered yellow then green. + await waitFor(() => expect(getClearButtons()).toHaveLength(2)); + + fireEvent.click(getClearButtons()[0]!); + expect(onHighlightRemove).toHaveBeenCalledTimes(1); + expect(onHighlightRemove).toHaveBeenLastCalledWith({ ...selection([1]), color: YELLOW }); + + // Green still showing on a selected verse -> popover stays open (AC 8a). + expect(screen.getByRole('dialog')).toBeTruthy(); + + // Host round-trips the removal; the reader re-projects (verse 1 unpaints, + // one clear circle remains). + rerenderWith([{ version_id: 111, passage_id: 'JHN.1.2', color: GREEN }]); + expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); + await waitFor(() => expect(getClearButtons()).toHaveLength(1)); + + fireEvent.click(getClearButtons()[0]!); + expect(onHighlightRemove).toHaveBeenCalledTimes(2); + expect(onHighlightRemove).toHaveBeenLastCalledWith({ ...selection([2]), color: GREEN }); + + // Last active color removed -> popover dismisses (AC 8). + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + }); + + it('ignores onHighlightApply / onHighlightRemove in self-contained mode', async () => { + const onHighlightApply = vi.fn(); + const onHighlightRemove = vi.fn(); + const { container } = renderReader({ onHighlightApply, onHighlightRemove }); + + selectVerse(container, 1); + await waitFor(() => expect(getApplyButtons().length).toBeGreaterThan(0)); + fireEvent.click(getApplyButtons()[0]!); + + // Self-contained behavior unchanged: paints and persists locally. + await waitFor(() => { + expect(getVerseEl(container, 1).style.backgroundColor).toBe(fillFor(YELLOW)); + }); + expect(localStorage.getItem('youversion-platform:highlights:111')).toBe( + JSON.stringify({ 'JHN.1.1': YELLOW }), + ); + + // Clear it through the popover's X circle. + selectVerse(container, 1); + await waitFor(() => expect(getClearButtons()).toHaveLength(1)); + fireEvent.click(getClearButtons()[0]!); + + expect(onHighlightApply).not.toHaveBeenCalled(); + expect(onHighlightRemove).not.toHaveBeenCalled(); + }); +}); + +describe('BibleReader controlled mode - latching', () => { + let warnSpy: MockInstance; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('stays controlled when highlights flips to undefined: warns, renders no highlights, never reads the store', () => { + // Seed the store: if the reader ever fell back to self-contained mode, + // this entry would paint. + localStorage.setItem( + 'youversion-platform:highlights:111', + JSON.stringify({ 'JHN.1.1': YELLOW }), + ); + + const { container, rerender } = renderReader({ + highlights: [{ version_id: 111, passage_id: 'JHN.1.2', color: GREEN }], + }); + expect(getVerseEl(container, 2).style.backgroundColor).toBe(fillFor(GREEN)); + + rerender( + + + , + ); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('latched at first mount')); + // Transient undefined renders as "no highlights" — not the localStorage store. + expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); + expect(getVerseEl(container, 2).style.backgroundColor).toBe(''); + }); + + it('stays self-contained when highlights appears after mount: warns and ignores the prop', () => { + const { container, rerender } = renderReader(); + expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); + + rerender( + + + , + ); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('latched at first mount')); + expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); + }); + + it('treats [] as controlled (store never read) while undefined means self-contained (store read)', () => { + localStorage.setItem( + 'youversion-platform:highlights:111', + JSON.stringify({ 'JHN.1.1': YELLOW }), + ); + + const controlled = renderReader({ highlights: [] }); + expect(getVerseEl(controlled.container, 1).style.backgroundColor).toBe(''); + controlled.unmount(); + + const selfContained = renderReader(); + expect(getVerseEl(selfContained.container, 1).style.backgroundColor).toBe(fillFor(YELLOW)); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 3d3c499c..db6b6a31 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -1,11 +1,17 @@ import { INTER_FONT, SOURCE_SERIF_FONT } from '@/lib/verse-html-utils'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Highlight } from '@youversion/platform-core'; import { delay, http, HttpResponse } from 'msw'; +import { useState } from 'react'; import { expect, fn, screen, spyOn, userEvent, waitFor } from 'storybook/test'; import mockBibles from '../test/mock-data/bibles.json'; import { globalHandlers } from '../test/mocks/handlers'; import { setupAuthenticatedUser } from '../test/utils'; -import { BibleReader } from './bible-reader'; +import { + BibleReader, + type BibleReaderHighlightIntent, + type BibleReaderRootProps, +} from './bible-reader'; let signInMock: ReturnType; @@ -803,6 +809,107 @@ export const JoshuaIntroChapter: Story = { }, }; +/** + * Controlled mode (YPE-3705): the host supplies highlight data via the + * `highlights` prop (core API shape, including range USFMs) and receives + * intent events; the reader performs no highlight persistence. Tweak the + * `highlights` arg and watch the paint follow; select verses and tap colors to + * see `onVerseSelect` / `onHighlightApply` / `onHighlightRemove` in the + * Actions panel. Note: taps paint nothing here — there is no host echoing the + * intents back (see `ControlledFakeHost` for the round-trip). + */ +export const Controlled: Story = { + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + highlights: [ + { version_id: 111, passage_id: 'JHN.1.1', color: 'fffe00' }, + { version_id: 111, passage_id: 'JHN.1.3-5', color: '5dff79' }, + ], + onVerseSelect: fn(), + onHighlightApply: fn(), + onHighlightRemove: fn(), + }, + render: (args) => ( +
+ + + + +
+ ), +}; + +/** + * Controlled mode with a stateful fake host that echoes `onHighlightApply` / + * `onHighlightRemove` back into the `highlights` prop — the executable + * reference implementation of the round-trip contract for native hosts + * (RN Expo, YPE-3710). Selecting verses and tapping a color paints only via + * the prop update; tapping an X circle un-paints the same way. + */ +export const ControlledFakeHost: Story = { + name: 'Controlled (fake host)', + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + onVerseSelect: fn(), + onHighlightApply: fn(), + onHighlightRemove: fn(), + }, + render: function FakeHostStory(args: BibleReaderRootProps) { + // The host's own highlight store, kept per-verse so remove intents (always + // per-verse `passageIds`) match entries directly. A real native host owns + // this in its data layer (with the API, cache, and optimism behind it); + // the reader only ever sees the resulting array. + const [highlights, setHighlights] = useState([ + { version_id: 111, passage_id: 'JHN.1.1', color: 'fffe00' }, + { version_id: 111, passage_id: 'JHN.1.3', color: '5dff79' }, + { version_id: 111, passage_id: 'JHN.1.4', color: '5dff79' }, + { version_id: 111, passage_id: 'JHN.1.5', color: '5dff79' }, + ]); + + const handleApply = (intent: BibleReaderHighlightIntent) => { + args.onHighlightApply?.(intent); + setHighlights((prev) => [ + // Replace any existing entry for these verses (last write wins). + ...prev.filter( + (h) => !(h.version_id === intent.versionId && intent.passageIds.includes(h.passage_id)), + ), + ...intent.passageIds.map((passage_id) => ({ + version_id: intent.versionId, + passage_id, + color: intent.color, + })), + ]); + }; + + const handleRemove = (intent: BibleReaderHighlightIntent) => { + args.onHighlightRemove?.(intent); + setHighlights((prev) => + prev.filter( + (h) => !(h.version_id === intent.versionId && intent.passageIds.includes(h.passage_id)), + ), + ); + }; + + return ( +
+ + + + +
+ ); + }, +}; + export const ChapterChangeLoadingOverlay: Story = { tags: ['integration'], args: { diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index c3d4810e..3b00300e 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -5,7 +5,7 @@ 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 { useControllableState } from '@radix-ui/react-use-controllable-state'; -import type { BibleBook } from '@youversion/platform-core'; +import type { BibleBook, Highlight } from '@youversion/platform-core'; import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core'; import { useBooks, @@ -41,6 +41,7 @@ import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from './ui/popo import { VerseActionPopover } from './verse-action-popover'; import { BibleTextView, getCleanVerseText, type FootnoteData } from './verse'; import { buildVerseReference, buildVerseShareText, joinVerseTexts } from '@/lib/verse-share'; +import { deriveHighlightedVerses } from '@/lib/highlight-projection'; type BibleReaderContextType = { book: string; @@ -66,6 +67,45 @@ type BibleReaderContextType = { onSignOutPress?: () => void; onCopy?: (data: BibleReaderShareData) => void | Promise; onShare?: (data: BibleReaderShareData) => void | Promise; + highlights?: Highlight[]; + isHighlightsControlled: boolean; + onVerseSelect?: (selection: BibleReaderVerseSelection) => void; + onHighlightApply?: (intent: BibleReaderHighlightIntent) => void; + onHighlightRemove?: (intent: BibleReaderHighlightIntent) => void; +}; + +/** + * Serializable payload describing the reader's current verse selection. + * Bridge-safe primitives only, so React Native / Expo DOM hosts can forward it + * across the native bridge. `passageIds` is deliberately redundant with + * `book`/`chapter`/`verses` (always per-verse, never ranges): native hosts feed + * the API without USFM string-building; analytics reads `verses` without USFM + * parsing. + */ +export type BibleReaderVerseSelection = { + /** Matches `Highlight.version_id`. */ + versionId: number; + /** USFM book code, e.g. `'JHN'`. */ + book: string; + /** Chapter id, e.g. `'3'`. */ + chapter: string; + /** Selected verse numbers, ascending and de-duplicated. `[]` on clear. */ + verses: number[]; + /** Per-verse USFM ids, e.g. `['JHN.3.16', 'JHN.3.17']`. Never ranges. */ + passageIds: string[]; +}; + +/** + * Serializable payload handed to `onHighlightApply` / `onHighlightRemove` in + * controlled mode. An intent is a request, not a fact: the reader paints + * nothing until the host round-trips an updated `highlights` prop. + */ +export type BibleReaderHighlightIntent = BibleReaderVerseSelection & { + /** + * 6-character lowercase hex, no `#`. For `onHighlightRemove`, the color + * being cleared. + */ + color: string; }; /** @@ -87,6 +127,11 @@ export type BibleReaderShareData = { versionId: number; }; +// `process` is not typed in this browser-targeting build. Consumers' bundlers +// statically replace `process.env.NODE_ENV`; the `typeof` guard keeps +// unbundled browser environments (no `process` global) safe at runtime. +declare const process: { env: { NODE_ENV?: string } }; + const BibleReaderContext = createContext(null); function useBibleReaderContext() { @@ -139,6 +184,39 @@ export type RootProps = { * default Web Share / clipboard flow — use for React Native / Expo hosts. */ onShare?: (data: BibleReaderShareData) => void | Promise; + /** + * Host-supplied highlights in the core API shape (`{ version_id, passage_id, + * color }`). Presence of this prop puts the reader's highlight slice in + * **controlled mode**: the reader renders highlights purely from this prop + * (filtered by the displayed version and chapter, range USFMs like + * `'JHN.3.16-18'` expanded per verse) and performs no highlight persistence + * of any kind — no network calls, no localStorage, no auth surface. Color + * taps emit {@link onHighlightApply} / {@link onHighlightRemove} instead of + * painting; nothing paints until the host round-trips an updated prop. + * + * The mode is latched at first mount: flipping between controlled and + * self-contained across renders is unsupported (dev warning). `[]` means + * "controlled, nothing highlighted"; leaving the prop off means + * self-contained. + */ + highlights?: Highlight[]; + /** + * Called on every verse selection change with the selection payload + * (`verses: []` when the selection is cleared by deselecting). Fires in both + * controlled and self-contained modes — it is an observation, not a request. + */ + onVerseSelect?: (selection: BibleReaderVerseSelection) => void; + /** + * Controlled mode only: called when the user taps a highlight color, with + * the intent payload. Ignored (never called) in self-contained mode. + */ + onHighlightApply?: (intent: BibleReaderHighlightIntent) => void; + /** + * Controlled mode only: called when the user taps a clear (X) circle, scoped + * to the selected verses currently showing that color. Ignored (never + * called) in self-contained mode. + */ + onHighlightRemove?: (intent: BibleReaderHighlightIntent) => void; children?: ReactNode; }; @@ -285,8 +363,33 @@ function Root({ onSignOutPress, onCopy, onShare, + highlights, + onVerseSelect, + onHighlightApply, + onHighlightRemove, children, }: RootProps) { + // Controlled mode is latched at first mount (presence of the `highlights` + // prop). Flipping controlled <-> self-contained across renders is + // unsupported: once controlled, a transient `undefined` renders as "no + // highlights" and never re-enables the self-contained localStorage store. + const isHighlightsControlledRef = useRef(highlights !== undefined); + const isHighlightsControlled = isHighlightsControlledRef.current; + const didWarnHighlightsModeFlipRef = useRef(false); + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV !== 'production' && + (highlights !== undefined) !== isHighlightsControlled && + !didWarnHighlightsModeFlipRef.current + ) { + didWarnHighlightsModeFlipRef.current = true; + console.warn( + `BibleReader.Root: the \`highlights\` prop switched from ${ + isHighlightsControlled ? 'present to absent' : 'absent to present' + } after mount. The highlight mode (controlled vs self-contained) is latched at first mount and will not change. Pass \`highlights\` (use \`[]\` for "nothing highlighted") on every render for controlled mode, or never pass it for self-contained mode.`, + ); + } + const [book, setBook] = useControllableState({ prop: controlledBook, defaultProp: defaultBook, @@ -433,6 +536,11 @@ function Root({ onSignOutPress, onCopy, onShare, + highlights: isHighlightsControlled ? highlights : undefined, + isHighlightsControlled, + onVerseSelect, + onHighlightApply, + onHighlightRemove, }; return ( @@ -463,6 +571,11 @@ function Content() { onFootnotePress, onCopy, onShare, + highlights, + isHighlightsControlled, + onVerseSelect, + onHighlightApply, + onHighlightRemove, } = useBibleReaderContext(); const { version } = useVersion(versionId); @@ -504,10 +617,13 @@ function Content() { }, [book, chapter]); // ---- Verse selection + highlights ------------------------------------------ - // Selection is ephemeral; highlights persist to localStorage only for now - // (ADR-001) in the future `highlight` API shape: keyed by full passage_id USFM - // and scoped by versionId/bible_id (ADR-002). The reader DOM ref lets us anchor - // the popover and pull clean verse text for Copy / Share. + // Selection is ephemeral; in self-contained mode highlights persist to + // localStorage only for now (ADR-001) in the future `highlight` API shape: + // keyed by full passage_id USFM and scoped by versionId/bible_id (ADR-002). + // In controlled mode (YPE-3705) the store is never read or written: the + // render map derives purely from the host's `highlights` prop, and color / + // clear taps emit intents instead of mutating anything. The reader DOM ref + // lets us anchor the popover and pull clean verse text for Copy / Share. const readerRef = useRef(null); const [selectedVerses, setSelectedVerses] = useState([]); const [popoverOpen, setPopoverOpen] = useState(false); @@ -522,13 +638,17 @@ function Content() { // previous version's highlights would paint over the new text for one frame — // their `${book}.${chapter}.N` keys collide with the new version's verses. const [loadedHighlightsKey, setLoadedHighlightsKey] = useState(highlightsStorageKey); - if (loadedHighlightsKey !== highlightsStorageKey) { + if (!isHighlightsControlled && loadedHighlightsKey !== highlightsStorageKey) { setLoadedHighlightsKey(highlightsStorageKey); setHighlightStore({}); } // Load this version's highlights when the version changes (client-only). + // Controlled mode never touches the store: the localStorage key is neither + // read nor written, so YPE-1034's store deletion is a no-op for controlled + // hosts. useEffect(() => { + if (isHighlightsControlled) return; let data: Record = {}; try { const raw = localStorage.getItem(highlightsStorageKey); @@ -537,7 +657,7 @@ function Content() { // Ignore (unavailable or malformed storage). } setHighlightStore(data); - }, [highlightsStorageKey]); + }, [highlightsStorageKey, isHighlightsControlled]); // Navigating away (book/chapter/version) drops the selection — those verses no // longer exist on screen (ADR-007). @@ -548,9 +668,14 @@ function Content() { lastSelectionRef.current = []; }, [book, chapter, versionId]); - // Derive the visible chapter's highlights (verse number → hex) from the store. + // Derive the visible chapter's highlights (verse number → hex): in + // controlled mode purely from the host's `highlights` prop, otherwise from + // the localStorage-backed store. const chapterPrefix = `${book}.${chapter}.`; const highlightedVerses = useMemo(() => { + if (isHighlightsControlled) { + return deriveHighlightedVerses(highlights ?? [], versionId, book, chapter); + } const map: Record = {}; for (const [passageId, color] of Object.entries(highlightStore)) { if (!passageId.startsWith(chapterPrefix)) continue; @@ -558,7 +683,7 @@ function Content() { if (verseNum) map[verseNum] = color; } return map; - }, [highlightStore, chapterPrefix]); + }, [isHighlightsControlled, highlights, versionId, book, chapter, highlightStore, chapterPrefix]); // Distinct colors present in the current selection → drives the X (remove) circles. const activeHighlights = useMemo( @@ -586,11 +711,26 @@ function Content() { lastSelectionRef.current = []; } + /** Builds the serializable selection payload (verses ascending, de-duped). */ + function buildVerseSelection(verses: number[]): BibleReaderVerseSelection { + const sorted = [...new Set(verses)].sort((a, b) => a - b); + return { + versionId, + book, + chapter, + verses: sorted, + passageIds: sorted.map((verse) => `${book}.${chapter}.${verse}`), + }; + } + function handleVerseSelect(verses: number[]) { const added = verses.find((verse) => !lastSelectionRef.current.includes(verse)); lastSelectionRef.current = verses; setSelectedVerses(verses); + // Observation, not a request: fires in both modes, `[]` on clear. + onVerseSelect?.(buildVerseSelection(verses)); + if (verses.length === 0) { setPopoverOpen(false); setAnchorElement(null); @@ -608,6 +748,13 @@ function Content() { } function handleHighlight(color: string) { + if (isHighlightsControlled) { + // Pure projection: emit the intent and paint nothing — the highlight + // appears only when the host round-trips an updated `highlights` prop. + onHighlightApply?.({ ...buildVerseSelection(selectedVerses), color }); + closeAndClearSelection(); + return; + } const next = { ...highlightStore }; for (const verse of selectedVerses) { next[`${book}.${chapter}.${verse}`] = color; @@ -618,13 +765,22 @@ function Content() { } function handleClearHighlight(color: string) { - const next = { ...highlightStore }; - for (const verse of selectedVerses) { - const passageId = `${book}.${chapter}.${verse}`; - if (next[passageId] === color) delete next[passageId]; + if (isHighlightsControlled) { + // Scope the intent to the selected verses currently showing this color + // (per the prop-derived map). + const versesToClear = selectedVerses.filter((verse) => highlightedVerses[verse] === color); + if (versesToClear.length > 0) { + onHighlightRemove?.({ ...buildVerseSelection(versesToClear), color }); + } + } else { + const next = { ...highlightStore }; + for (const verse of selectedVerses) { + const passageId = `${book}.${chapter}.${verse}`; + if (next[passageId] === color) delete next[passageId]; + } + setHighlightStore(next); + persistHighlights(next); } - setHighlightStore(next); - persistHighlights(next); // Multiple colors active → keep open so the user can remove others (AC 8a); // last color removed → dismiss (AC 8). diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 61c3a18b..c727e0a1 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -15,9 +15,11 @@ export { createBibleThemeSettingsContentHandlers, nextBibleReaderFontSizeDown, nextBibleReaderFontSizeUp, + type BibleReaderHighlightIntent, type BibleReaderRootProps, type BibleReaderShareData, type BibleReaderToolbarProps, + type BibleReaderVerseSelection, type BibleThemeSettingsContentProps, type BibleThemeSettingsSnapshot, type BibleThemeSettingsValues, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9304ceac..7eaf5116 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,6 +12,9 @@ export { // Authentication type ApiConfig, type AuthenticationState, + + // Highlights (the shape of BibleReader.Root's controlled `highlights` prop) + type Highlight, } from '@youversion/platform-core'; export { From 0b11da69e88036cc6c30474974e6b2a8b644cf62 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 11:51:09 -0500 Subject: [PATCH 03/12] fix(ui): emit onVerseSelect([]) on every selection-clearing path Co-Authored-By: Claude Fable 5 --- .changeset/bible-reader-controlled-mode.md | 2 +- packages/ui/AGENTS.md | 2 +- .../bible-reader-controlled.test.tsx | 131 +++++++++++++----- .../src/components/bible-reader.stories.tsx | 14 +- packages/ui/src/components/bible-reader.tsx | 27 +++- 5 files changed, 123 insertions(+), 53 deletions(-) diff --git a/.changeset/bible-reader-controlled-mode.md b/.changeset/bible-reader-controlled-mode.md index 066c6652..cbe7a9e2 100644 --- a/.changeset/bible-reader-controlled-mode.md +++ b/.changeset/bible-reader-controlled-mode.md @@ -4,7 +4,7 @@ BibleReader gains a controlled highlights mode (YPE-3705) for hosts that own highlight data themselves (e.g. React Native / Expo DOM hosts keeping the user token out of the WebView). -- New `BibleReader.Root` props: `highlights?: Highlight[]` (core API shape; presence puts the highlight slice in controlled mode, latched at first mount), `onVerseSelect` (both modes, fires on every selection change with `verses: []` on clear), and `onHighlightApply` / `onHighlightRemove` (controlled mode only; ignored in self-contained mode). Payloads are serializable, bridge-safe objects (`BibleReaderVerseSelection`, `BibleReaderHighlightIntent`) with always-per-verse `passageIds`. +- New `BibleReader.Root` props: `highlights?: Highlight[]` (core API shape; presence puts the highlight slice in controlled mode, latched at first mount), `onVerseSelect` (both modes, fires on every selection change — `verses: []` whenever a non-empty selection clears, including after highlight/copy/share actions, popover dismiss, and navigation), and `onHighlightApply` / `onHighlightRemove` (controlled mode only; ignored in self-contained mode). Payloads are serializable, bridge-safe objects (`BibleReaderVerseSelection`, `BibleReaderHighlightIntent`) with always-per-verse `passageIds`. - In controlled mode the reader is a pure projection: highlights render solely from the prop (filtered by displayed version + chapter, range USFMs like `JHN.3.16-18` expanded per verse), color taps emit intents and paint nothing until the host round-trips an updated prop, and the localStorage highlight store is never read or written. No auth surface can originate from the highlight path. - Self-contained behavior (no `highlights` prop) is unchanged. - `@youversion/platform-react-ui` now re-exports the `Highlight` type from `@youversion/platform-core`. diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 29a1259e..aa9a56fb 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -19,7 +19,7 @@ src/index.ts # Entry point (re-exports components, types, hooks) - Components exported from `src/components/` - Re-exports from `@youversion/platform-core`: - `SignInWithYouVersionPermission`, `SignInWithYouVersionResult`, `YouVersionAPIUsers` - - `ApiConfig`, `AuthenticationState` types + - `ApiConfig`, `AuthenticationState`, `Highlight` types - Re-exports from `@youversion/platform-react-hooks`: - `YouVersionProvider`, `useYVAuth`, `UseYVAuthReturn` type diff --git a/packages/ui/src/components/bible-reader-controlled.test.tsx b/packages/ui/src/components/bible-reader-controlled.test.tsx index e7e35c90..8e4c6e9b 100644 --- a/packages/ui/src/components/bible-reader-controlled.test.tsx +++ b/packages/ui/src/components/bible-reader-controlled.test.tsx @@ -8,8 +8,24 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import type { BibleBook, BiblePassage, BibleVersion, Highlight } from '@youversion/platform-core'; -import { useBooks, usePassage, useTheme, useVersion } from '@youversion/platform-react-hooks'; +import type { + BibleBook, + BiblePassage, + BibleVersion, + Highlight, + Language, +} from '@youversion/platform-core'; +import { + useBooks, + useFilteredVersions, + useLanguage, + useLanguages, + useOrganizations, + usePassage, + useTheme, + useVersion, + useVersions, +} from '@youversion/platform-react-hooks'; import { BibleReader, type BibleReaderRootProps } from './bible-reader'; import { HIGHLIGHT_COLORS } from './verse-action-popover'; @@ -31,9 +47,14 @@ vi.mock('@youversion/platform-react-hooks', async () => { return { ...actual, useBooks: vi.fn(), + useFilteredVersions: vi.fn(), + useLanguage: vi.fn(), + useLanguages: vi.fn(), + useOrganizations: vi.fn(), usePassage: vi.fn(), useTheme: vi.fn(), useVersion: vi.fn(), + useVersions: vi.fn(), }; }); @@ -95,16 +116,43 @@ function setupDefaultMocks() { 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([]); + vi.mocked(useOrganizations).mockReturnValue({ organizations: new Map() }); } -function renderReader(props: Partial = {}) { - return render( +// Toolbar is mounted alongside Content so the adversarial surface (chapter / +// version pickers, settings) is present in every test. +function readerJsx(props: Partial = {}) { + return ( - , + + ); } +function renderReader(props: Partial = {}) { + return render(readerJsx(props)); +} + function getVerseEl(container: HTMLElement, verse: number): HTMLElement { const els = container.querySelectorAll(`.yv-v[v="${verse}"]`); const el = els[els.length - 1]; @@ -181,14 +229,7 @@ describe('BibleReader controlled mode - pure projection', () => { expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); rerender( - - - , + readerJsx({ highlights: [{ version_id: 111, passage_id: 'JHN.1.1', color: YELLOW }] }), ); expect(getVerseEl(container, 1).style.backgroundColor).toBe(fillFor(YELLOW)); }); @@ -261,6 +302,43 @@ describe('BibleReader controlled mode - events', () => { expect(onVerseSelect).toHaveBeenCalledTimes(4); }); + it('emits onVerseSelect([]) when a color tap clears the selection', async () => { + const onVerseSelect = vi.fn(); + const { container } = renderReader({ + highlights: [], + onVerseSelect, + onHighlightApply: vi.fn(), + }); + + selectVerse(container, 1); + await waitFor(() => expect(getApplyButtons().length).toBeGreaterThan(0)); + onVerseSelect.mockClear(); + + fireEvent.click(getApplyButtons()[0]!); + expect(onVerseSelect).toHaveBeenCalledTimes(1); + expect(onVerseSelect).toHaveBeenCalledWith(selection([])); + }); + + it('emits onVerseSelect([]) when navigation clears the selection', async () => { + const onVerseSelect = vi.fn(); + const { container } = renderReader({ highlights: [], onVerseSelect }); + + selectVerse(container, 1); + onVerseSelect.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: /next chapter/i })); + + await waitFor(() => expect(onVerseSelect).toHaveBeenCalledTimes(1)); + // Payload carries the new location and an empty selection. + expect(onVerseSelect).toHaveBeenCalledWith({ + versionId: 111, + book: 'JHN', + chapter: '2', + verses: [], + passageIds: [], + }); + }); + it('emits onVerseSelect in self-contained mode too', () => { const onVerseSelect = vi.fn(); const { container } = renderReader({ onVerseSelect }); @@ -295,17 +373,7 @@ describe('BibleReader controlled mode - events', () => { { version_id: 111, passage_id: 'JHN.1.2', color: GREEN }, ]; const rerenderWith = (next: Highlight[]) => - rerender( - - - , - ); + rerender(readerJsx({ highlights: next, onHighlightRemove })); const { container, rerender } = renderReader({ highlights, onHighlightRemove }); selectVerse(container, 1); @@ -388,11 +456,7 @@ describe('BibleReader controlled mode - latching', () => { }); expect(getVerseEl(container, 2).style.backgroundColor).toBe(fillFor(GREEN)); - rerender( - - - , - ); + rerender(readerJsx()); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('latched at first mount')); // Transient undefined renders as "no highlights" — not the localStorage store. @@ -405,14 +469,7 @@ describe('BibleReader controlled mode - latching', () => { expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); rerender( - - - , + readerJsx({ highlights: [{ version_id: 111, passage_id: 'JHN.1.1', color: YELLOW }] }), ); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('latched at first mount')); diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index db6b6a31..6c37451b 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -841,6 +841,10 @@ export const Controlled: Story = { ), }; +/** True when a stored highlight entry is covered by an intent's verses. */ +const matchesIntent = (h: Highlight, intent: BibleReaderHighlightIntent) => + h.version_id === intent.versionId && intent.passageIds.includes(h.passage_id); + /** * Controlled mode with a stateful fake host that echoes `onHighlightApply` / * `onHighlightRemove` back into the `highlights` prop — the executable @@ -874,9 +878,7 @@ export const ControlledFakeHost: Story = { args.onHighlightApply?.(intent); setHighlights((prev) => [ // Replace any existing entry for these verses (last write wins). - ...prev.filter( - (h) => !(h.version_id === intent.versionId && intent.passageIds.includes(h.passage_id)), - ), + ...prev.filter((h) => !matchesIntent(h, intent)), ...intent.passageIds.map((passage_id) => ({ version_id: intent.versionId, passage_id, @@ -887,11 +889,7 @@ export const ControlledFakeHost: Story = { const handleRemove = (intent: BibleReaderHighlightIntent) => { args.onHighlightRemove?.(intent); - setHighlights((prev) => - prev.filter( - (h) => !(h.version_id === intent.versionId && intent.passageIds.includes(h.passage_id)), - ), - ); + setHighlights((prev) => prev.filter((h) => !matchesIntent(h, intent))); }; return ( diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 3b00300e..ea63552a 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -201,9 +201,11 @@ export type RootProps = { */ highlights?: Highlight[]; /** - * Called on every verse selection change with the selection payload - * (`verses: []` when the selection is cleared by deselecting). Fires in both - * controlled and self-contained modes — it is an observation, not a request. + * Called on every verse selection change with the selection payload — + * `verses: []` whenever a non-empty selection clears, whether by + * deselecting, after a highlight/copy/share action, on popover dismiss, or + * on navigation. Fires in both controlled and self-contained modes — it is + * an observation, not a request. */ onVerseSelect?: (selection: BibleReaderVerseSelection) => void; /** @@ -659,13 +661,21 @@ function Content() { setHighlightStore(data); }, [highlightsStorageKey, isHighlightsControlled]); + // Latest onVerseSelect without making it an effect dependency (an inline + // handler would otherwise re-clear the selection every render). + const onVerseSelectRef = useRef(onVerseSelect); + onVerseSelectRef.current = onVerseSelect; + // Navigating away (book/chapter/version) drops the selection — those verses no - // longer exist on screen (ADR-007). + // longer exist on screen (ADR-007). Observing hosts hear the clear. useEffect(() => { setSelectedVerses([]); setPopoverOpen(false); setAnchorElement(null); - lastSelectionRef.current = []; + if (lastSelectionRef.current.length > 0) { + lastSelectionRef.current = []; + onVerseSelectRef.current?.({ versionId, book, chapter, verses: [], passageIds: [] }); + } }, [book, chapter, versionId]); // Derive the visible chapter's highlights (verse number → hex): in @@ -708,7 +718,12 @@ function Content() { setPopoverOpen(false); setSelectedVerses([]); setAnchorElement(null); - lastSelectionRef.current = []; + // Clearing a non-empty selection is a selection change: observing hosts + // hear `verses: []` (the deselect-tap path emits via handleVerseSelect). + if (lastSelectionRef.current.length > 0) { + lastSelectionRef.current = []; + onVerseSelect?.(buildVerseSelection([])); + } } /** Builds the serializable selection payload (verses ascending, de-duped). */ From 20af2809dd0076338e7ce1ba9dcb65ffadb68e5e Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 11:52:56 -0500 Subject: [PATCH 04/12] docs: ADR for YPE-3705 controlled (headless) BibleReader highlights Co-Authored-By: Claude Fable 5 --- docs/adr/YPE-3705-controlled-mode.md | 123 +++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/adr/YPE-3705-controlled-mode.md diff --git a/docs/adr/YPE-3705-controlled-mode.md b/docs/adr/YPE-3705-controlled-mode.md new file mode 100644 index 00000000..610d6928 --- /dev/null +++ b/docs/adr/YPE-3705-controlled-mode.md @@ -0,0 +1,123 @@ +# YPE-3705 — Controlled (headless) BibleReader highlights + +Status: **Accepted** (grilling session 2026-07-20, Dustin + Claude) +Component: `packages/ui/src/components/bible-reader.tsx` (+ `packages/ui/src/lib/highlight-projection.ts`) +Epic: YPE-2894 (RN Expo Highlights) · Blocks: YPE-3710, YPE-3711, YPE-3712 +Related: PR #288 (highlights UI + state machine + API, open) · PR #269 (verse action popover) · YPE-1034 ADR (on PR #288) + +## Context + +Native hosts (the RN Expo SDK embedding the web reader as a DOM component) own +highlight data, auth, and persistence natively. The epic's goal is keeping the +**user token** out of the WebView, so the reader must be able to render +highlights and report highlight taps without performing any highlight +persistence of its own. Bible *content* fetching (passage, books, version +metadata) is app-key catalog traffic, not user data, and is unchanged. + +## Decisions (ADRs) + +### ADR-001 — Presence of `highlights` = controlled mode, latched at first mount + +`BibleReader.Root` gains `highlights?: Highlight[]`. Passing it (including +`[]` = "controlled, nothing highlighted") puts the highlight slice in +controlled mode; never passing it keeps the self-contained posture. The mode is +latched at first mount: flipping presence across renders gets a dev-mode +`console.warn` and is unsupported. After mount, a transient `undefined` on a +controlled reader renders as "no highlights" — it never re-enables +self-contained behavior. + +### ADR-002 — Prop shape is core's `Highlight[]`, projection is the reader's job + +The prop is core's `{ version_id, passage_id, color }` — exactly what +`/v1/highlights` returns — so hosts pipe API/cache data through untouched. The +reader filters by displayed version + chapter, expands range USFMs +(`JHN.3.16-18`), and derives its internal per-verse render map itself +(`packages/ui/src/lib/highlight-projection.ts`). Entries for other versions, +books, or chapters are ignored: every entry carries its full identity, so stale +bridge data can never mispaint. Named `highlights`, **not** `highlightedVerses` +— that name already means the internal `Record` render map. + +### ADR-003 — Pure projection, no optimistic echo + +A color tap paints nothing; the highlight appears only when the host +round-trips an updated `highlights` prop. The native data layer (C2/YPE-3708) +is the only optimistic layer in the system — two optimistic layers would fight +over reconciliation. + +### ADR-004 — Intent events out, single serializable payload per event + +Three new callbacks on `BibleReader.Root`: + +- `onVerseSelect(selection)` — fires in **both** modes on every selection + change; `verses: []` whenever a non-empty selection clears (deselect, + after a highlight/copy/share action, popover dismiss, navigation). It is an + observation. +- `onHighlightApply(intent)` / `onHighlightRemove(intent)` — **controlled mode + only**; they are requests. In self-contained mode the reader owns the tap + and these props are ignored (never called). Remove is scoped to the selected + verses currently showing the tapped color, per the prop-derived map. + +Payloads are single serializable objects of bridge-safe primitives (repo +convention, `BibleReaderShareData` precedent). `passageIds` is always +per-verse — range collapsing is an API-layer optimization, not user intent — +and deliberately redundant with `book`/`chapter`/`verses`: native feeds the API +without USFM string-building; analytics reads `verses` without USFM parsing. + +### ADR-005 — No persistence, no auth surface in controlled mode + +Controlled mode never reads, writes, or migrates the localStorage highlight +store, and (post-#288) never fetches or writes the highlights API. No sign-in +dialog, permission dialog, or redirect can originate from the highlight path. +The prop fully shadows local state, so YPE-1034's localStorage deletion is a +no-op for controlled hosts. `onCopy`/`onShare` are unchanged (already +mode-independent). + +### ADR-006 — Controlled mode bypasses the `HIGHLIGHTS_LIVE` dark-launch flag + +The flag (a PR #288 concept) strictly means "is the self-contained server path +live." The color row is always interactive in controlled mode. Consequence: +the controlled prop surface is public released API while self-contained stays +dark. + +## Rejected alternatives + +- **Explicit mode prop** (`mode="controlled"`): a second axis that can + contradict the data (`mode="controlled"` with no `highlights`, or data with + `mode="selfContained"`). Presence-of-prop is the established controlled/ + uncontrolled idiom in this codebase and in React generally. +- **Chapter-scoped `Record` prop**: pushes filtering, USFM + parsing, and range expansion onto every host, and loses the identity fields + that make stale bridge data harmless. It is also a second public meaning for + the `highlightedVerses` shape/name. +- **Reader-side optimism** (paint on tap, reconcile on prop): two optimistic + layers (reader + native data layer) with no way to agree on failure + semantics. The reader cannot know whether a write succeeded; the host can. +- **Machine-level controlled state** (teaching the XState machine a + `controlled` region): the machine's job is the self-contained auth/write + flow. Controlled mode wants the machine provably inert, which is an enable + guard, not a new state chart region (see integration rules below). +- **Stacking on PR #288**: serializes two large changes and blocks the RN epic + on #288's merge date. Built in parallel on `main` instead; whoever merges + second pays the (small, documented) re-seat. + +## PR #288 integration rules (for whoever merges second) + +- The controlled branch moves from `Content` into the `useBibleReaderHighlights` + adapter: a `controlled` input; when set, the `useHighlights` fetch stays + `enabled: false`, and the machine's enable guard keeps it in `disabled` (the + machine never spawns activity — assert this in tests). +- Controlled mode bypasses `isHighlightsLive()` — the flag gates only the + self-contained path. `highlightsInteractive` is unconditionally `true` in + controlled mode. +- The render-map derivation reuses/parallels `parseServerColors` (same + filter-and-project job, prop-fed instead of fetch-fed). +- `CONTEXT.md`: both branches create it; merge the glossaries (entries here are + written to be word-compatible with #288's). + +## Out of scope (deliberate) + +- Notes, custom colors, >5 colors (epic Decision 9 — fast-follow). +- Self-contained *notification* events (`onHighlightApplied` etc.) — future, + differently-named so intent and fact never share a name. +- Controlled passage/books/version content (the reader keeps fetching content). +- Offline/write-queue concerns — native-side (F1/YPE-3717). From 07b6fd9c8f9153093aa09ac71133433ccb72b9c2 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 12:09:28 -0500 Subject: [PATCH 05/12] docs(adr): slim YPE-3705 ADR to coexistence model and #288 integration rules Co-Authored-By: Claude Fable 5 --- docs/adr/YPE-3705-controlled-mode.md | 147 +++++++++------------------ 1 file changed, 50 insertions(+), 97 deletions(-) diff --git a/docs/adr/YPE-3705-controlled-mode.md b/docs/adr/YPE-3705-controlled-mode.md index 610d6928..0733a3b0 100644 --- a/docs/adr/YPE-3705-controlled-mode.md +++ b/docs/adr/YPE-3705-controlled-mode.md @@ -8,97 +8,53 @@ Related: PR #288 (highlights UI + state machine + API, open) · PR #269 (verse a ## Context Native hosts (the RN Expo SDK embedding the web reader as a DOM component) own -highlight data, auth, and persistence natively. The epic's goal is keeping the -**user token** out of the WebView, so the reader must be able to render -highlights and report highlight taps without performing any highlight -persistence of its own. Bible *content* fetching (passage, books, version -metadata) is app-key catalog traffic, not user data, and is unchanged. - -## Decisions (ADRs) - -### ADR-001 — Presence of `highlights` = controlled mode, latched at first mount - -`BibleReader.Root` gains `highlights?: Highlight[]`. Passing it (including -`[]` = "controlled, nothing highlighted") puts the highlight slice in -controlled mode; never passing it keeps the self-contained posture. The mode is -latched at first mount: flipping presence across renders gets a dev-mode -`console.warn` and is unsupported. After mount, a transient `undefined` on a -controlled reader renders as "no highlights" — it never re-enables -self-contained behavior. - -### ADR-002 — Prop shape is core's `Highlight[]`, projection is the reader's job - -The prop is core's `{ version_id, passage_id, color }` — exactly what -`/v1/highlights` returns — so hosts pipe API/cache data through untouched. The -reader filters by displayed version + chapter, expands range USFMs -(`JHN.3.16-18`), and derives its internal per-verse render map itself -(`packages/ui/src/lib/highlight-projection.ts`). Entries for other versions, -books, or chapters are ignored: every entry carries its full identity, so stale -bridge data can never mispaint. Named `highlights`, **not** `highlightedVerses` -— that name already means the internal `Record` render map. - -### ADR-003 — Pure projection, no optimistic echo - -A color tap paints nothing; the highlight appears only when the host -round-trips an updated `highlights` prop. The native data layer (C2/YPE-3708) -is the only optimistic layer in the system — two optimistic layers would fight -over reconciliation. - -### ADR-004 — Intent events out, single serializable payload per event - -Three new callbacks on `BibleReader.Root`: - -- `onVerseSelect(selection)` — fires in **both** modes on every selection - change; `verses: []` whenever a non-empty selection clears (deselect, - after a highlight/copy/share action, popover dismiss, navigation). It is an - observation. -- `onHighlightApply(intent)` / `onHighlightRemove(intent)` — **controlled mode - only**; they are requests. In self-contained mode the reader owns the tap - and these props are ignored (never called). Remove is scoped to the selected - verses currently showing the tapped color, per the prop-derived map. - -Payloads are single serializable objects of bridge-safe primitives (repo -convention, `BibleReaderShareData` precedent). `passageIds` is always -per-verse — range collapsing is an API-layer optimization, not user intent — -and deliberately redundant with `book`/`chapter`/`verses`: native feeds the API -without USFM string-building; analytics reads `verses` without USFM parsing. - -### ADR-005 — No persistence, no auth surface in controlled mode - -Controlled mode never reads, writes, or migrates the localStorage highlight -store, and (post-#288) never fetches or writes the highlights API. No sign-in -dialog, permission dialog, or redirect can originate from the highlight path. -The prop fully shadows local state, so YPE-1034's localStorage deletion is a -no-op for controlled hosts. `onCopy`/`onShare` are unchanged (already -mode-independent). - -### ADR-006 — Controlled mode bypasses the `HIGHLIGHTS_LIVE` dark-launch flag - -The flag (a PR #288 concept) strictly means "is the self-contained server path -live." The color row is always interactive in controlled mode. Consequence: -the controlled prop surface is public released API while self-contained stays -dark. +highlight data, auth, and persistence natively; the epic's goal is keeping the +**user token** out of the WebView. Bible *content* fetching is app-key catalog +traffic and is unchanged. + +The reader therefore has two postures for its highlight slice (see +`CONTEXT.md`): **self-contained** (YPE-1034 — the reader fetches and writes +highlights through the SDK's own auth session) and **controlled** (this ADR — +the host passes `highlights: Highlight[]` into `BibleReader.Root` and receives +intent events; presence of the prop selects the posture, latched at first +mount). The prop/event surface and its edge semantics are documented by the +public types and the tests in `bible-reader-controlled.test.tsx`; this ADR +records only what the code cannot show. + +## Coexistence model + +Controlled mode performs **no highlight persistence of any kind** — no API +calls, no localStorage, no sign-in/permission dialogs or redirects. The two +postures never mix: a controlled reader's highlight slice is a pure projection +of the prop, and a self-contained reader never calls the intent-event props. +`onVerseSelect` is a mode-independent observation; `onHighlightApply`/ +`onHighlightRemove` are controlled-only requests (a self-contained "applied" +notification would be a different event under a different name). + +## Non-obvious decisions + +- **Pure projection, no optimistic echo.** A color tap paints nothing; the + highlight appears when the host round-trips an updated prop. The native data + layer (YPE-3708) is the only optimistic layer — two optimistic layers cannot + agree on failure semantics, and only the host knows whether a write succeeded. +- **Controlled mode bypasses the `HIGHLIGHTS_LIVE` dark-launch flag** (a + PR #288 concept). The flag strictly means "is the self-contained server path + live"; the color row is always interactive in controlled mode. Consequence: + the controlled prop surface is public released API while self-contained + stays dark. ## Rejected alternatives -- **Explicit mode prop** (`mode="controlled"`): a second axis that can - contradict the data (`mode="controlled"` with no `highlights`, or data with - `mode="selfContained"`). Presence-of-prop is the established controlled/ - uncontrolled idiom in this codebase and in React generally. -- **Chapter-scoped `Record` prop**: pushes filtering, USFM - parsing, and range expansion onto every host, and loses the identity fields - that make stale bridge data harmless. It is also a second public meaning for - the `highlightedVerses` shape/name. -- **Reader-side optimism** (paint on tap, reconcile on prop): two optimistic - layers (reader + native data layer) with no way to agree on failure - semantics. The reader cannot know whether a write succeeded; the host can. -- **Machine-level controlled state** (teaching the XState machine a - `controlled` region): the machine's job is the self-contained auth/write - flow. Controlled mode wants the machine provably inert, which is an enable - guard, not a new state chart region (see integration rules below). -- **Stacking on PR #288**: serializes two large changes and blocks the RN epic - on #288's merge date. Built in parallel on `main` instead; whoever merges - second pays the (small, documented) re-seat. +- **Explicit mode prop** — a second axis that can contradict the data; + presence-of-prop is the established controlled/uncontrolled idiom. +- **Chapter-scoped `Record` prop** — loses the identity fields + that make stale bridge data harmless, and overloads the internal + `highlightedVerses` name. +- **Reader-side optimism** — see above. +- **Machine-level controlled state** — controlled mode wants the machine + provably inert; that is an enable guard, not a statechart region. +- **Stacking on PR #288** — would gate the RN epic on #288's merge date; built + in parallel on `main` instead, with the re-seat documented below. ## PR #288 integration rules (for whoever merges second) @@ -106,18 +62,15 @@ dark. adapter: a `controlled` input; when set, the `useHighlights` fetch stays `enabled: false`, and the machine's enable guard keeps it in `disabled` (the machine never spawns activity — assert this in tests). -- Controlled mode bypasses `isHighlightsLive()` — the flag gates only the - self-contained path. `highlightsInteractive` is unconditionally `true` in - controlled mode. +- Controlled mode bypasses `isHighlightsLive()`; `highlightsInteractive` is + unconditionally `true` in controlled mode. - The render-map derivation reuses/parallels `parseServerColors` (same filter-and-project job, prop-fed instead of fetch-fed). - `CONTEXT.md`: both branches create it; merge the glossaries (entries here are written to be word-compatible with #288's). -## Out of scope (deliberate) +## Out of scope -- Notes, custom colors, >5 colors (epic Decision 9 — fast-follow). -- Self-contained *notification* events (`onHighlightApplied` etc.) — future, - differently-named so intent and fact never share a name. -- Controlled passage/books/version content (the reader keeps fetching content). -- Offline/write-queue concerns — native-side (F1/YPE-3717). +Notes, custom colors, >5 colors (epic fast-follow); self-contained notification +events; controlled *content* (the reader keeps fetching passages/books/versions); +offline/write-queue concerns (native-side, YPE-3717). From 82839d3e98da637508a4b7183530b1f227f24820 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 12:12:05 -0500 Subject: [PATCH 06/12] docs(adr): defer glossary entries to #288's CONTEXT.md Co-Authored-By: Claude Fable 5 --- docs/adr/YPE-3705-controlled-mode.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/adr/YPE-3705-controlled-mode.md b/docs/adr/YPE-3705-controlled-mode.md index 0733a3b0..76660432 100644 --- a/docs/adr/YPE-3705-controlled-mode.md +++ b/docs/adr/YPE-3705-controlled-mode.md @@ -12,8 +12,8 @@ highlight data, auth, and persistence natively; the epic's goal is keeping the **user token** out of the WebView. Bible *content* fetching is app-key catalog traffic and is unchanged. -The reader therefore has two postures for its highlight slice (see -`CONTEXT.md`): **self-contained** (YPE-1034 — the reader fetches and writes +The reader therefore has two postures for its highlight slice: +**self-contained** (YPE-1034 — the reader fetches and writes highlights through the SDK's own auth session) and **controlled** (this ADR — the host passes `highlights: Highlight[]` into `BibleReader.Root` and receives intent events; presence of the prop selects the posture, latched at first @@ -66,8 +66,11 @@ notification would be a different event under a different name). unconditionally `true` in controlled mode. - The render-map derivation reuses/parallels `parseServerColors` (same filter-and-project job, prop-fed instead of fetch-fed). -- `CONTEXT.md`: both branches create it; merge the glossaries (entries here are - written to be word-compatible with #288's). +- `CONTEXT.md` arrives with #288 (its glossary already defines "Controlled + mode" as planned): graduate that entry to decided and add a "Highlight + intent" entry (the reader's request that a highlight be applied/removed — + always intent, never completed fact; intent and fact are deliberately never + given the same name). ## Out of scope From c63a2ced6408e5e7d1a99042ddbf5451996201cd Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 12:20:10 -0500 Subject: [PATCH 07/12] docs(adr): record why the reader is not fully headless Co-Authored-By: Claude Fable 5 --- docs/adr/YPE-3705-controlled-mode.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/adr/YPE-3705-controlled-mode.md b/docs/adr/YPE-3705-controlled-mode.md index 76660432..c73f787c 100644 --- a/docs/adr/YPE-3705-controlled-mode.md +++ b/docs/adr/YPE-3705-controlled-mode.md @@ -45,6 +45,13 @@ notification would be a different event under a different name). ## Rejected alternatives +- **Fully headless reader (pure projection for everything)** — would require + the host to supply passage HTML, books, and version metadata as props, + tripling the bridge surface for app-key catalog data that isn't user data. + The pure-presentational layer already exists below (`BibleTextView`); the + epic's goal is native ownership of *user data and tokens*, so only the + highlight slice becomes a pure projection while the reader keeps fetching + content. - **Explicit mode prop** — a second axis that can contradict the data; presence-of-prop is the established controlled/uncontrolled idiom. - **Chapter-scoped `Record` prop** — loses the identity fields From 822269e4d2ba33a10fe5b74b2205134e40d73b0a Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 13:50:31 -0500 Subject: [PATCH 08/12] chore(ui): slim narrative comments in bible-reader Co-Authored-By: Claude Fable 5 --- packages/ui/src/components/bible-reader.tsx | 43 +++++++-------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index ea63552a..5261979e 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -371,10 +371,8 @@ function Root({ onHighlightRemove, children, }: RootProps) { - // Controlled mode is latched at first mount (presence of the `highlights` - // prop). Flipping controlled <-> self-contained across renders is - // unsupported: once controlled, a transient `undefined` renders as "no - // highlights" and never re-enables the self-contained localStorage store. + // Latched at first mount: a transient `undefined` on a controlled reader must + // render as "no highlights", never re-enable the localStorage store. const isHighlightsControlledRef = useRef(highlights !== undefined); const isHighlightsControlled = isHighlightsControlledRef.current; const didWarnHighlightsModeFlipRef = useRef(false); @@ -619,13 +617,10 @@ function Content() { }, [book, chapter]); // ---- Verse selection + highlights ------------------------------------------ - // Selection is ephemeral; in self-contained mode highlights persist to - // localStorage only for now (ADR-001) in the future `highlight` API shape: - // keyed by full passage_id USFM and scoped by versionId/bible_id (ADR-002). - // In controlled mode (YPE-3705) the store is never read or written: the - // render map derives purely from the host's `highlights` prop, and color / - // clear taps emit intents instead of mutating anything. The reader DOM ref - // lets us anchor the popover and pull clean verse text for Copy / Share. + // Selection is ephemeral. Self-contained mode persists highlights to + // localStorage in the future API shape (passage_id USFM keys, per version — + // YPE-642 ADR-001/002); controlled mode (YPE-3705) never touches the store. + // The reader DOM ref anchors the popover and supplies clean verse text. const readerRef = useRef(null); const [selectedVerses, setSelectedVerses] = useState([]); const [popoverOpen, setPopoverOpen] = useState(false); @@ -645,10 +640,7 @@ function Content() { setHighlightStore({}); } - // Load this version's highlights when the version changes (client-only). - // Controlled mode never touches the store: the localStorage key is neither - // read nor written, so YPE-1034's store deletion is a no-op for controlled - // hosts. + // Self-contained only: load this version's highlights (client-only). useEffect(() => { if (isHighlightsControlled) return; let data: Record = {}; @@ -661,13 +653,13 @@ function Content() { setHighlightStore(data); }, [highlightsStorageKey, isHighlightsControlled]); - // Latest onVerseSelect without making it an effect dependency (an inline - // handler would otherwise re-clear the selection every render). + // Read via ref in the navigation effect so an inline callback prop doesn't + // retrigger it every render. const onVerseSelectRef = useRef(onVerseSelect); onVerseSelectRef.current = onVerseSelect; - // Navigating away (book/chapter/version) drops the selection — those verses no - // longer exist on screen (ADR-007). Observing hosts hear the clear. + // Navigating away (book/chapter/version) drops the selection — those verses + // no longer exist on screen (ADR-007). useEffect(() => { setSelectedVerses([]); setPopoverOpen(false); @@ -678,9 +670,7 @@ function Content() { } }, [book, chapter, versionId]); - // Derive the visible chapter's highlights (verse number → hex): in - // controlled mode purely from the host's `highlights` prop, otherwise from - // the localStorage-backed store. + // The visible chapter's highlights (verse number → hex). const chapterPrefix = `${book}.${chapter}.`; const highlightedVerses = useMemo(() => { if (isHighlightsControlled) { @@ -718,8 +708,7 @@ function Content() { setPopoverOpen(false); setSelectedVerses([]); setAnchorElement(null); - // Clearing a non-empty selection is a selection change: observing hosts - // hear `verses: []` (the deselect-tap path emits via handleVerseSelect). + // The deselect-tap path emits its own `[]` via handleVerseSelect. if (lastSelectionRef.current.length > 0) { lastSelectionRef.current = []; onVerseSelect?.(buildVerseSelection([])); @@ -742,8 +731,6 @@ function Content() { const added = verses.find((verse) => !lastSelectionRef.current.includes(verse)); lastSelectionRef.current = verses; setSelectedVerses(verses); - - // Observation, not a request: fires in both modes, `[]` on clear. onVerseSelect?.(buildVerseSelection(verses)); if (verses.length === 0) { @@ -764,8 +751,6 @@ function Content() { function handleHighlight(color: string) { if (isHighlightsControlled) { - // Pure projection: emit the intent and paint nothing — the highlight - // appears only when the host round-trips an updated `highlights` prop. onHighlightApply?.({ ...buildVerseSelection(selectedVerses), color }); closeAndClearSelection(); return; @@ -781,8 +766,6 @@ function Content() { function handleClearHighlight(color: string) { if (isHighlightsControlled) { - // Scope the intent to the selected verses currently showing this color - // (per the prop-derived map). const versesToClear = selectedVerses.filter((verse) => highlightedVerses[verse] === color); if (versesToClear.length > 0) { onHighlightRemove?.({ ...buildVerseSelection(versesToClear), color }); From 7102d6f40c4d04c96470a4ba0a5cca4cd2fd7588 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 14:11:25 -0500 Subject: [PATCH 09/12] fix(ui): ignore controlled highlight colors outside the built-in swatches A color the verse-action popover can't offer removal for must not paint; otherwise a host-supplied rogue color (valid per the Highlight schema) would render as an un-removable highlight. Co-Authored-By: Claude Fable 5 --- .../components/bible-reader-controlled.test.tsx | 11 +++++++++++ packages/ui/src/components/bible-reader.tsx | 8 ++++++-- packages/ui/src/lib/highlight-projection.test.ts | 16 ++++++++++++++++ packages/ui/src/lib/highlight-projection.ts | 9 ++++++++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/bible-reader-controlled.test.tsx b/packages/ui/src/components/bible-reader-controlled.test.tsx index 8e4c6e9b..d53b93ef 100644 --- a/packages/ui/src/components/bible-reader-controlled.test.tsx +++ b/packages/ui/src/components/bible-reader-controlled.test.tsx @@ -224,6 +224,17 @@ describe('BibleReader controlled mode - pure projection', () => { } }); + it('ignores entries with colors outside the built-in swatches (no un-removable paint)', () => { + const highlights: Highlight[] = [ + { version_id: 111, passage_id: 'JHN.1.1', color: 'abcdef' }, + { version_id: 111, passage_id: 'JHN.1.2', color: YELLOW }, + ]; + const { container } = renderReader({ highlights }); + + expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); + expect(getVerseEl(container, 2).style.backgroundColor).toBe(fillFor(YELLOW)); + }); + it('re-projects when the highlights prop changes (host round-trip)', () => { const { container, rerender } = renderReader({ highlights: [] }); expect(getVerseEl(container, 1).style.backgroundColor).toBe(''); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 5261979e..fa27ab3a 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -38,7 +38,7 @@ import { PersonIcon } from './icons/person'; import { ProfileAvatar } from './profile-avatar'; import { Button } from './ui/button'; import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from './ui/popover'; -import { VerseActionPopover } from './verse-action-popover'; +import { HIGHLIGHT_COLORS, VerseActionPopover } from './verse-action-popover'; import { BibleTextView, getCleanVerseText, type FootnoteData } from './verse'; import { buildVerseReference, buildVerseShareText, joinVerseTexts } from '@/lib/verse-share'; import { deriveHighlightedVerses } from '@/lib/highlight-projection'; @@ -198,6 +198,10 @@ export type RootProps = { * self-contained across renders is unsupported (dev warning). `[]` means * "controlled, nothing highlighted"; leaving the prop off means * self-contained. + * + * Entries whose color is outside the reader's five built-in swatches are + * ignored — the verse-action popover can only offer removal for its own + * palette, so an unmanageable color must not paint. */ highlights?: Highlight[]; /** @@ -674,7 +678,7 @@ function Content() { const chapterPrefix = `${book}.${chapter}.`; const highlightedVerses = useMemo(() => { if (isHighlightsControlled) { - return deriveHighlightedVerses(highlights ?? [], versionId, book, chapter); + return deriveHighlightedVerses(highlights ?? [], versionId, book, chapter, HIGHLIGHT_COLORS); } const map: Record = {}; for (const [passageId, color] of Object.entries(highlightStore)) { diff --git a/packages/ui/src/lib/highlight-projection.test.ts b/packages/ui/src/lib/highlight-projection.test.ts index 96194cf8..1608ac4f 100644 --- a/packages/ui/src/lib/highlight-projection.test.ts +++ b/packages/ui/src/lib/highlight-projection.test.ts @@ -75,6 +75,22 @@ describe('deriveHighlightedVerses', () => { expect(map).toEqual({ 16: 'fffe00', 18: '5dff79', 19: '5dff79' }); }); + it('ignores colors outside allowedColors, matching case-insensitively', () => { + const map = deriveHighlightedVerses( + [highlight(111, 'JHN.3.16', 'abcdef'), highlight(111, 'JHN.3.17', 'FFFE00')], + 111, + 'JHN', + '3', + ['fffe00'], + ); + expect(map).toEqual({ 17: 'fffe00' }); + }); + + it('accepts any color when allowedColors is omitted', () => { + const map = deriveHighlightedVerses([highlight(111, 'JHN.3.16', 'abcdef')], 111, 'JHN', '3'); + expect(map).toEqual({ 16: 'abcdef' }); + }); + it('ignores entries for other versions', () => { const map = deriveHighlightedVerses([highlight(222, 'JHN.3.16', 'fffe00')], 111, 'JHN', '3'); expect(map).toEqual({}); diff --git a/packages/ui/src/lib/highlight-projection.ts b/packages/ui/src/lib/highlight-projection.ts index d0fc9d19..640a93bf 100644 --- a/packages/ui/src/lib/highlight-projection.ts +++ b/packages/ui/src/lib/highlight-projection.ts @@ -51,20 +51,27 @@ export function expandPassageId(passageId: string): ExpandedPassageId | null { * carries its full identity, so stale host data can never mispaint. Range * passage ids are expanded per verse. Colors are normalized to lowercase (the * API accepts uppercase at the boundary). Later entries win on collisions. + * + * When `allowedColors` is given, entries with any other color are ignored too: + * the verse-action popover can only offer removal for its own swatches, so a + * color the reader can't manage must not paint (it would be un-removable). */ export function deriveHighlightedVerses( highlights: readonly Highlight[], versionId: number, book: string, chapter: string, + allowedColors?: readonly string[], ): Record { const map: Record = {}; for (const { version_id, passage_id, color } of highlights) { if (version_id !== versionId) continue; + const normalizedColor = color.toLowerCase(); + if (allowedColors && !allowedColors.includes(normalizedColor)) continue; const expanded = expandPassageId(passage_id); if (!expanded || expanded.book !== book || expanded.chapter !== chapter) continue; for (const verse of expanded.verses) { - map[verse] = color.toLowerCase(); + map[verse] = normalizedColor; } } return map; From 665b6063d3f21c675b71e89fdd6b4f6499ed0f02 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Mon, 20 Jul 2026 14:27:09 -0500 Subject: [PATCH 10/12] fix(ui): expand range passage ids in the fake-host story before matching intents API data may hold range USFMs; matching them as opaque strings against per-verse intent passageIds silently no-ops, and a partial removal must split the range. Seed the story with a range entry so the reference implementation demonstrates the normalization. Co-Authored-By: Claude Fable 5 --- .../src/components/bible-reader.stories.tsx | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 6c37451b..5f9c44d8 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -1,3 +1,4 @@ +import { expandPassageId } from '@/lib/highlight-projection'; import { INTER_FONT, SOURCE_SERIF_FONT } from '@/lib/verse-html-utils'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Highlight } from '@youversion/platform-core'; @@ -841,7 +842,24 @@ export const Controlled: Story = { ), }; -/** True when a stored highlight entry is covered by an intent's verses. */ +/** + * Splits a stored highlight into per-verse entries so it can be matched + * against an intent's per-verse `passageIds`. API data may hold range USFMs + * (`JHN.1.3-5`); matching those as opaque strings would silently miss, and a + * partial removal (one verse out of a range) must split the range, not drop + * it. Unexpandable ids pass through untouched. + */ +const toPerVerse = (h: Highlight): Highlight[] => { + const expanded = expandPassageId(h.passage_id); + if (!expanded) return [h]; + return expanded.verses.map((verse) => ({ + version_id: h.version_id, + passage_id: `${expanded.book}.${expanded.chapter}.${verse}`, + color: h.color, + })); +}; + +/** True when a per-verse stored entry is covered by an intent's verses. */ const matchesIntent = (h: Highlight, intent: BibleReaderHighlightIntent) => h.version_id === intent.versionId && intent.passageIds.includes(h.passage_id); @@ -863,22 +881,21 @@ export const ControlledFakeHost: Story = { onHighlightRemove: fn(), }, render: function FakeHostStory(args: BibleReaderRootProps) { - // The host's own highlight store, kept per-verse so remove intents (always - // per-verse `passageIds`) match entries directly. A real native host owns - // this in its data layer (with the API, cache, and optimism behind it); - // the reader only ever sees the resulting array. + // The host's own highlight store. A real native host owns this in its data + // layer (with the API, cache, and optimism behind it); the reader only ever + // sees the resulting array. Seeded with a range USFM on purpose — API data + // can hold ranges, and intents are per-verse, so every write normalizes the + // store through `toPerVerse` before matching. const [highlights, setHighlights] = useState([ { version_id: 111, passage_id: 'JHN.1.1', color: 'fffe00' }, - { version_id: 111, passage_id: 'JHN.1.3', color: '5dff79' }, - { version_id: 111, passage_id: 'JHN.1.4', color: '5dff79' }, - { version_id: 111, passage_id: 'JHN.1.5', color: '5dff79' }, + { version_id: 111, passage_id: 'JHN.1.3-5', color: '5dff79' }, ]); const handleApply = (intent: BibleReaderHighlightIntent) => { args.onHighlightApply?.(intent); setHighlights((prev) => [ // Replace any existing entry for these verses (last write wins). - ...prev.filter((h) => !matchesIntent(h, intent)), + ...prev.flatMap(toPerVerse).filter((h) => !matchesIntent(h, intent)), ...intent.passageIds.map((passage_id) => ({ version_id: intent.versionId, passage_id, @@ -889,7 +906,7 @@ export const ControlledFakeHost: Story = { const handleRemove = (intent: BibleReaderHighlightIntent) => { args.onHighlightRemove?.(intent); - setHighlights((prev) => prev.filter((h) => !matchesIntent(h, intent))); + setHighlights((prev) => prev.flatMap(toPerVerse).filter((h) => !matchesIntent(h, intent))); }; return ( From c68dc003ba11596a2a6a0ac823ad7ddfdbc351e6 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 21 Jul 2026 14:17:08 -0500 Subject: [PATCH 11/12] fix(ui): use IS_PRODUCTION constant for environment checks --- packages/ui/src/components/bible-reader.tsx | 19 ++++++++++--------- packages/ui/src/lib/constants.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/lib/constants.ts diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 522af84f..8a8ac7fd 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -1,6 +1,7 @@ 'use client'; 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'; @@ -127,11 +128,6 @@ export type BibleReaderShareData = { versionId: number; }; -// `process` is not typed in this browser-targeting build. Consumers' bundlers -// statically replace `process.env.NODE_ENV`; the `typeof` guard keeps -// unbundled browser environments (no `process` global) safe at runtime. -declare const process: { env: { NODE_ENV?: string } }; - const BibleReaderContext = createContext(null); function useBibleReaderContext() { @@ -210,6 +206,10 @@ export type RootProps = { * deselecting, after a highlight/copy/share action, on popover dismiss, or * on navigation. Fires in both controlled and self-contained modes — it is * an observation, not a request. + * + * A clear fired by navigation carries the *destination* book/chapter/version + * (the selection cleared because the reader moved there). Treat `verses: []` + * as "no selection anywhere" rather than keying off its location fields. */ onVerseSelect?: (selection: BibleReaderVerseSelection) => void; /** @@ -381,8 +381,7 @@ function Root({ const isHighlightsControlled = isHighlightsControlledRef.current; const didWarnHighlightsModeFlipRef = useRef(false); if ( - typeof process !== 'undefined' && - process.env.NODE_ENV !== 'production' && + !IS_PRODUCTION && (highlights !== undefined) !== isHighlightsControlled && !didWarnHighlightsModeFlipRef.current ) { @@ -681,10 +680,12 @@ function Content() { setPopoverOpen(false); setSelectedVerses([]); setAnchorElement(null); - // The deselect-tap path emits its own `[]` via handleVerseSelect. + // The deselect-tap path emits its own `[]` via handleVerseSelect. Read via + // ref: async callers (navigator.share().then) capture this closure, and a + // host-swapped callback must not go stale on that path. if (lastSelectionRef.current.length > 0) { lastSelectionRef.current = []; - onVerseSelect?.(buildVerseSelection([])); + onVerseSelectRef.current?.(buildVerseSelection([])); } } diff --git a/packages/ui/src/lib/constants.ts b/packages/ui/src/lib/constants.ts new file mode 100644 index 00000000..9eb34b90 --- /dev/null +++ b/packages/ui/src/lib/constants.ts @@ -0,0 +1,10 @@ +// `process` is not typed in this browser-targeting build. Consumers' bundlers +// statically replace `process.env.NODE_ENV` at build time, so this whole +// expression folds to a constant; the `typeof` guard treats unbundled browser +// environments (no `process` global) as production so dev-only behavior stays +// off there. +declare const process: { env: { NODE_ENV?: string } }; + +/** True in production builds. Dev-only warnings gate on `!IS_PRODUCTION`. */ +export const IS_PRODUCTION = + typeof process === 'undefined' || process.env.NODE_ENV === 'production'; From 7e4a2ebd531f5834ad8b3f0b9350a7256b1f36ea Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 21 Jul 2026 14:20:04 -0500 Subject: [PATCH 12/12] docs(adr): update controlled mode integration details --- docs/adr/YPE-3705-controlled-mode.md | 31 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/adr/YPE-3705-controlled-mode.md b/docs/adr/YPE-3705-controlled-mode.md index c73f787c..5c545916 100644 --- a/docs/adr/YPE-3705-controlled-mode.md +++ b/docs/adr/YPE-3705-controlled-mode.md @@ -61,23 +61,24 @@ notification would be a different event under a different name). - **Machine-level controlled state** — controlled mode wants the machine provably inert; that is an enable guard, not a statechart region. - **Stacking on PR #288** — would gate the RN epic on #288's merge date; built - in parallel on `main` instead, with the re-seat documented below. + in parallel on `main` instead. The re-seat onto the shared adapter has since + landed (see below). -## PR #288 integration rules (for whoever merges second) +## Integration with the self-contained path (landed) -- The controlled branch moves from `Content` into the `useBibleReaderHighlights` - adapter: a `controlled` input; when set, the `useHighlights` fetch stays - `enabled: false`, and the machine's enable guard keeps it in `disabled` (the - machine never spawns activity — assert this in tests). -- Controlled mode bypasses `isHighlightsLive()`; `highlightsInteractive` is - unconditionally `true` in controlled mode. -- The render-map derivation reuses/parallels `parseServerColors` (same - filter-and-project job, prop-fed instead of fetch-fed). -- `CONTEXT.md` arrives with #288 (its glossary already defines "Controlled - mode" as planned): graduate that entry to decided and add a "Highlight - intent" entry (the reader's request that a highlight be applied/removed — - always intent, never completed fact; intent and fact are deliberately never - given the same name). +Controlled mode is seated in the `useBibleReaderHighlights` adapter (the +reader's single seam onto highlights, shared with YPE-1034's self-contained +path) via a `controlled` input, latched by `Root` at first mount: + +- When `controlled` is set, the `useHighlights` fetch stays `enabled: false` + and the adapter performs no writes and keeps no optimistic overlay — + controlled mode is a pure projection of the host's highlights + (`deriveHighlightedVerses`) plus intent forwarding from `apply`/`remove`. +- Controlled mode bypasses `isHighlightsLive()` — the dark-launch flag gates + only the self-contained server path, so the color row stays interactive and + the controlled prop surface ships while self-contained remains dark. +- The glossary (`CONTEXT.md`) defines "Controlled mode" and "Highlight + intent"; intent and fact are deliberately never given the same name. ## Out of scope