Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a8b1c28
test(core): expect a qs pause on its own end-aligned line
cursoragent Oct 30, 2026
ea3fe8e
fix(core): render a qs pause on its own end-aligned line
cursoragent Oct 30, 2026
dbdfd34
docs(core): state the Selah footnote marker stays on the pause line
cursoragent Nov 2, 2026
471d42a
fix(core): keep pause punctuation after the footnote marker
cursoragent Nov 2, 2026
50b621c
Merge branch 'main' into cursor/qs-selah-own-line-6aa8
Dustin-Kelley Nov 3, 2026
351c1a0
docs: add Habakkuk 3:3 before and after reader shots
cursoragent Nov 3, 2026
4f353c8
fix(core): keep the verse highlight behind a block Selah pause
cursoragent Nov 3, 2026
8e3391a
docs(ui): move Selah screenshots to the PR description
Dustin-Kelley Nov 3, 2026
08c60b1
fix(core): group pause punctuation after transformed footnote anchors
Dustin-Kelley Nov 4, 2026
cf3c7ca
fix(core): skip blank text between a qs pause and its footnote
Dustin-Kelley Nov 4, 2026
c190527
fix(core): leave qs pause punctuation alone inside refrains
Dustin-Kelley Nov 4, 2026
29afbb3
fix(core): move Unicode pause punctuation onto the qs line
Dustin-Kelley Nov 4, 2026
3fdd210
test(core): name the qs own-line rule for all non-refrain pauses
Dustin-Kelley Nov 4, 2026
865a245
docs(core): refresh Habakkuk 3:3 yellow highlight after shot
Dustin-Kelley Nov 4, 2026
73ff9cb
docs(core): keep Selah screenshots out of the repo
Dustin-Kelley Nov 4, 2026
12d78e1
fix(core): lift qs Selah onto a verse-tagged line host
Dustin-Kelley Nov 4, 2026
212d913
fix(core): keep trailing text and dir when lifting qs pauses
Dustin-Kelley Nov 4, 2026
6fb40f8
fix(core): keep .yv-v around text after a mid-verse qs pause
Dustin-Kelley Nov 4, 2026
7898c7d
Merge branch 'main' into cursor/qs-selah-own-line-6aa8
Dustin-Kelley Nov 4, 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
6 changes: 6 additions & 0 deletions .changeset/qs-selah-own-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@youversion/platform-core': patch
'@youversion/platform-react-ui': patch
---

Render a Selah pause on its own italic line, aligned to the end. A pause inside a refrain stays on that line.

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.

This changeset does not describe changes that consumers will see. The output HTML now has a new div.yv-qs-line, and a host paragraph splits into two elements around the pause. Trailing punctuation and footnote markers also move into .qs. docs/review-guidelines.md asks changesets to call out behavior changes.

Please add one or two sentences about these changes.

6 changes: 4 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export default [
{
name: 'core / browser entry',
path: 'packages/core/dist/browser.js',
limit: '2 KB',
// extractPauseLines + trailing split; measured ~2.1 kB brotli, +~10%.
limit: '2.4 KB',
ignore: ['jsdom'],
},
{
name: 'core / server entry',
path: 'packages/core/dist/server.js',
limit: '2 KB',
// Same pause-line transform surface as browser; measured ~2.18 kB brotli.
limit: '2.4 KB',
ignore: ['jsdom'],
},
{
Expand Down
201 changes: 201 additions & 0 deletions packages/core/src/bible-html-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,207 @@ describe('transformBibleHtml - idempotency', () => {
});
});

describe('transformBibleHtml - qs pause punctuation', () => {
it('keeps a period after a qs pause with the pause', () => {
const html =
'<div><div class="q"><span class="qs">Selah</span>.</div><div class="q">Next sentence.</div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const pause = doc.querySelector('.yv-qs-line .qs');

expect(pause?.textContent).toBe('Selah.');
expect(pause?.closest('.yv-qs-line')?.textContent).toBe('Selah.');
expect(doc.querySelector('.q')?.textContent).toBe('Next sentence.');
});

it('keeps a closing quote, Arabic question mark, and CJK period with the pause', () => {
const cases = [
{ html: '<div><div class="q"><span class="qs">Selah</span>”</div></div>', want: 'Selah”' },
{ html: '<div><div class="q"><span class="qs">سلاه</span>؟</div></div>', want: 'سلاه؟' },
{ html: '<div><div class="q"><span class="qs">细拉</span>。</div></div>', want: '细拉。' },
] as const;

for (const { html, want } of cases) {
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
expect(doc.querySelector('.qs')?.textContent).toBe(want);
}
});

it('leaves the sentence before a qs pause in q where it was', () => {
const html =
'<div><div class="q">There is no salvation. <span class="qs">Selah</span></div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const line = doc.querySelector('.q');

expect(line?.querySelector('.qs')).toBeNull();
expect(line?.textContent).toContain('There is no salvation.');
expect(doc.querySelector('.yv-qs-line .qs')?.textContent).toBe('Selah');
});

it('lifts a non-refrain qs pause onto its own verse-tagged line', () => {
const html =
'<div><div class="q"><span class="yv-v" v="3"></span>His glory covered the heavens. <span class="qs">Selah</span>.</div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const poetry = doc.querySelector('.q .yv-v[v="3"]');
const pauseLine = doc.querySelector('.yv-qs-line');
const pauseVerse = pauseLine?.querySelector('.yv-v[v="3"]');

const pause = pauseLine?.querySelector('.qs');

expect(poetry?.textContent).toContain('His glory covered the heavens.');
expect(poetry?.querySelector('.qs')).toBeNull();
expect(pause?.textContent).toBe('Selah.');
expect(pause && pauseVerse?.contains(pause)).toBe(true);
expect(doc.querySelector('.yv-v .yv-qs-line')).toBeNull();
});

it('keeps text that followed a mid-host pause after the pause line', () => {
const html =
'<div><div class="p"><span class="yv-v" v="1"></span>First. <span class="qs">Selah</span>. <span class="yv-v" v="2"></span>Second verse.</div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const parts = [...doc.body.querySelectorAll('.p, .yv-qs-line')].map((el) => ({
kind: el.className,
text: (el.textContent ?? '').replace(/\s+/g, ' ').trim(),
}));

expect(parts).toEqual([
{ kind: 'p', text: 'First.' },
{ kind: 'yv-qs-line', text: 'Selah.' },
{ kind: 'p', text: 'Second verse.' },
]);
});

it('keeps same-verse text after a pause inside its .yv-v wrapper', () => {
const html =
'<div><div class="q"><span class="yv-v" v="3">First <span class="qs">Selah</span> last</span></div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const before = doc.querySelectorAll('.q')[0];
const after = doc.querySelectorAll('.q')[1];
const afterVerse = after?.querySelector('.yv-v[v="3"]');

expect(before?.querySelector('.yv-v[v="3"]')?.textContent).toBe('First ');
expect(doc.querySelector('.yv-qs-line .qs')?.textContent).toBe('Selah');
expect(afterVerse?.textContent).toBe(' last');
expect(after?.querySelector(':scope > .yv-v[v="3"]')).toBeTruthy();
});

it('copies the host reading direction onto the pause line', () => {
// Top-level hosts so resolveTextDirection sees both roots after the split.
const html =
'<div class="q" dir="rtl"><span class="yv-v" v="3"></span>نص. <span class="qs">سلاه</span>.</div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');

expect(doc.querySelector('.yv-qs-line')?.getAttribute('dir')).toBe('rtl');
expect(result.direction).toBe('rtl');
});

it('keeps Selah, the footnote marker, then the period when notes are on', () => {
const html =
'<div><div class="q"><span class="qs">Selah</span><span class="yv-n f"><span class="ft">A note</span></span>.</div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const pause = doc.querySelector('.qs');
const parts = [...(pause?.childNodes ?? [])].map((node) => {
if (node.nodeType === Node.TEXT_NODE) return `text:${node.textContent}`;
if (node instanceof Element && node.hasAttribute('data-verse-footnote')) return 'footnote';
return node.nodeName;
});

expect(parts).toEqual(['text:Selah', 'footnote', 'text:.']);
expect(pause?.querySelector('[data-verse-footnote-content]')).toBeTruthy();
expect(
pause?.querySelector('[data-verse-footnote]')?.getAttribute('data-verse-footnote-content'),
).toContain('A note');
});

it('keeps the period with Selah when the footnote marker is off', () => {
const html =
'<div><div class="q"><span class="qs">Selah</span><span class="yv-n f"><span class="ft">A note</span></span>.</div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const pause = doc.querySelector('.qs');
pause?.querySelector('[data-verse-footnote]')?.remove();

expect(pause?.textContent).toBe('Selah.');
});

it('keeps a footnote after a qs pause in q4 as the next marker', () => {
const html =
'<div><div class="q4"><span class="qs">Pausa</span><span class="yv-n f"><span class="ft">Hebrew Selah</span></span></div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const pause = doc.querySelector('.yv-qs-line .qs');

expect(pause?.textContent).toBe('Pausa');
expect(pause?.nextElementSibling?.getAttribute('data-verse-footnote')).toBeTruthy();
expect(pause?.nextElementSibling?.getAttribute('data-verse-footnote-content')).toContain(
'Hebrew Selah',
);
expect(pause?.closest('.yv-qs-line')).toBeTruthy();
});

it('leaves a qs pause inside qr beside the refrain', () => {
const html =
'<div><div class="qr"><span class="qs">Pause in his presence</span><span class="yv-n f"><span class="ft">Or Selah</span></span></div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const refrain = doc.querySelector('.qr');
const pause = refrain?.querySelector(':scope > .qs');

expect(pause?.textContent).toBe('Pause in his presence');
expect(pause?.nextElementSibling?.hasAttribute('data-verse-footnote')).toBe(true);
expect(refrain?.className).toBe('qr');
});

it('leaves a period after a qs pause inside qr outside the pause', () => {
const html = '<div><div class="qr"><span class="qs">Selah</span>.</div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const pause = doc.querySelector('.qr .qs');

expect(pause?.textContent).toBe('Selah');
expect(pause?.nextSibling?.textContent).toBe('.');
});

it('keeps Selah, the footnote marker, then the period when a blank sits between them', () => {
const html =
'<div><div class="q"><span class="qs">Selah</span> <span class="yv-n f"><span class="ft">A note</span></span>.</div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const pause = doc.querySelector('.qs');
const parts = [...(pause?.childNodes ?? [])].map((node) => {
if (node.nodeType === Node.TEXT_NODE) return `text:${node.textContent}`;
if (node instanceof Element && node.hasAttribute('data-verse-footnote')) return 'footnote';
return node.nodeName;
});

expect(parts).toEqual(['text:Selah', 'footnote', 'text:.']);
});

it('keeps Selah, the footnote marker, then the period for already-transformed HTML', () => {
const html =
'<div data-yv-transformed=""><div class="q"><span class="yv-v" v="3"><span class="qs">Selah</span><span data-verse-footnote="3" data-verse-footnote-content="x"></span>.</span></div></div>';
const result = transformBibleHtml(html, createAdapters());
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const pause = doc.querySelector('.yv-qs-line .qs');
const parts = [...(pause?.childNodes ?? [])].map((node) => {
if (node.nodeType === Node.TEXT_NODE) return `text:${node.textContent}`;
if (node instanceof Element && node.hasAttribute('data-verse-footnote')) return 'footnote';
return node.nodeName;
});

expect(parts).toEqual(['text:Selah', 'footnote', 'text:.']);
expect(doc.querySelector('.yv-qs-line .yv-v[v="3"]')).toBeTruthy();
expect(doc.querySelector('.q .qs')).toBeNull();
});
});

describe('transformBibleHtmlForBrowser - DOMParser fallback', () => {
it('should throw when DOMParser is unavailable', () => {
const original = globalThis.DOMParser;
Expand Down
Loading
Loading