Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f2fc973
feat(core): parse USFM references in one grammar
camrun91 Sep 11, 2026
c360180
feat(hooks): encode Reader search as an exclusive-lane session
camrun91 Sep 11, 2026
3b108f8
feat(hooks): add useBibleSearch over the session reducer
camrun91 Sep 11, 2026
f21507b
feat(ui): hold search verse focus until the chapter paints
camrun91 Sep 11, 2026
6c4ff6f
feat(ui): mount Bible Reader search from the Toolbar
camrun91 Sep 11, 2026
03ccaf4
chore: add YPE-5766 search UI changeset
camrun91 Sep 11, 2026
896a635
fix(hooks): do not suggest a query that was just submitted
camrun91 Sep 11, 2026
445db09
fix(ui): clear search verse focus on the reader scroller
camrun91 Sep 11, 2026
a9cec71
fix(ui): hide native search clear next to custom X
camrun91 Sep 11, 2026
b29aaf2
fix(ui): keep Search key on mobile after type=text
camrun91 Sep 11, 2026
291371b
fix: unblock search UI CI typecheck, story play, and locale ownership
camrun91 Sep 14, 2026
5bacf53
fix: restore ui en.json to origin/main for locale ownership
camrun91 Sep 14, 2026
4b9f29d
test(core): reproduce USFM verse range expansion hang
camrun91 Sep 15, 2026
9d725a5
fix(core): reject unsafe USFM verse range expansion
camrun91 Sep 15, 2026
9720ea4
Merge branch 'main' into cl/ype-5766_search_ui
cameronapak Sep 16, 2026
39fffb5
fix: complete Bible search navigation and interaction contracts
ampagent Sep 16, 2026
fe1e35a
test(ui): isolate Storybook fonts and passage responses
ampagent Sep 16, 2026
497d9e2
chore: merge main RTL support into Bible search
ampagent Sep 16, 2026
d34472a
Merge branch 'main' into cl/ype-5766_search_ui
cameronapak Sep 16, 2026
b868d2b
feat(ui): refine Bible search experience and filter disclosure
ampagent Sep 17, 2026
6e4fa5d
Merge branch 'main' into cl/ype-5766_search_ui
cameronapak Sep 17, 2026
77aa9e6
fix(ci): align release tooling manifests and search bundle budgets
ampagent Sep 17, 2026
7d6fe84
fix(search): preserve runtime compatibility and allow resubmission
ampagent Sep 18, 2026
c6e5efc
fix(search): honor queued navigation and omit chapter-only hits
ampagent Sep 18, 2026
cb1ff01
chore: merge main into Bible search branch
ampagent Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/ype-5766-search-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@youversion/platform-core": minor
"@youversion/platform-react-hooks": minor
"@youversion/platform-react-ui": minor
---

Add Bible Reader search: one USFM grammar, an exclusive-lane `useBibleSearch` session, and a Toolbar-mounted `BibleReaderSearch` that navigates with transient verse focus.

Expose `BibleReaderNavigation` for host requests before reader mount, passage or full-chapter display, and optional in-place focus. Search supports grapheme-safe input, deduplicated results, verse previews, automatic pagination, and accessible dismissal and errors.

Align search with the reader designs in a toolbar-anchored popover. Show three trending queries and the latest three browser-local recent searches, and preserve query entry and recents when suggestions fail. Load verse previews behind one spinner, keeping earlier results visible during pagination. Result selection moves keyboard focus to the verse; dismissing search with Close or Escape restores the trigger.

Add an async-compatible `BibleReader.Root.onSearchPress` override for host-owned search. Match toolbar popover motion and native scrollbar themes. Correct verse mapping across poetry lines so focus dims the complete surrounding verses without adding a highlight, and retain focus until user interaction.

Add Old Testament, New Testament, and Both search filters using the selected Bible's book metadata. Continue through nonmatching result pages before reporting no results. Display sentence-case references above three-line serif previews with inline verse numbers. Back clears the search and filters without closing the popover; show only one X control at a time.

Give search rows inset hover surfaces without shifting content, align initial search and preview spinners, and reveal loaded previews with a short reduced-motion-aware transition. Reuse existing localized empty-state copy and clear stale failures when changing testament filters.

Start testament controls collapsed behind an accessible Filters disclosure. Preserve and indicate active filtering while collapsed, and reset to Both and collapsed on Back or clear. Expand and collapse the controls with an interruptible accordion transition, immediate keyboard toggles, and fade-only reduced motion.

Keep core imports usable without Intl.Segmenter, falling back to Unicode code-point limits for search on those runtimes. Preserve the generic useDebounce delay for null values while suppressing suggestions in submitted searches. Allow Enter to resubmit failed or empty searches without replaying cached pages or duplicating an in-flight request.

Apply queued reader destinations before fetching passage content, and wait for controlled props to accept pending navigation. Clear activated navigation when the host leaves its destination so returning does not restore stale passage selection or focus. Omit chapter-only search hits while preserving verse ranges and continuation loading.
43 changes: 43 additions & 0 deletions .github/scripts/major-release-signoff.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,49 @@ run_decision_case "ordinary major previews still require signoff" \
run_decision_case "failed ordinary previews remain blocked, not major" \
'blocked=release preview did not succeed (failure)' false false skipped '' failure ''

extract_step "Restore release tooling from the base branch" > "$TMP/restore.sh"
PREVIEW_REPO="$TMP/preview-repo"
git init --quiet --initial-branch=main "$PREVIEW_REPO"
git -C "$PREVIEW_REPO" config commit.gpgsign false
git -C "$PREVIEW_REPO" config user.name test
git -C "$PREVIEW_REPO" config user.email test@example.com
mkdir -p "$PREVIEW_REPO/packages/ui" "$PREVIEW_REPO/packages/core" \
"$PREVIEW_REPO/scripts" "$PREVIEW_REPO/.changeset"
printf '{"name":"fixture","private":true}\n' > "$PREVIEW_REPO/package.json"
printf '{"name":"@fixture/ui","version":"1.0.0"}\n' > "$PREVIEW_REPO/packages/ui/package.json"
printf '{"name":"@fixture/core","version":"1.0.0"}\n' > "$PREVIEW_REPO/packages/core/package.json"
printf 'packages:\n - "packages/*"\n' > "$PREVIEW_REPO/pnpm-workspace.yaml"
printf '// base-owned detector\n' > "$PREVIEW_REPO/scripts/preview-release.mjs"
printf '{}\n' > "$PREVIEW_REPO/.changeset/config.json"
pnpm --dir "$PREVIEW_REPO" install --lockfile-only --ignore-scripts --offline > "$TMP/preview-install.log" 2>&1
git -C "$PREVIEW_REPO" add -A
git -C "$PREVIEW_REPO" commit --quiet -m base
git -C "$PREVIEW_REPO" switch --quiet -c feature
git -C "$PREVIEW_REPO" remote add origin "$PREVIEW_REPO"
# A changed dependency, a new workspace, and a deleted workspace must not influence
# the base-owned install. The PR's major changeset must still reach the detector.
printf '{"name":"@fixture/ui","version":"1.0.0","dependencies":{"zod":"4.1.12"}}\n' \
> "$PREVIEW_REPO/packages/ui/package.json"
mkdir "$PREVIEW_REPO/packages/new"
printf '{"name":"@fixture/new","version":"1.0.0"}\n' > "$PREVIEW_REPO/packages/new/package.json"
rm "$PREVIEW_REPO/packages/core/package.json"
printf '%s\n' '---' '"@fixture/ui": major' '---' 'PR release data' \
> "$PREVIEW_REPO/.changeset/pr-change.md"
git -C "$PREVIEW_REPO" add -A
git -C "$PREVIEW_REPO" commit --quiet -m feature

if (cd "$PREVIEW_REPO" && bash "$TMP/restore.sh" && \
pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile --offline && \
git diff --exit-code main -- package.json pnpm-lock.yaml ':(glob)**/package.json' && \
test ! -e packages/new/package.json && \
git diff --exit-code HEAD -- .changeset/pr-change.md && \
grep -Fq '"@fixture/ui": major' .changeset/pr-change.md) > "$TMP/restore.log" 2>&1; then
pass "base tooling installs despite PR workspace dependency changes and preserves release data"
else
fail "base tooling installs despite PR workspace dependency changes and preserves release data" \
"$(cat "$TMP/restore.log")"
fi

if pnpm exec prettier --check "$WORKFLOW" >/dev/null; then
pass "workflow YAML parses and is formatted"
else
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/major-release-signoff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ jobs:
pnpm-workspace.yaml .changeset/config.json; do
git cat-file -e "main:$f" 2>/dev/null && git checkout main -- "$f"
done
# Frozen installs validate every workspace manifest against the base lockfile.
# Remove PR-only manifests and restore base manifests, including PR deletions.
# Keep PR changesets as data; never resolve PR-owned dependencies for tooling.
git restore --source=main --staged --worktree -- ':(glob)**/package.json'
# Install hooks a PR could add to rewrite what we just restored. Take main's, or
# remove it outright: leaving a PR-authored one hands back the control.
for f in .npmrc .pnpmfile.cjs; do
Expand Down
6 changes: 3 additions & 3 deletions .size-limit.js
Comment thread
cameronapak marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export default [
{
name: 'core / full barrel (esm)',
path: 'packages/core/dist/index.js',
limit: '21 KB',
limit: '21.5 KB',
ignore: ['jsdom'],
},
{
Expand All @@ -28,7 +28,7 @@ export default [
{
name: 'hooks / full barrel',
path: 'packages/hooks/dist/index.js',
limit: '26 KB',
limit: '27 KB',
ignore: ['react', 'react-dom', '@tanstack/react-query'],
},
{
Expand Down Expand Up @@ -61,6 +61,6 @@ export default [
{
name: 'ui / tailwind.css',
path: 'packages/ui/dist/tailwind.css',
limit: '9 KB',
limit: '9.5 KB',
},
];
46 changes: 45 additions & 1 deletion packages/core/src/__tests__/search-verses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { describe, it, expect, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { ApiClient } from '../client';
import { SearchClient } from '../search';
import { isValidStructuralUsfmReference, parseSearchLanguageRange } from '../schemas/search';
import {
MAX_SEARCH_QUERY_GRAPHEMES,
SearchTextQuerySchema,
clampSearchText,
isValidStructuralUsfmReference,
parseSearchLanguageRange,
} from '../schemas/search';
import { server } from './setup';

const apiHost = process.env.YVP_API_HOST;
Expand Down Expand Up @@ -33,9 +39,15 @@ describe('SearchClient.searchVerses', () => {
expect(isValidStructuralUsfmReference('JHN.0.1')).toBe(false);
expect(isValidStructuralUsfmReference('JHN.1.0')).toBe(false);
expect(isValidStructuralUsfmReference('JHN.1.1-0')).toBe(false);
expect(isValidStructuralUsfmReference('JHN.1.5-3')).toBe(false);
expect(isValidStructuralUsfmReference('')).toBe(false);
});

it('accepts chapter-only and inclusive verse ranges', () => {
expect(isValidStructuralUsfmReference('JHN.6')).toBe(true);
expect(isValidStructuralUsfmReference('JHN.6.9-11')).toBe(true);
});

it('accepts valid language ranges and normalizes case and underscores', () => {
expect(parseSearchLanguageRange('EN')).toBe('en');
expect(parseSearchLanguageRange('en')).toBe('en');
Expand All @@ -49,6 +61,38 @@ describe('SearchClient.searchVerses', () => {
expect(() => parseSearchLanguageRange('en-abcdefghi')).toThrow(/Language range must/);
});

it('validates and clamps search text by grapheme clusters', () => {
const combiningCluster = 'e\u0301';
const combiningBoundary = `${'a'.repeat(MAX_SEARCH_QUERY_GRAPHEMES - 1)}${combiningCluster}`;
const surrogateBoundary = `${'a'.repeat(MAX_SEARCH_QUERY_GRAPHEMES - 1)}😀`;

expect(SearchTextQuerySchema.parse(combiningBoundary)).toBe(combiningBoundary);
expect(SearchTextQuerySchema.parse(surrogateBoundary)).toBe(surrogateBoundary);
expect(() => SearchTextQuerySchema.parse(`${combiningBoundary}z`)).toThrow();
expect(() => SearchTextQuerySchema.parse(`${surrogateBoundary}z`)).toThrow();
expect(clampSearchText(`${combiningBoundary}z`)).toBe(combiningBoundary);
expect(clampSearchText(`${surrogateBoundary}z`)).toBe(surrogateBoundary);
});

it('imports core without Intl.Segmenter and clamps fallback input without splitting surrogate pairs', async () => {
const nativeSegmenter = Intl.Segmenter;
vi.resetModules();
Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: undefined });
try {
const core = await import('../index');
expect(core.ApiClient).toBeTypeOf('function');
const boundary = `${'a'.repeat(99)}😀`;
expect(core.clampSearchText(`${boundary}z`)).toBe(boundary);
expect(core.clampSearchText('short')).toBe('short');
const { SearchTextQuerySchema: fallbackSchema } = await import('../schemas/search');
expect(fallbackSchema.parse(boundary)).toBe(boundary);
expect(() => fallbackSchema.parse(`${boundary}z`)).toThrow(/Query must/);
} finally {
Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: nativeSegmenter });
vi.resetModules();
}
});

it('maps wire reference to SDK id and metadata fields', async () => {
const searchClient = createSearchClient();

Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/__tests__/usfm-reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { parseUsfmReference } from '../usfm-reference';

describe('parseUsfmReference', () => {
it.each([
['JHN.6', { book: 'JHN', chapter: '6', verses: [] }],
['JHN.6.9', { book: 'JHN', chapter: '6', verses: [9] }],
['JHN.6.9-11', { book: 'JHN', chapter: '6', verses: [9, 10, 11] }],
['JHN.6.9-9', { book: 'JHN', chapter: '6', verses: [9] }],
['1JN.1.1', { book: '1JN', chapter: '1', verses: [1] }],
['ZZZ.1.1', { book: 'ZZZ', chapter: '1', verses: [1] }],
['REV.22.20-21', { book: 'REV', chapter: '22', verses: [20, 21] }],
] as const)('parses %s', (usfm, expected) => {
expect(parseUsfmReference(usfm)).toEqual(expected);
});

it.each([
'',
'JHN',
'jhn.6.9',
'JOHN.6.9',
'JHN.0.1',
'JHN.1.0',
'JHN.1.1-0',
'JHN.1.5-3',
'JHN.6.9-8',
'JHN.6.9-',
'JHN..9',
'MAT.1.1-2-3',
'JHN.6.9007199254740992',
'JHN.6.1-100000',
])('rejects %s', (usfm) => {
expect(parseUsfmReference(usfm)).toBeNull();
});

it('rejects unsafe integer verse ranges without hanging', () => {
expect(parseUsfmReference('JHN.6.9007199254740992-9007199254740993')).toBeNull();
expect(parseUsfmReference('JHN.6.9007199254740991-9007199254740992')).toBeNull();
}, 1000);

it('parses a full Psalm 119 range', () => {
const parsed = parseUsfmReference('PSA.119.1-176');
expect(parsed?.book).toBe('PSA');
expect(parsed?.chapter).toBe('119');
expect(parsed?.verses[0]).toBe(1);
expect(parsed?.verses[175]).toBe(176);
expect(parsed?.verses).toHaveLength(176);
});
});
30 changes: 30 additions & 0 deletions packages/core/src/bible-html-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,36 @@ describe('transformBibleHtml - intro chapter footnotes', () => {
});

describe('transformBibleHtml - verse wrapping', () => {
it('assigns poetry continuation lines to their verse without swallowing headings or the next verse', () => {
const html = `<div>
<div class="d"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>A Psalm of David.</div>
<div class="q1">The Lord is my shepherd;</div>
<div class="q2">I shall not want.</div>
<div class="q1"><span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>He makes me lie down</div>
<div class="q2">beside quiet waters.</div>
<div class="s1">A heading</div>
<div class="q1"><span class="yv-v" v="3"></span><span class="yv-vlbl">3</span>He restores my soul.</div>
<div class="q2">He guides me.</div>
</div>`;
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
expect(
[...doc.querySelectorAll('.yv-v[v]')].map((node) => [
node.getAttribute('v'),
node.textContent?.trim(),
]),
).toEqual([
['1', '1\u00a0A Psalm of David.'],
['1', 'The Lord is my shepherd;'],
['1', 'I shall not want.'],
['2', '2\u00a0He makes me lie down'],
['2', 'beside quiet waters.'],
['3', '3\u00a0He restores my soul.'],
['3', 'He guides me.'],
]);
expect(doc.querySelector('.s1')?.closest('.yv-v')).toBeNull();
});

it('should wrap verse content in .yv-v[v] elements', () => {
const html = `
<div>
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/bible-html-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export type TransformedBibleHtml = {
};

function wrapVerseContent(doc: Document): void {
const verseBlockSelector =
'.p, p, .d, .q, .q1, .q2, .q3, .q4, .qm, .qm1, .qm2, .qm3, .qm4, .qr, .qc, .m, .mi, .pi, .pi1, .pi2, .pi3, .pi4, .pc, .pr, .pm, .pmo, .pmc, .pmr, .li, .li1, .li2, .li3, .li4';
function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void {
const children = Array.from(paragraph.childNodes);
if (children.length === 0) return;
Expand Down Expand Up @@ -162,7 +164,7 @@ function wrapVerseContent(doc: Document): void {

if (currentParagraph.querySelector('.yv-v[v]')) break;

if (currentParagraph.classList.contains('p') || currentParagraph.tagName === 'P') {
if (currentParagraph.matches(verseBlockSelector)) {
wrapParagraphContent(doc, currentParagraph, verseNum);
}

Expand Down Expand Up @@ -197,8 +199,8 @@ function wrapVerseContent(doc: Document): void {
const nodesToWrap = collectNodesBetweenMarkers(marker, nextMarker);
if (nodesToWrap.length === 0) return;

const currentParagraph = marker.closest('.p, p, div.p');
const nextParagraph = nextMarker?.closest('.p, p, div.p') || null;
const currentParagraph = marker.closest(verseBlockSelector);
const nextParagraph = nextMarker?.closest(verseBlockSelector) || null;
const doc = marker.ownerDocument;

wrapNodesInVerse(marker, verseNum, nodesToWrap);
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ export { LanguagesClient } from './languages';
export { getLanguage } from './languages-language';
export { getLanguages, type GetLanguagesOptions } from './languages-list';
export { SearchClient, type SearchVersesOptions } from './search';
export {
getSuggestedQueries,
getTrendingQueries,
} from './search-queries';
export { getSuggestedQueries, getTrendingQueries } from './search-queries';
export { searchVerses } from './search-verses';
export { searchTopics } from './search-topics';
export { isValidStructuralUsfmReference } from './schemas/search';
export {
MAX_SEARCH_QUERY_GRAPHEMES,
clampSearchText,
isValidStructuralUsfmReference,
parseSearchLanguageRange,
} from './schemas/search';
export { parseUsfmReference, type UsfmReference } from './usfm-reference';
export { OrganizationsClient } from './organizations';
export {
HighlightsClient,
Expand Down
Loading
Loading