Skip to content

YPE-642 - verse action popover — highlights, copy & share #269

Open
cameronapak wants to merge 4 commits into
mainfrom
cp/YPE-642-react-sdk-bible-reader-ui-verse-action-popover-with-highlight-colors-copy-and-share
Open

YPE-642 - verse action popover — highlights, copy & share #269
cameronapak wants to merge 4 commits into
mainfrom
cp/YPE-642-react-sdk-bible-reader-ui-verse-action-popover-with-highlight-colors-copy-and-share

Conversation

@cameronapak

@cameronapak cameronapak commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

VIDEO OVERVIEW

YPE-642

Adds a verse action popover to BibleReader: tap verse(s) to highlight, copy, or share.

What's in it

  • Highlights — 5 iOS-matched colors, painted as a 35%-opacity fill. Tap a color to apply, tap its X to remove.
  • Copy / Share — bible.com-style output: curly-quoted text (verse numbers + footnote markers stripped), blank line, then Book Ch:vv VERSION. Non-contiguous picks join with ...; ranges collapse (1-3). Share uses the Web Share API, clipboard fallback.
  • Persistence — localStorage only, keyed by bible_id + verse USFM to mirror the future highlight API shape. No network this PR (server sync is a separate ticket).

Behavior worth a look

  • Sticky bar — when the anchored verse scrolls off, the bar docks to the edge it exited (down → top, up → bottom) so the actions stay reachable.
  • Fades in place — applying a highlight or clicking away fades the bar out where it sits (no anchor-loss jump).
  • No auth gating; highlights render regardless of sign-in. No cross-chapter selection.

Notes

  • Footnote markers darken to --yv-muted-foreground over a highlight for AA contrast.
  • Design rationale + ADRs: docs/adr/YPE-642-verse-action-popover.md.

Testing

  • 164 unit tests pass (incl. new verse-action-popover + verse-share suites); typecheck + lint clean.
  • Storybook: Components/BibleTextView → VerseSelection.

🤖 Generated with Claude Code

Greptile Summary

This PR adds a verse action popover to BibleReader: tapping one or more verses selects them (underline indicator), opens a floating toolbar anchored to the last-tapped verse, and exposes five highlight colors, Copy, and Share. Highlights persist to localStorage keyed by versionId and passage USFM, shaped to match the future API contract.

  • verse-action-popover.tsx — new component with Radix virtualRef anchoring, IntersectionObserver-based dock-to-edge behavior when the anchor verse scrolls off screen, and a frozen-snapshot pattern that lets the bar fade in place during close animations.
  • verse-share.ts — new formatting helpers (formatVerseNumbers, joinVerseTexts, buildVerseShareText) implementing the bible.com output style (curly quotes, ... gaps, collapsed verse ranges); 83 unit tests cover all edge cases.
  • bible-reader.tsx / verse.tsxContent gains selection + highlight state, DOM-based anchor resolution, and getCleanVerseText for clean prose extraction; highlightedVerses type widens from Record<number, boolean> to Record<number, string> (hex).

Confidence Score: 3/5

The core highlight, copy, and share flows work correctly, but an accessibility gap and a stale-highlight flash on version switches need to be resolved before this ships to all users.

All five color circle buttons share the same aria-label text ("Apply highlight" / "Clear highlight") — no color name is included — so a keyboard or screen reader user cannot distinguish them. This means the color selection part of the feature is effectively broken for AT users. Additionally, when a user switches Bible versions, the previous version's stored highlights appear on the new version's text for one render cycle because highlightStore is not cleared before the async load effect fires. The accessibility gap also has a test that inadvertently encodes the broken behavior, locking in the non-color-specific label.

verse-action-popover.tsx (aria-label on color circles, prefers-reduced-motion) and bible-reader.tsx (stale highlight store during version switch). The changeset version bump also deserves a second look given the highlightedVerses type change.

Important Files Changed

Filename Overview
packages/ui/src/components/verse-action-popover.tsx New component for verse highlight/copy/share popover; correct docking/anchoring logic and frozen-snapshot animation pattern, but all color circle buttons share the same generic aria-label making them inaccessible to screen reader users, and animations lack prefers-reduced-motion support.
packages/ui/src/components/bible-reader.tsx Wires verse selection, highlight persistence (localStorage), copy, and share into the BibleReader Content component; logic is sound but the highlight store briefly shows the previous version's data when versionId changes because the async load effect fires after paint.
packages/ui/src/lib/verse-share.ts New formatting helpers for Copy/Share output; well-tested, handles contiguous range collapse and ellipsis gaps correctly, and gracefully handles empty version abbreviations.
packages/ui/src/components/verse.tsx Adds highlight fill via hexToRgba inline styles, exports getCleanVerseText for DOM-based verse text extraction, and improves footnote contrast on highlighted verses; changes are correct.
.changeset/verse-action-popover.md Changeset bumps as minor but the BibleTextViewProps.highlightedVerses type change (boolean → string) is a breaking API change that should be documented more explicitly or bumped to major.
packages/ui/src/components/verse-action-popover.test.tsx Comprehensive unit tests covering all 9 ACs including color ordering, mixed/multiple highlights, dismiss logic, and accessibility expectations; test coverage is thorough.
packages/ui/src/lib/verse-share.test.ts Unit tests for verse share formatting helpers; covers single verse, contiguous range, non-contiguous gap, sorting/deduplication, and empty version abbreviation.
packages/ui/src/styles/global.css Adds the yv-v-selected underline rule inside the correct yv-sdk-bible-reader layer; straightforward and non-breaking.
packages/ui/src/i18n/locales/en.json Adds i18n keys for copy/share labels and all ARIA labels; translations look correct in en/es/fr but the applyHighlightAriaLabel and clearHighlightAriaLabel keys lack a color interpolation placeholder needed for accessibility.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User
    participant BTV as BibleTextView
    participant C as Content (bible-reader)
    participant VAP as VerseActionPopover
    participant LS as localStorage

    U->>BTV: tap verse(s)
    BTV->>C: onVerseSelect(verses)
    C->>C: resolve anchor DOM element
    C->>VAP: "open=true, anchorElement, selectedVerses"

    VAP->>VAP: IntersectionObserver watches anchor
    Note over VAP: verse scrolls off screen
    VAP->>VAP: setDockEdge top or bottom

    U->>VAP: click color swatch (apply)
    VAP->>C: onHighlight(color)
    C->>LS: setItem(key, JSON)
    C->>C: closeAndClearSelection()

    U->>VAP: click X swatch (clear)
    VAP->>C: onClearHighlight(color)
    C->>LS: setItem(key, JSON)
    C->>C: if no remaining colors close

    U->>VAP: click Copy
    VAP->>C: onCopy()
    C->>C: getCleanVerseText x N
    C->>C: buildVerseShareText()
    C->>C: navigator.clipboard.writeText()

    U->>VAP: click Share
    VAP->>C: onShare()
    C->>C: buildVerseShareText()
    C->>C: navigator.share() fallback clipboard
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 U as User
    participant BTV as BibleTextView
    participant C as Content (bible-reader)
    participant VAP as VerseActionPopover
    participant LS as localStorage

    U->>BTV: tap verse(s)
    BTV->>C: onVerseSelect(verses)
    C->>C: resolve anchor DOM element
    C->>VAP: "open=true, anchorElement, selectedVerses"

    VAP->>VAP: IntersectionObserver watches anchor
    Note over VAP: verse scrolls off screen
    VAP->>VAP: setDockEdge top or bottom

    U->>VAP: click color swatch (apply)
    VAP->>C: onHighlight(color)
    C->>LS: setItem(key, JSON)
    C->>C: closeAndClearSelection()

    U->>VAP: click X swatch (clear)
    VAP->>C: onClearHighlight(color)
    C->>LS: setItem(key, JSON)
    C->>C: if no remaining colors close

    U->>VAP: click Copy
    VAP->>C: onCopy()
    C->>C: getCleanVerseText x N
    C->>C: buildVerseShareText()
    C->>C: navigator.clipboard.writeText()

    U->>VAP: click Share
    VAP->>C: onShare()
    C->>C: buildVerseShareText()
    C->>C: navigator.share() fallback clipboard
Loading

Comments Outside Diff (2)

  1. packages/ui/src/components/verse-action-popover.tsx, line 1275-1282 (link)

    P1 Non-unique aria-labels on color circle buttons

    All five "apply" buttons receive the same label ("Apply highlight") and all "clear" buttons receive the same label ("Clear highlight"). A screen reader user navigating the toolbar hears the same announcement for every color swatch and has no way to know which color they are about to apply or remove. This blocks the highlight-by-color feature for keyboard/AT users entirely.

    The i18n keys applyHighlightAriaLabel and clearHighlightAriaLabel are also locked to a bare string in all three locale files, so there is no placeholder available for the color name. A fix requires adding a color-name map (e.g. { fffe00: 'yellow', '5dff79': 'green', ... }) and updating the i18n keys to accept an interpolation variable (e.g. "Apply {{color}} highlight" / "Clear {{color}} highlight").

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/ui/src/components/verse-action-popover.tsx
    Line: 1275-1282
    
    Comment:
    **Non-unique aria-labels on color circle buttons**
    
    All five "apply" buttons receive the same label (`"Apply highlight"`) and all "clear" buttons receive the same label (`"Clear highlight"`). A screen reader user navigating the toolbar hears the same announcement for every color swatch and has no way to know which color they are about to apply or remove. This blocks the highlight-by-color feature for keyboard/AT users entirely.
    
    The i18n keys `applyHighlightAriaLabel` and `clearHighlightAriaLabel` are also locked to a bare string in all three locale files, so there is no placeholder available for the color name. A fix requires adding a color-name map (e.g. `{ fffe00: 'yellow', '5dff79': 'green', ... }`) and updating the i18n keys to accept an interpolation variable (e.g. `"Apply {{color}} highlight"` / `"Clear {{color}} highlight"`).
    
    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/verse-action-popover.tsx, line 1240-1244 (link)

    P2 No prefers-reduced-motion guard on popover animations

    The popover uses fade-in-0, zoom-in-95, slide-in-from-top-2, and related Tailwind animation utilities with a 180 ms custom duration. Users who prefer reduced motion (via the OS setting) will still see these transitions. The review guidelines call out respecting this media query for all animations.

    Adding motion-safe: variants (e.g. yv:motion-safe:data-[state=open]:animate-in) or a global @media (prefers-reduced-motion: reduce) override that sets animation-duration: 0s on [data-yv-sdk] elements would resolve this.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/ui/src/components/verse-action-popover.tsx
    Line: 1240-1244
    
    Comment:
    **No `prefers-reduced-motion` guard on popover animations**
    
    The popover uses `fade-in-0`, `zoom-in-95`, `slide-in-from-top-2`, and related Tailwind animation utilities with a 180 ms custom duration. Users who prefer reduced motion (via the OS setting) will still see these transitions. The review guidelines call out respecting this media query for all animations.
    
    Adding `motion-safe:` variants (e.g. `yv:motion-safe:data-[state=open]:animate-in`) or a global `@media (prefers-reduced-motion: reduce)` override that sets `animation-duration: 0s` on `[data-yv-sdk]` elements would resolve this.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    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/verse-action-popover.tsx:1275-1282
**Non-unique aria-labels on color circle buttons**

All five "apply" buttons receive the same label (`"Apply highlight"`) and all "clear" buttons receive the same label (`"Clear highlight"`). A screen reader user navigating the toolbar hears the same announcement for every color swatch and has no way to know which color they are about to apply or remove. This blocks the highlight-by-color feature for keyboard/AT users entirely.

The i18n keys `applyHighlightAriaLabel` and `clearHighlightAriaLabel` are also locked to a bare string in all three locale files, so there is no placeholder available for the color name. A fix requires adding a color-name map (e.g. `{ fffe00: 'yellow', '5dff79': 'green', ... }`) and updating the i18n keys to accept an interpolation variable (e.g. `"Apply {{color}} highlight"` / `"Clear {{color}} highlight"`).

### Issue 2 of 4
packages/ui/src/components/bible-reader.tsx:482-501
**Stale `highlightStore` renders for one frame when `versionId` changes**

The load `useEffect` (line 483) and the ADR-007 navigation `useEffect` (line 496) are both async — they fire after the browser has painted. When the user switches Bible versions, `versionId` changes, the component re-renders with the new version's text, but `highlightStore` still holds the previous version's highlights until the load effect's `setHighlightStore(data)` call takes effect. During that first paint, the old version's passage IDs (`${book}.${chapter}.N`) match the new version's `chapterPrefix`, so the prior version's highlights flash visibly over the new text.

Adding `setHighlightStore({})` as the first line inside the load `useEffect` ensures the store is cleared before the new data is applied, preventing the stale-data paint at the cost of one additional (empty) frame.

### Issue 3 of 4
packages/ui/src/components/verse-action-popover.tsx:1240-1244
**No `prefers-reduced-motion` guard on popover animations**

The popover uses `fade-in-0`, `zoom-in-95`, `slide-in-from-top-2`, and related Tailwind animation utilities with a 180 ms custom duration. Users who prefer reduced motion (via the OS setting) will still see these transitions. The review guidelines call out respecting this media query for all animations.

Adding `motion-safe:` variants (e.g. `yv:motion-safe:data-[state=open]:animate-in`) or a global `@media (prefers-reduced-motion: reduce)` override that sets `animation-duration: 0s` on `[data-yv-sdk]` elements would resolve this.

### Issue 4 of 4
.changeset/verse-action-popover.md:1-5
**`highlightedVerses` type change is a breaking API change but the bump is `minor`**

`BibleTextViewProps.highlightedVerses` changed from `Record<number, boolean>` to `Record<number, string>`. Any external consumer passing `{ 1: true }` will get a TypeScript error after upgrading. The review guidelines require breaking changes to be clearly identified in changesets, and the project's unified-versioning rule means this bump propagates to all packages. The changeset should be `major` (or the ADR rationale — that the prop is wired nowhere in real usage today — should be noted explicitly in the changeset so consumers know it is safe to upgrade).

Reviews (1): Last reviewed commit: "refactor(ui): trim redundant guards and ..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used:

  • File used - docs/review-guidelines.md (source)

cameronapak and others added 4 commits June 23, 2026 13:58
…642)

Tapping verses in BibleReader selects them (underline) and opens a popover
anchored to the last-selected verse with 5 highlight colors, Copy, and Share.
Highlights paint a 35%-opacity fill, persist to localStorage per Bible version
(shaped like the future highlight API), and can be removed per-color. Copy/Share
mirror bible.com formatting; Share uses the Web Share API with a clipboard
fallback. Footnote markers darken on highlighted verses to stay AA-legible.

Salvages the popover component and tests from the closed PR #131.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…642)

Keep the verse action bar reachable while scrolling. An IntersectionObserver
watches the anchored verse against the reader's scroll container; when the verse
leaves the viewport the bar docks to the edge it exited through (scroll down →
top, scroll up → bottom) instead of following the verse off-screen. A single
Radix popover swaps its anchor between the real verse element and a virtual
element pinned to the container edge, preserving focus/escape/outside-click and
avoiding remount flicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the popover closes, the parent clears the selection and anchor
synchronously. Without freezing the last rendered state, the
animating bar would jump to a fallback position and flash an empty
layout before fading out.
Drop the unnecessary `typeof navigator` guards inside the Copy/Share click
handlers (handlers run client-side only, matching verse-of-the-day's pattern),
remove a no-op catch reassignment, and fix a stale dock-direction doc comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3e7a7c1

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

Comment on lines +606 to +610
function handleCopy() {
const text = buildSelectionText();
if (text) void navigator.clipboard?.writeText(text);
closeAndClearSelection();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

nitpick: We may need the ability to pass in an override function for React Native Expo SDK.

Comment on lines +611 to +628

function handleShare() {
const text = buildSelectionText();
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
navigator
.share({ text })
.then(() => closeAndClearSelection())
.catch(() => {
// Cancelled or failed — keep the popover open (AC 4).
});
return;
}
// No Web Share support (e.g. most desktop browsers) — fall back to clipboard.
if (text && typeof navigator !== 'undefined') {
void navigator.clipboard?.writeText(text);
}
closeAndClearSelection();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

nitpick: We may need the ability to pass in an override function for React Native Expo SDK.

@cameronapak cameronapak changed the title feat(ui): verse action popover — highlights, copy & share (YPE-642) YPE-642 - verse action popover — highlights, copy & share Jun 23, 2026
Comment on lines +482 to +501
// Load this version's highlights when the version changes (client-only).
useEffect(() => {
let data: Record<string, string> = {};
try {
const raw = localStorage.getItem(highlightsStorageKey);
if (raw) data = JSON.parse(raw) as Record<string, string>;
} catch {
// Ignore (unavailable or malformed storage).
}
setHighlightStore(data);
}, [highlightsStorageKey]);

// Navigating away (book/chapter/version) drops the selection — those verses no
// longer exist on screen (ADR-007).
useEffect(() => {
setSelectedVerses([]);
setPopoverOpen(false);
setAnchorElement(null);
lastSelectionRef.current = [];
}, [book, chapter, versionId]);

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 Stale highlightStore renders for one frame when versionId changes

The load useEffect (line 483) and the ADR-007 navigation useEffect (line 496) are both async — they fire after the browser has painted. When the user switches Bible versions, versionId changes, the component re-renders with the new version's text, but highlightStore still holds the previous version's highlights until the load effect's setHighlightStore(data) call takes effect. During that first paint, the old version's passage IDs (${book}.${chapter}.N) match the new version's chapterPrefix, so the prior version's highlights flash visibly over the new text.

Adding setHighlightStore({}) as the first line inside the load useEffect ensures the store is cleared before the new data is applied, preventing the stale-data paint at the cost of one additional (empty) frame.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/components/bible-reader.tsx
Line: 482-501

Comment:
**Stale `highlightStore` renders for one frame when `versionId` changes**

The load `useEffect` (line 483) and the ADR-007 navigation `useEffect` (line 496) are both async — they fire after the browser has painted. When the user switches Bible versions, `versionId` changes, the component re-renders with the new version's text, but `highlightStore` still holds the previous version's highlights until the load effect's `setHighlightStore(data)` call takes effect. During that first paint, the old version's passage IDs (`${book}.${chapter}.N`) match the new version's `chapterPrefix`, so the prior version's highlights flash visibly over the new text.

Adding `setHighlightStore({})` as the first line inside the load `useEffect` ensures the store is cleared before the new data is applied, preventing the stale-data paint at the cost of one additional (empty) frame.

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 +1 to +5
---
"@youversion/platform-react-ui": minor
---

Add a verse action popover to `BibleReader`. Tapping verses selects them (shown with an underline) and opens a popover anchored to the last-selected verse with five highlight colors, Copy, and Share. Highlights apply a translucent fill, persist to `localStorage` per Bible version (shaped like the future highlight API), and can be removed individually. Copy/Share output mirrors bible.com formatting: the verse text in curly quotes, gaps in a non-contiguous selection joined with `...`, followed by the `Book Chapter:verses VERSION` reference. Share uses the Web Share API and falls back to copying where it isn't available.

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 highlightedVerses type change is a breaking API change but the bump is minor

BibleTextViewProps.highlightedVerses changed from Record<number, boolean> to Record<number, string>. Any external consumer passing { 1: true } will get a TypeScript error after upgrading. The review guidelines require breaking changes to be clearly identified in changesets, and the project's unified-versioning rule means this bump propagates to all packages. The changeset should be major (or the ADR rationale — that the prop is wired nowhere in real usage today — should be noted explicitly in the changeset so consumers know it is safe to upgrade).

File Used: docs/review-guidelines.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: .changeset/verse-action-popover.md
Line: 1-5

Comment:
**`highlightedVerses` type change is a breaking API change but the bump is `minor`**

`BibleTextViewProps.highlightedVerses` changed from `Record<number, boolean>` to `Record<number, string>`. Any external consumer passing `{ 1: true }` will get a TypeScript error after upgrading. The review guidelines require breaking changes to be clearly identified in changesets, and the project's unified-versioning rule means this bump propagates to all packages. The changeset should be `major` (or the ADR rationale — that the prop is wired nowhere in real usage today — should be noted explicitly in the changeset so consumers know it is safe to upgrade).

**File Used:** docs/review-guidelines.md ([source](docs/review-guidelines.md))

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