Skip to content

feat(highlights): wire BibleReader to the highlights API (YPE-1034)#288

Open
cameronapak wants to merge 10 commits into
mainfrom
claude/consolidate-stacked-prs-bd457b
Open

feat(highlights): wire BibleReader to the highlights API (YPE-1034)#288
cameronapak wants to merge 10 commits into
mainfrom
claude/consolidate-stacked-prs-bd457b

Conversation

@cameronapak

@cameronapak cameronapak commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

BibleReader highlights now read/write the real /v1/highlights API — with a sign-in/permission flow, recent colors, and staging-hardening fixes — all dark-launched behind the internal HIGHLIGHTS_LIVE flag (off; flip tracked in YPE-3874). Consolidates the YPE-1034 stack (#283, #285, #286, #287) into one reviewable PR, rebased onto main.

Changes

  1. Highlights persist to the user's YouVersion account instead of localStorage, with optimistic per-verse overlay and contiguous runs collapsed to range USFMs on the wire (flag off = feature inert: no fetches, no writes).
  2. Tapping a color without a session or the highlights permission routes through sign-in or a just-in-time data-exchange grant, preserving the user's intent across the redirect (pending highlight in sessionStorage, ~10-min expiry).
  3. Failures route by cause: 401/403 invalidates the permission cache and re-prompts, network/5xx reverts the overlay (snackbar is YPE-3873), decline/cancel discards the pending highlight but keeps the selection.
  4. The color row shows the user's recent colors (GET /v1/highlights/recent-colors, fallback to the 5-color palette), and active swatches show a checkmark instead of an X.
  5. Staging-session fixes: the permission dialog's Continue button is visible again (Button default variant was white-on-white), overlay entries survive read-after-write replica lag, writes refetch once per settled batch, removes send per-verse DELETEs (range DELETE fails server-side), and fills fade in (250ms, reduced-motion honored).
  6. useApiData now fetches when enabled flips false→true, clears data on disable, and drops stale responses via latest-wins sequencing.

Start here: change 5 — overlay entries retire only when a fetch reflects the write; the accepted trade-off (concurrent same-verse edits from another device render stale until navigation/next write) needs product sign-off before the flag flips.

Flow

flowchart LR
    A[Tap color] --> B{Signed in +<br>highlights permission?}
    B -- yes --> C[Optimistic overlay + POST]
    B -- no session --> D[Sign-in requesting highlights]
    B -- no permission --> E[Confirm dialog] --> F[Data-exchange grant]
    D --> G[Pending highlight resumes]
    F --> G
    G --> C
Loading

Breaking / Migration

Local-only highlights are gone: the localStorage store is deleted without migration per the server-only ADR — highlights are account data and un-render on sign-out.

Test plan

  • pnpm test: 865 passing (core 334, hooks 285, ui 246) after rebase onto main
  • pnpm typecheck and pnpm lint clean
  • Rebase conflict (main's localization sync vs new highlight strings in en.json) resolved by keeping both key sets
  • Staging fixes verified against a real session recording (Jam + console/network capture)
  • Needs manual check: one live data-exchange round-trip on staging before HIGHLIGHTS_LIVE flips — callback params (data_exchange_status, granted_permissions) are assumed from spec; handling is isolated in buildDataExchangeUrl/parseDataExchangeCallback

Replaces the stacked drafts #283, #285, #286, #287.

🤖 Generated with Claude Code

Greptile Summary

This PR wires BibleReader highlights to the server-backed highlights API. The main changes are:

  • Server-only highlight fetch, create, and delete paths behind the live flag.
  • Sign-in and data-exchange permission flow for highlight writes.
  • Optimistic overlay reconciliation and batched write refetching.
  • Recent highlight colors and active-swatch checkmark UI.
  • Core data-exchange utilities, hook exports, tests, and ADR updates.

Confidence Score: 4/5

The highlight auth and permission-cache paths need fixes before merging.

  • A resumed pending highlight can be deleted before the write succeeds.
  • A stale cached permission can be read for a later user/session.
  • The remaining issues are contained to callback cleanup and range-response hardening.

packages/ui/src/components/use-bible-reader-highlights.ts, packages/core/src/YouVersionPlatformConfiguration.ts

Security Review

The permission cache can carry a stale highlights grant across user/session changes because it is user-unscoped and merge-only. That can skip the intended consent branch until the server rejects the request.

Important Files Changed

Filename Overview
packages/ui/src/components/use-bible-reader-highlights.ts Adds the BibleReader highlights seam with auth resume, optimistic overlay, write queueing, recent colors, and reconciliation.
packages/core/src/YouVersionPlatformConfiguration.ts Adds persistent permission-cache helpers used by sign-in, data-exchange, and write-failure handling.
packages/core/src/data-exchange.ts Adds data-exchange token, consent URL, callback parsing, permission-cache seeding, and callback URL cleanup.
packages/hooks/src/useHighlightAuthActions.ts Adds highlight auth callbacks for sign-in, permission invalidation, callback consumption, and data-exchange redirect.
packages/hooks/src/useApiData.ts Updates enabled transitions, stale-response handling, and disable-time data clearing.
packages/hooks/src/useHighlights.ts Removes automatic mutation refetching so callers can coalesce batched highlight writes.
packages/ui/src/components/bible-reader.tsx Replaces the local highlight store with the new server-backed highlight hook and permission dialog.
packages/ui/src/components/verse-action-popover.tsx Adds recent color palette support and swaps the active swatch icon to a checkmark.
packages/ui/src/lib/pending-highlight.ts Adds sessionStorage-backed pending highlight persistence with validation and expiry.
packages/ui/src/lib/usfm-ranges.ts Adds contiguous verse-run collapsing and range passage-id construction.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Tap[Tap highlight color] --> Auth{Signed in?}
  Auth -- no --> Pending[Stash pending highlight]
  Pending --> SignIn[Sign-in requesting highlights]
  Auth -- yes --> Perm{Cached highlights permission?}
  Perm -- no --> Dialog[Permission dialog]
  Dialog --> DataExchange[Data-exchange grant]
  SignIn --> Resume[Resume pending highlight]
  DataExchange --> Resume
  Perm -- yes --> Write[Optimistic overlay and API write]
  Resume --> Write
  Write --> Refetch[Refetch highlights]
  Refetch --> Reconcile[Retire overlay when server reflects write]
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"}}}%%
flowchart TD
  Tap[Tap highlight color] --> Auth{Signed in?}
  Auth -- no --> Pending[Stash pending highlight]
  Pending --> SignIn[Sign-in requesting highlights]
  Auth -- yes --> Perm{Cached highlights permission?}
  Perm -- no --> Dialog[Permission dialog]
  Dialog --> DataExchange[Data-exchange grant]
  SignIn --> Resume[Resume pending highlight]
  DataExchange --> Resume
  Perm -- yes --> Write[Optimistic overlay and API write]
  Resume --> Write
  Write --> Refetch[Refetch highlights]
  Refetch --> Reconcile[Retire overlay when server reflects write]
Loading

Comments Outside Diff (2)

  1. packages/ui/src/components/use-bible-reader-highlights.ts, line 3837 (link)

    P1 Pending Intent Clears Too Early

    When the resumed highlight write fails after a sign-in or data-exchange return, this path has already deleted the pending highlight. A 401/403 or transient write failure then only logs and reverts, so the tap that started the auth flow is lost instead of being kept and re-prompted like the normal apply path.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/ui/src/components/use-bible-reader-highlights.ts
    Line: 3837
    
    Comment:
    **Pending Intent Clears Too Early**
    
    When the resumed highlight write fails after a sign-in or data-exchange return, this path has already deleted the pending highlight. A 401/403 or transient write failure then only logs and reverts, so the tap that started the auth flow is lost instead of being kept and re-prompted like the normal apply path.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Cursor Fix in Codex

  2. packages/ui/src/components/use-bible-reader-highlights.ts, line 3436-3437 (link)

    P2 Range Response Loses Verses

    If GET /v1/highlights ever returns a range passage such as JHN.3.16-18, parseInt maps it only to verse 16. The trailing verses are omitted from highlightedVerses, and the reconcile path using the same parsing can leave overlay entries for verses 17-18 pinned until navigation.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/ui/src/components/use-bible-reader-highlights.ts
    Line: 3436-3437
    
    Comment:
    **Range Response Loses Verses**
    
    If `GET /v1/highlights` ever returns a range passage such as `JHN.3.16-18`, `parseInt` maps it only to verse 16. The trailing verses are omitted from `highlightedVerses`, and the reconcile path using the same parsing can leave overlay entries for verses 17-18 pinned until navigation.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Cursor Fix in Codex

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
packages/ui/src/components/use-bible-reader-highlights.ts:3837
**Pending Intent Clears Too Early**

When the resumed highlight write fails after a sign-in or data-exchange return, this path has already deleted the pending highlight. A 401/403 or transient write failure then only logs and reverts, so the tap that started the auth flow is lost instead of being kept and re-prompted like the normal apply path.

### Issue 2 of 4
packages/core/src/YouVersionPlatformConfiguration.ts:102-106
**Permission Cache Outlives User**

This merge keeps old `localStorage` permissions when a later authenticated user is established without first calling `clearAuthTokens()` or when the new callback does not echo `granted_permissions`. In that state `hasPermission('highlights')` can return true for the wrong user, so the highlight flow skips consent and attempts account writes under a stale grant until the server rejects them.

### Issue 3 of 4
packages/core/src/data-exchange.ts:135-136
**Callback Cleanup Drops App Params**

A successful data-exchange return clears the entire query string, not just `data_exchange_status` and `granted_permissions`. If the host route uses query params for app state, referral data, or navigation context, completing the permission flow silently removes that unrelated state from the URL.

### Issue 4 of 4
packages/ui/src/components/use-bible-reader-highlights.ts:3436-3437
**Range Response Loses Verses**

If `GET /v1/highlights` ever returns a range passage such as `JHN.3.16-18`, `parseInt` maps it only to verse 16. The trailing verses are omitted from `highlightedVerses`, and the reconcile path using the same parsing can leave overlay entries for verses 17-18 pinned until navigation.

Reviews (1): Last reviewed commit: "fix(highlights): settle write batches pe..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used (8)

  • Rule used - Unified versioning: All packages must maintain exa... (source)
  • Rule used - Zero React: Pure TypeScript, no React dependencies (source)
  • Rule used - Context and Provider in separate files, exported v... (source)
  • Rule used - Auto-injected styles: index.ts calls injectStyles(... (source)
  • Context used - CLAUDE.md (source)
  • Context used - AGENTS.md (source)
  • File used - docs/review-guidelines.md (source)
  • Context used - Enforce project-specific review guidelines - see d... (source)

cameronapak and others added 10 commits July 14, 2026 09:53
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dark-launch flag

Replace the localStorage highlight store (YPE-642 stand-in) with real API
wiring per YPE-1034 ADR-001: highlights are server-only account data, deleted
locally with no migration.

- New internal useBibleReaderHighlights seam: fetches the current chapter's
  highlights via useHighlights (chapter USFM passage_id, keyed on version),
  renders through an in-memory optimistic overlay, reverts + console.error on
  write failure (toasts and 401/403 handling are PR 2)
- Contiguous verse runs collapse to range USFMs on the wire (usfm-ranges.ts,
  mirroring the verse-share run-grouping idiom); colors sent as lowercase hex
- Internal HIGHLIGHTS_LIVE dark-launch flag (off, not exported from the
  package entry) gates fetches, writes, and rendering; setHighlightsLive() is
  the test/Storybook-only override
- Auth-gated: reads YouVersionAuthContext directly (now exported from hooks)
  so a missing auth provider degrades to signed-out instead of the useYVAuth
  throw; sign-out un-renders highlights immediately
- better-result added to the ui package for error typing at the new seam's
  write boundary; core's throwing clients are untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… responses

Adversarial review of the highlights wiring surfaced one blocker and two
races, all rooted in useApiData:

- The fetch effect ignored enabled transitions: a hook that mounted disabled
  (auth still resolving) never fetched once enabled flipped true, so a
  signed-in reader rendered zero highlights until a write or navigation.
  enabled now rides alongside the caller-supplied deps.
- Disabling kept the last response, so stale account data could render across
  sign-out or a host-controlled auth user switch. Disabling now clears data
  and error.
- refetch-initiated requests escaped cancellation: a stale refetch for a
  previous chapter resolving late could clobber the new chapter's data. All
  requests now go through a monotonic sequence; only the latest-issued
  request may commit state.

In the BibleReader seam hook:

- Successfully settled writes now hand their verses to a confirmed set whose
  overlay entries are dropped when the post-write refetch lands, so server
  truth wins again instead of the optimistic entry masking later remote
  changes until navigation.
- Documented the two remaining apply/remove concurrency windows (in-flight
  POST vs DELETE ordering, snapshot revert vs concurrent write) at the write
  boundary; a real operation queue is deliberately PR 2.

New tests: useApiData enabled transitions and stale-response handling, and a
seam-hook integration test through the real useHighlights/useApiData path
(module-level mocks had hidden the enabled-flip bug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turn a color tap without a session or the `highlights` permission into an
applied highlight across the two auth paths, behind the internal
HIGHLIGHTS_LIVE flag.

Core: DataExchangeClient (POST /data-exchange/token, Zod-validated), the
hosted-grant URL builder + callback parser/handler, and an optimistic
permission cache on YouVersionPlatformConfiguration seeded from
`granted_permissions` on the sign-in and data-exchange callbacks (server
401/403 invalidates it). SignInWithYouVersionResult gains `permissions`.

Hooks: useHighlightAuthActions exposes one-fell-swoop sign-in (requesting
`highlights`), the just-in-time data-exchange redirect, permission
reads/invalidation, and the data-exchange return handler.

UI: useBibleReaderHighlights runs the state machine — pending highlights
persist to sessionStorage (~10-min expiry) across the redirect round-trip and
apply on a granted return; a permission confirm dialog (copy matched to the
native SDK, en/fr/es) gates the grant. Write failures route by status
(401/403 re-prompts and keeps pending; 5xx/network reverts and discards).
Apply/remove writes are serialized through a FIFO queue with per-verse
ownership, closing the two concurrency windows PR1 documented. Copy/share-only
behavior with no auth provider is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on hydrates

The resume effect consumed the data-exchange return status into a local, but
bailed on `!isAuthenticated` first. The shipped YouVersionAuthProvider
hydrates userInfo asynchronously, so the first effect run after a redirect
return is always unauthenticated: the status was consumed and lost, and when
the session flipped the cancel/failure branch was skipped — the pending
highlight survived a decline and the just-declined dialog re-opened.

Discard the pending highlight at consume time for any non-granted return,
before the auth gate: it runs exactly once and needs no session (a decline
kills the intent regardless of who signs in).

Tests: the cancel-return test previously set signedIn=true synchronously — a
timing the real provider never produces — which hid the bug. It now mounts
signed out and flips the session after mount (failed against the old code,
passes now), plus a failure-status variant with the same timing and the
granted-return test aligned for realism.

Also document the three review-deferred follow-ups (expired-token 401
misrouting, resume-write failure handling, remove-failure re-prompt) at their
code sites and in the changeset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…watch

Render the server's recent-colors list in BibleReader's verse action
popover, and swap the active/remove swatch's X for a checkmark to match
iOS (platform-sdk-swift #179). Still behind the internal HIGHLIGHTS_LIVE
flag (YPE-1034 PR3).

- useBibleReaderHighlights fetches getRecentColors once the feature is
  live (flag on + auth provider + signed in) and exposes recentColors;
  it resets to null on sign-out / flag-off and on fetch failure so the
  row falls back to the default palette.
- VerseActionPopover takes an optional recentColors prop: normalized
  (lowercase, strip leading #, drop non-6-hex), deduped
  first-occurrence-wins, never reordered (server order is truth), and
  horizontally scrollable on overflow. Falls back to HIGHLIGHT_COLORS
  when recents are absent/empty. Active colors outside the palette still
  get a remove circle.
- HighlightColor widened from the 5-literal union to string (loosening,
  non-breaking); HIGHLIGHT_COLORS still exported as the default palette.
- New icons/check; ADR-005 as-built notes updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-ups on the recent-colors popover change:

- The row's new `overflow-x: auto` forces overflow-y out of `visible`
  (CSS spec), clipping the swatches' focus-visible ring (ring-2 +
  offset-2, ~4px overpaint) and hover scale-110 — including for prod
  users with HIGHLIGHTS_LIVE off, since the popover itself is not
  flag-gated. Add 6px padding with compensating negative margin so the
  overpaint stays inside the scroll box without changing layout, and a
  regression test guarding the padding/overflow pairing.
- Rework the primary recent-colors integration test to the production
  auth timing: mount signed out (as YouVersionAuthProvider hydrates
  async), assert no fetch, then flip signed-in and assert the fetch
  fires — the sync signed-in mount shape both prior PRs were dinged for.
- Document the deferred account-switch-without-sign-out staleness at the
  fetch effect (recents keyed on `live` only, not user identity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ui: default button variant now bg-primary/text-primary-foreground
  (was white-on-white in light theme, making the permission dialog's
  Continue button invisible). YouVersionAuthButton pins bg-background so
  its brand surface is unchanged.
- ui: retire the optimistic overlay only once a fetch reflects the write,
  so read-after-write lag no longer flickers a highlight out/back (apply)
  or resurrects a removed one (remove).
- hooks/ui: stop refetching per write; the seam hook coalesces to one
  refetch per settled batch.
- core/ui: send one DELETE per verse instead of a range (range delete
  is unsupported server-side); docstring updated to stop claiming it.
- core styles: fade highlight fills in/out (~250ms, reduced-motion aware).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re too

An adversarial review caught a regression from the refetch coalescing: on
ANY failure the whole batch's overlay reverted and no refetch fired, so
sub-writes that succeeded server-side vanished from the UI (apply) or
resurrected as ghosts (per-verse remove) until the next navigation/write.

- runApply / runRemove / the resume-path write now track each sub-write's
  outcome: succeeded runs/verses register for reconciliation (overlay holds
  until a fetch reflects them), only truly-failed verses revert.
- Exactly one refetch per batch, success or failure, restoring the
  self-correction the per-write auto-refetch used to provide.
- Integration tests for partial apply failure, partial remove failure
  (no ghosts), and all-fail; the overlay-retirement test now distinguishes
  retired from still-masking via a divergent server color.
- Changeset notes the partial-failure semantics and the accepted
  concurrent-edit staleness trade-off (pending product sign-off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 385486a

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-hooks Patch
@youversion/platform-react-ui Patch
@youversion/platform-core Patch
vite-react Patch

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

Comment on lines +102 to +106
public static saveGrantedPermissions(permissions: string[]): void {
if (typeof localStorage === 'undefined') return;
const merged = new Set([...this.grantedPermissions, ...permissions]);
localStorage.setItem(this.grantedPermissionsKey, JSON.stringify([...merged]));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Permission Cache Outlives User

This merge keeps old localStorage permissions when a later authenticated user is established without first calling clearAuthTokens() or when the new callback does not echo granted_permissions. In that state hasPermission('highlights') can return true for the wrong user, so the highlight flow skips consent and attempts account writes under a stale grant until the server rejects them.

Context Used: Enforce project-specific review guidelines - see d... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/YouVersionPlatformConfiguration.ts
Line: 102-106

Comment:
**Permission Cache Outlives User**

This merge keeps old `localStorage` permissions when a later authenticated user is established without first calling `clearAuthTokens()` or when the new callback does not echo `granted_permissions`. In that state `hasPermission('highlights')` can return true for the wrong user, so the highlight flow skips consent and attempts account writes under a stale grant until the server rejects them.

**Context Used:** Enforce project-specific review guidelines - see d... ([source](greptile.json))

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Cursor Fix in Codex

Comment on lines +135 to +136
const cleanUrl = new URL(window.location.href);
cleanUrl.search = '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Callback Cleanup Drops App Params

A successful data-exchange return clears the entire query string, not just data_exchange_status and granted_permissions. If the host route uses query params for app state, referral data, or navigation context, completing the permission flow silently removes that unrelated state from the URL.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/data-exchange.ts
Line: 135-136

Comment:
**Callback Cleanup Drops App Params**

A successful data-exchange return clears the entire query string, not just `data_exchange_status` and `granted_permissions`. If the host route uses query params for app state, referral data, or navigation context, completing the permission flow silently removes that unrelated state from the URL.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Cursor Fix in Codex

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.

1 participant