Skip to content

feat(ui): controlled (headless) highlights mode on BibleReader (YPE-3705)#293

Merged
Dustin-Kelley merged 15 commits into
mainfrom
dk/ype-3705-abstract-bible-reader-into-headless
Jul 22, 2026
Merged

feat(ui): controlled (headless) highlights mode on BibleReader (YPE-3705)#293
Dustin-Kelley merged 15 commits into
mainfrom
dk/ype-3705-abstract-bible-reader-into-headless

Conversation

@Dustin-Kelley

@Dustin-Kelley Dustin-Kelley commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a controlled mode to BibleReader: a host application can pass highlight data in as a prop and receive highlight-intent events out, and the reader performs no highlight persistence of any kind — no API calls, no localStorage, no auth surface. This is W1 in the RN Expo Highlights epic (YPE-2894): the cross-SDK enabler that lets the RN Expo SDK render the web reader as a DOM component while owning highlight data, auth, and persistence natively (blocks YPE-3710, YPE-3711, YPE-3712).

New public API on BibleReader.Root:

  • highlights?: Highlight[] — core's { version_id, passage_id, color }, exactly what /v1/highlights returns, so hosts pipe API/cache data through untouched. Presence of this prop = controlled mode, latched at first mount ([] means "controlled, nothing highlighted"; never passing it keeps today's self-contained behavior byte-for-byte).
  • onVerseSelect(selection) — fires in both modes on every selection change (verses: [] on clear).
  • onHighlightApply(intent) / onHighlightRemove(intent)controlled mode only; single serializable payloads (bridge-safe primitives, per-verse passageIds).

In controlled mode the highlight slice is a pure projection: the reader filters the prop by displayed version + chapter, expands range USFMs (JHN.3.16-18), and paints — a color tap emits an intent and paints nothing until the host round-trips an updated prop (the native data layer owns optimism).

Why

Designed in a grilling session against the RN Expo Highlights epic plan (locked decision: native owns the highlights API/cache/state machine; the DOM reader makes zero highlight API calls, keeping the user token out of the WebView). Full rationale, rejected alternatives, and PR #288 integration rules are in the ADR: docs/adr/YPE-3705-controlled-mode.md.

Why controlled/uncontrolled rather than a fully headless (pure-projection) reader: a fully headless reader 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), and the epic's goal is native ownership of user data and tokens — so only the highlight slice becomes a pure projection (data in via highlights, intents out via events), while the reader keeps fetching content itself.

Relationship to #288

Deliberately built in parallel on main, not stacked on #288 (per Cam's call) — this touches the current localStorage-based highlight path only. The ADR's "PR #288 integration rules" section documents the mechanical re-seat for whoever merges second (controlled branch moves into the useBibleReaderHighlights adapter; machine stays disabled; controlled mode bypasses HIGHLIGHTS_LIVE).

Reviewer notes

  • Self-contained (no highlights prop) behavior is unchanged; existing tests pass unmodified.
  • packages/ui/src/lib/highlight-projection.ts is the USFM→render-map projection, unit-tested separately (18 tests); 16 integration tests cover projection, provable inertness (zero network + zero localStorage in controlled mode), exact event payloads, and mode latching.
  • Controlled entries whose color is outside the five built-in swatches are ignored — the popover can only offer removal for its own palette, so an unmanageable color must not paint (caught in review).
  • Two new Storybook stories: Controlled (args-driven) and Controlled (fake host) — the latter is the reference round-trip implementation for RN's YPE-3710.
  • Glossary entries ("Controlled mode", "Highlight intent") are deliberately not added here — CONTEXT.md arrives with [don't review this... it's getting split into smol prs] Highlights for React Web SDK - UI, state machine, and API #288; the ADR's integration rules say to add/graduate the entries there at merge time.
  • Changeset: minor (unified versioning bumps all three packages).
  • Local dev note: on Node 26, vitest needs NODE_OPTIONS=--no-experimental-webstorage (Node's new localStorage global shadows jsdom's); CI on Node 24 is unaffected.

🤖 Generated with Claude Code

Greptile Summary

This PR adds a controlled highlights mode to BibleReader (YPE-3705): when a host passes highlights?: Highlight[], the reader becomes a pure projection — no network calls, no localStorage, no auth surface — and emits intents via onHighlightApply / onHighlightRemove instead of writing to the API. Self-contained behavior (no highlights prop) is byte-for-byte unchanged.

  • highlight-projection.ts implements USFM range expansion (expandPassageId) and the verse-render-map derivation (deriveHighlightedVerses), both with thorough unit tests (18 cases).
  • useBibleReaderHighlights gains a controlled input: the machine is kept inert (flagOn: false), useHighlights stays enabled: false, and apply/remove forward serializable bridge-safe intents instead of touching the API or optimistic overlay.
  • BibleReader.Root latches the mode at first mount, warns in dev on flip, and threads the new props through context; 16 integration tests cover projection, provable inertness, intent payloads, and mode latching.
  • Two Storybook stories — Controlled (args-driven) and Controlled (fake host) — serve as living documentation, with the fake-host story acting as the executable reference round-trip for RN Expo (YPE-3710).

Confidence Score: 5/5

Safe to merge — self-contained behavior is unchanged and the controlled-mode additions are well-isolated behind a mount-latched flag with no auth surface.

The implementation correctly latches mode at first mount and never mixes the two paths. The previous concern about range passage_id entries in the fake-host story is resolved: both handleApply and handleRemove call flatMap(toPerVerse) before matchesIntent, so range USFMs in the host store are expanded before comparison against per-verse intent passageIds. No network writes, no localStorage accesses, and no optimistic echo leak into the controlled path, as proven by the inertness tests.

No files require special attention — the core projection logic in highlight-projection.ts and the adapter changes in use-bible-reader-highlights.ts are the most load-bearing and both are well-covered by tests.

Important Files Changed

Filename Overview
packages/ui/src/lib/highlight-projection.ts New utility: USFM range expansion (expandPassageId) and verse-render-map projection (deriveHighlightedVerses). Handles malformed input, reversed ranges, verse 0, and implausibly large ranges defensively.
packages/ui/src/components/use-bible-reader-highlights.ts Controlled mode wired into the hook: fetch kept disabled, highlightedVerses is a pure projection, apply/remove emit intents with no optimistic paint. Refs used correctly for stable callbacks.
packages/ui/src/components/bible-reader.tsx Root latches mode at first mount via useRef, dev-warns on flip. onVerseSelect callback read via ref for async paths. buildVerseSelection correctly de-dupes and sorts verses.
packages/ui/src/components/bible-reader.stories.tsx ControlledFakeHost correctly calls flatMap(toPerVerse) before matchesIntent in both handleApply and handleRemove — the previous concern about silent no-ops on range entries is addressed.
packages/ui/src/components/bible-reader-controlled.test.tsx 16 integration tests cover projection, provable inertness (no network/localStorage), event payloads, and mode latching.
packages/ui/src/lib/highlight-projection.test.ts 18 unit tests covering single-verse, range, numbered-book codes, malformed input, reversed ranges, verse 0, collision resolution, and color normalization.
packages/ui/src/lib/constants.ts New IS_PRODUCTION constant using typeof process guard for unbundled browser environments. Correctly evaluates to false in test environments.
.changeset/bible-reader-controlled-mode.md Minor bump for @youversion/platform-react-ui only; unified versioning auto-bumps remaining packages per repo tooling.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Host as Native Host (RN/Expo)
    participant Root as BibleReader.Root
    participant Content as BibleReader.Content
    participant Hook as useBibleReaderHighlights
    participant Proj as deriveHighlightedVerses

    Host->>Root: "highlights=[...], onHighlightApply, onHighlightRemove"
    Root->>Root: "latch isHighlightsControlled=true (first mount)"
    Root->>Content: passes highlights, callbacks via context
    Content->>Hook: "controlled={highlights, onApply, onRemove}"
    Hook->>Hook: "flagOn=false, useHighlights(enabled:false)"
    Hook->>Proj: deriveHighlightedVerses(highlights, versionId, book, chapter, HIGHLIGHT_COLORS)
    Proj-->>Hook: "highlightedVerses map {verse->color}"
    Hook-->>Content: highlightedVerses (pure projection)
    Note over Content: User selects verses and taps a color
    Content->>Hook: apply(color, selectedVerses)
    Hook->>Hook: buildHighlightIntent(...)
    Hook->>Host: "onHighlightApply({versionId, book, chapter, verses, passageIds, color})"
    Hook-->>Content: return 'applied' (no optimistic paint)
    Content->>Content: "clearPopover() -> onVerseSelect({verses:[]})"
    Note over Host: Host updates its data layer
    Host->>Root: "highlights=[...updated...]"
    Hook->>Proj: re-project
    Proj-->>Content: updated highlightedVerses (verse now painted)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Host as Native Host (RN/Expo)
    participant Root as BibleReader.Root
    participant Content as BibleReader.Content
    participant Hook as useBibleReaderHighlights
    participant Proj as deriveHighlightedVerses

    Host->>Root: "highlights=[...], onHighlightApply, onHighlightRemove"
    Root->>Root: "latch isHighlightsControlled=true (first mount)"
    Root->>Content: passes highlights, callbacks via context
    Content->>Hook: "controlled={highlights, onApply, onRemove}"
    Hook->>Hook: "flagOn=false, useHighlights(enabled:false)"
    Hook->>Proj: deriveHighlightedVerses(highlights, versionId, book, chapter, HIGHLIGHT_COLORS)
    Proj-->>Hook: "highlightedVerses map {verse->color}"
    Hook-->>Content: highlightedVerses (pure projection)
    Note over Content: User selects verses and taps a color
    Content->>Hook: apply(color, selectedVerses)
    Hook->>Hook: buildHighlightIntent(...)
    Hook->>Host: "onHighlightApply({versionId, book, chapter, verses, passageIds, color})"
    Hook-->>Content: return 'applied' (no optimistic paint)
    Content->>Content: "clearPopover() -> onVerseSelect({verses:[]})"
    Note over Host: Host updates its data layer
    Host->>Root: "highlights=[...updated...]"
    Hook->>Proj: re-project
    Proj-->>Content: updated highlightedVerses (verse now painted)
Loading

Comments Outside Diff (1)

  1. packages/ui/src/components/bible-reader.stories.tsx, line 692-693 (link)

    P1 matchesIntent silently fails for range passage_id entries

    intent.passageIds contains only per-verse IDs (e.g., ['JHN.1.3', 'JHN.1.4', 'JHN.1.5']). If the host's store holds a range entry like { passage_id: 'JHN.1.3-5', ... } — valid API data, and the changeset says hosts can "pipe API/cache data through untouched" — intent.passageIds.includes('JHN.1.3-5') returns false, handleRemove silently no-ops, and the reader keeps painting those verses on every subsequent round-trip. The story comment says "kept per-verse," but ControlledFakeHost is the "executable reference implementation for RN Expo YPE-3710" — a host that stores ranges and copies this helper verbatim will have a silent correctness bug. The already-exported expandPassageId could be used here to expand the stored passage_id before matching against the per-verse intent passageIds.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/ui/src/components/bible-reader.stories.tsx
    Line: 692-693
    
    Comment:
    **`matchesIntent` silently fails for range `passage_id` entries**
    
    `intent.passageIds` contains only per-verse IDs (e.g., `['JHN.1.3', 'JHN.1.4', 'JHN.1.5']`). If the host's store holds a range entry like `{ passage_id: 'JHN.1.3-5', ... }` — valid API data, and the changeset says hosts can "pipe API/cache data through untouched" — `intent.passageIds.includes('JHN.1.3-5')` returns `false`, `handleRemove` silently no-ops, and the reader keeps painting those verses on every subsequent round-trip. The story comment says "kept per-verse," but `ControlledFakeHost` is the "executable reference implementation for RN Expo YPE-3710" — a host that stores ranges and copies this helper verbatim will have a silent correctness bug. The already-exported `expandPassageId` could be used here to expand the stored `passage_id` before matching against the per-verse intent `passageIds`.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Cursor Fix in Codex

Reviews (7): Last reviewed commit: "Merge branch 'main' into dk/ype-3705-abs..." | Re-trigger Greptile

Dustin-Kelley and others added 2 commits July 20, 2026 11:32
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9c37be0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@youversion/platform-react-ui Minor
vite-react Patch
@youversion/platform-core Minor
@youversion/platform-react-hooks Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Dustin-Kelley and others added 4 commits July 20, 2026 12:11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n rules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Dustin-Kelley
Dustin-Kelley force-pushed the dk/ype-3705-abstract-bible-reader-into-headless branch from 4d49540 to 82839d3 Compare July 20, 2026 17:12
Dustin-Kelley and others added 2 commits July 20, 2026 12:20
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Dustin-Kelley
Dustin-Kelley marked this pull request as ready for review July 20, 2026 19:04

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 822269e4d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/ui/src/lib/highlight-projection.ts Outdated
Dustin-Kelley and others added 2 commits July 20, 2026 14:11
…ches

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 <noreply@anthropic.com>
…ing 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 <noreply@anthropic.com>
@Dustin-Kelley

Copy link
Copy Markdown
Collaborator Author

@greptile please review the matchesIntent silently fails for range passage_id entries p1 again. I think commit 665b606 fixes this.

@Dustin-Kelley
Dustin-Kelley requested a review from bmanquen July 21, 2026 14:48
Integrate YPE-3705 controlled mode with YPE-1034's server-only highlights
path: controlled input disables the fetch, projects from the host prop, and
emits intents with no optimism; self-contained keeps the API overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cameronapak cameronapak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor review (on behalf of Cam)

Looks solid — controlled mode matches the YPE-3705 contract (latch, pure projection, inert persistence, per-verse intents). CI green; prior palette / fake-host range notes look addressed.

A few light conventional comments inline. Nothing blocking from my side.

Comment thread packages/ui/src/components/bible-reader.tsx Outdated
Comment thread packages/ui/src/components/bible-reader.tsx
Comment thread docs/adr/YPE-3705-controlled-mode.md Outdated
Comment thread packages/ui/src/components/bible-reader.tsx Outdated
cameronapak
cameronapak previously approved these changes Jul 21, 2026

@cameronapak cameronapak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, young padiwan **bows down with honor**

@cameronapak cameronapak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Greppy says 5/5, it's Chuck Norris approved

@Dustin-Kelley
Dustin-Kelley merged commit ebddf21 into main Jul 22, 2026
8 checks passed
@Dustin-Kelley
Dustin-Kelley deleted the dk/ype-3705-abstract-bible-reader-into-headless branch July 22, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants