feat(ui): controlled (headless) highlights mode on BibleReader (YPE-3705)#293
Conversation
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 detectedLatest commit: 9c37be0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
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>
4d49540 to
82839d3
Compare
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 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".
…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>
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
left a comment
There was a problem hiding this comment.
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.
cameronapak
left a comment
There was a problem hiding this comment.
LGTM, young padiwan **bows down with honor**
cameronapak
left a comment
There was a problem hiding this comment.
If Greppy says 5/5, it's Chuck Norris approved
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/highlightsreturns, 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-versepassageIds).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 viahighlights, 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 theuseBibleReaderHighlightsadapter; machine staysdisabled; controlled mode bypassesHIGHLIGHTS_LIVE).Reviewer notes
highlightsprop) behavior is unchanged; existing tests pass unmodified.packages/ui/src/lib/highlight-projection.tsis 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(args-driven) andControlled (fake host)— the latter is the reference round-trip implementation for RN's YPE-3710.CONTEXT.mdarrives 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.NODE_OPTIONS=--no-experimental-webstorage(Node's newlocalStorageglobal 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 passeshighlights?: Highlight[], the reader becomes a pure projection — no network calls, no localStorage, no auth surface — and emits intents viaonHighlightApply/onHighlightRemoveinstead of writing to the API. Self-contained behavior (nohighlightsprop) is byte-for-byte unchanged.highlight-projection.tsimplements USFM range expansion (expandPassageId) and the verse-render-map derivation (deriveHighlightedVerses), both with thorough unit tests (18 cases).useBibleReaderHighlightsgains acontrolledinput: the machine is kept inert (flagOn: false),useHighlightsstaysenabled: false, andapply/removeforward serializable bridge-safe intents instead of touching the API or optimistic overlay.BibleReader.Rootlatches 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.Controlled(args-driven) andControlled (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
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)%%{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)Comments Outside Diff (1)
packages/ui/src/components/bible-reader.stories.tsx, line 692-693 (link)matchesIntentsilently fails for rangepassage_identriesintent.passageIdscontains 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')returnsfalse,handleRemovesilently no-ops, and the reader keeps painting those verses on every subsequent round-trip. The story comment says "kept per-verse," butControlledFakeHostis 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-exportedexpandPassageIdcould be used here to expand the storedpassage_idbefore matching against the per-verse intentpassageIds.Prompt To Fix With AI
Reviews (7): Last reviewed commit: "Merge branch 'main' into dk/ype-3705-abs..." | Re-trigger Greptile