diff --git a/.changeset/qs-selah-own-line.md b/.changeset/qs-selah-own-line.md
new file mode 100644
index 00000000..eacf8c38
--- /dev/null
+++ b/.changeset/qs-selah-own-line.md
@@ -0,0 +1,8 @@
+---
+'@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.
+
+Transformed HTML now wraps that pause in a `div.yv-qs-line`, and a host paragraph can split into two elements around it. Trailing punctuation and an attached footnote marker move into `.qs` with the pause.
diff --git a/.size-limit.js b/.size-limit.js
index 02cf6429..595f6509 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -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'],
},
{
diff --git a/packages/core/src/bible-html-transformer.test.ts b/packages/core/src/bible-html-transformer.test.ts
index 74e44431..da94a64b 100644
--- a/packages/core/src/bible-html-transformer.test.ts
+++ b/packages/core/src/bible-html-transformer.test.ts
@@ -529,6 +529,222 @@ describe('transformBibleHtml - idempotency', () => {
});
});
+describe('transformBibleHtml - qs pause punctuation', () => {
+ it('keeps a period after a qs pause with the pause', () => {
+ const html =
+ '
';
+ 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: '', want: 'Selah”' },
+ { html: '', want: 'سلاه؟' },
+ { html: '', 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 =
+ 'There is no salvation. Selah
';
+ 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 =
+ 'His glory covered the heavens. Selah.
';
+ 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 =
+ 'First. Selah. Second verse.
';
+ 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 =
+ '';
+ 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 =
+ 'نص. سلاه.
';
+ 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 =
+ '';
+ 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 =
+ '';
+ 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 =
+ '';
+ 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 =
+ 'Pause in his presenceOr Selah
';
+ 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 = '';
+ 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 =
+ '';
+ 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 =
+ '';
+ 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();
+ });
+
+ it('keeps already-transformed pause backfill stable on a second transform', () => {
+ // Marked top-level `.q` with text before the pause so the host stays and a
+ // sibling `.yv-qs-line` is inserted (the case that must get marked too).
+ const html =
+ 'God came. Selah.
';
+ const first = transformBibleHtml(html, createAdapters());
+ const second = transformBibleHtml(first.html, createAdapters());
+ const doc = new DOMParser().parseFromString(first.html, 'text/html');
+ const topLevel = [...doc.body.children];
+
+ expect(topLevel.map((el) => el.className)).toEqual(['q', 'yv-qs-line']);
+ expect(topLevel.every((el) => el.hasAttribute('data-yv-transformed'))).toBe(true);
+ expect(second.html).toBe(first.html);
+ });
+});
+
describe('transformBibleHtmlForBrowser - DOMParser fallback', () => {
it('should throw when DOMParser is unavailable', () => {
const original = globalThis.DOMParser;
diff --git a/packages/core/src/bible-html-transformer.ts b/packages/core/src/bible-html-transformer.ts
index 97cb1b78..b85f3309 100644
--- a/packages/core/src/bible-html-transformer.ts
+++ b/packages/core/src/bible-html-transformer.ts
@@ -6,6 +6,9 @@ const TRANSFORMED_ATTR = 'data-yv-transformed';
const NEEDS_SPACE_BEFORE = /^[^\s.,;:!?)}\]'"'»›]/;
+// Other punctuation, final quotes, and closers — not only Latin .!?…
+const PAUSE_PUNCTUATION = /^[\s\p{Po}\p{Pf}\p{Pe}]+$/u;
+
const ALLOWED_TAGS = new Set([
'DIV',
'P',
@@ -263,6 +266,192 @@ function wrapVerseContent(doc: Document): void {
verseMarkers.forEach(processVerseMarker);
}
+function pausePunctuation(node: Node | null): string | null {
+ if (node?.nodeType !== 3) return null;
+ const text = node.textContent ?? '';
+ if (!PAUSE_PUNCTUATION.test(text) || text.trim() === '') return null;
+ return text.trim();
+}
+
+function isPauseFootnote(node: Node | null): node is Element {
+ if (node?.nodeType !== 1) return false;
+ // SAFETY: nodeType 1 is an Element. TypeScript's Node union does not narrow
+ // on nodeType, and instanceof Element fails across JSDOM/window realms.
+ const element = node as Element;
+ // Fresh transforms still have `.yv-n.f`. Already-transformed HTML (the
+ // backfill path) has replaced those with `[data-verse-footnote]` anchors.
+ return element.matches('.yv-n.f, [data-verse-footnote]');
+}
+
+function nextNonBlankSibling(start: Node | null) {
+ let current = start;
+ const blanks: Node[] = [];
+ while (current?.nodeType === 3 && (current.textContent ?? '').trim() === '') {
+ blanks.push(current);
+ current = current.nextSibling;
+ }
+ return { node: current, blanks };
+}
+
+function removeNodes(nodes: Node[]): void {
+ for (const node of nodes) node.parentNode?.removeChild(node);
+}
+
+function punctuationAfter(start: Node | null): { text: string; node: Node } | null {
+ const { node: current, blanks } = nextNonBlankSibling(start);
+ const text = pausePunctuation(current);
+ if (!text || !current) return null;
+ removeNodes(blanks);
+ return { text, node: current };
+}
+
+function appendFollowingPausePunctuation(doc: Document): void {
+ for (const pause of doc.querySelectorAll('.qs')) {
+ if (pause.closest('.yv-n')) continue;
+ // Refrains keep pause + punctuation inline; CSS also leaves `.qr .qs` alone.
+ if (pause.closest('.qr')) continue;
+ const { node: next, blanks: leadingBlanks } = nextNonBlankSibling(pause.nextSibling);
+ const direct = pausePunctuation(next);
+ if (direct && next) {
+ removeNodes(leadingBlanks);
+ pause.append(direct);
+ next.parentNode?.removeChild(next);
+ continue;
+ }
+ if (!isPauseFootnote(next)) continue;
+ const trailing = punctuationAfter(next.nextSibling);
+ if (!trailing) continue;
+ // Selah, then the marker, then the period, all inside the pause line.
+ removeNodes(leadingBlanks);
+ pause.appendChild(next);
+ pause.append(trailing.text);
+ trailing.node.parentNode?.removeChild(trailing.node);
+ }
+}
+
+const PAUSE_LINE_HOST =
+ '.p, p, .d, .q, .q1, .q2, .q3, .q4, .qm, .qm1, .qm2, .qm3, .qm4, .qc, .m, .mi, .pi, .pi1, .pi2, .pi3, .pi4, .pc, .pr, .pm, .pmo, .pmc, .pmr, .li, .li1, .li2, .li3, .li4';
+
+function isElementVisuallyEmpty(el: Element): boolean {
+ if (el.querySelector('[data-verse-footnote], .yv-n, .qs, .yv-vlbl, .va')) return false;
+ return (el.textContent ?? '').trim() === '';
+}
+
+function readingDirectionOf(el: Element): string | null {
+ let cur: Element | null = el;
+ while (cur) {
+ const dir = cur.getAttribute('dir');
+ if (dir === 'ltr' || dir === 'rtl' || dir === 'auto') return dir;
+ cur = cur.parentElement;
+ }
+ return null;
+}
+
+/**
+ * Move every node after `marker` within `host` into a same-tagged sibling.
+ * Clones intermediate wrappers (such as `.yv-v[v]`) so trailing same-verse
+ * text stays selectable, highlightable, and included in Copy / Share.
+ */
+function takeTrailingContentAfter(host: Element, marker: Node): Element | null {
+ const doc = host.ownerDocument;
+ if (!doc) return null;
+
+ const splitAfter = (parent: Element): DocumentFragment => {
+ const frag = doc.createDocumentFragment();
+ let collecting = false;
+
+ for (const child of Array.from(parent.childNodes)) {
+ if (!collecting) {
+ if (child === marker) {
+ collecting = true;
+ continue;
+ }
+ if (child.nodeType === 1) {
+ // SAFETY: nodeType 1 is an Element. instanceof Element fails across
+ // JSDOM/window realms.
+ const el = child as Element;
+ if (el.contains(marker)) {
+ const inner = splitAfter(el);
+ if (inner.childNodes.length > 0) {
+ // SAFETY: Element.cloneNode(false) returns an Element of the same tag.
+ const wrap = el.cloneNode(false) as Element;
+ wrap.appendChild(inner);
+ frag.appendChild(wrap);
+ }
+ collecting = true;
+ }
+ continue;
+ }
+ continue;
+ }
+ frag.appendChild(child);
+ }
+ return frag;
+ };
+
+ const frag = splitAfter(host);
+ if (frag.childNodes.length === 0) return null;
+
+ // SAFETY: Element.cloneNode(false) returns an Element of the same tag.
+ const after = host.cloneNode(false) as Element;
+ after.appendChild(frag);
+ return after;
+}
+
+/**
+ * Lift a non-refrain `qs` pause out of the inline verse wrapper onto its own
+ * end-aligned line. A block inside `.yv-v` leaves blank highlight bars above
+ * and below the pause; a sibling line-level host keeps one line and still
+ * paints through a matching `.yv-v[v]`.
+ */
+function extractPauseLines(doc: Document): void {
+ for (const pause of Array.from(doc.querySelectorAll('.qs'))) {
+ if (pause.closest('.yv-n')) continue;
+ if (pause.closest('.qr')) continue;
+ if (pause.closest('.yv-qs-line')) continue;
+
+ const verse = pause.closest('.yv-v[v]');
+ const verseNum = verse?.getAttribute('v');
+ const host = pause.closest(PAUSE_LINE_HOST) ?? pause.parentElement;
+ if (!host?.parentNode) continue;
+
+ const nodes: Node[] = [pause];
+ const { node: next, blanks } = nextNonBlankSibling(pause.nextSibling);
+ if (next && isPauseFootnote(next) && next.parentNode === pause.parentNode) {
+ removeNodes(blanks);
+ nodes.push(next);
+ }
+
+ const clusterEnd = nodes[nodes.length - 1]!;
+ const afterHost = takeTrailingContentAfter(host, clusterEnd);
+
+ const line = doc.createElement('div');
+ line.className = 'yv-qs-line';
+ const hostDir = readingDirectionOf(host);
+ if (hostDir) line.setAttribute('dir', hostDir);
+ const lineVerse = doc.createElement('span');
+ lineVerse.className = 'yv-v';
+ if (verseNum) lineVerse.setAttribute('v', verseNum);
+ for (const node of nodes) {
+ lineVerse.appendChild(node);
+ }
+ line.appendChild(lineVerse);
+
+ const parent = host.parentNode;
+ parent.insertBefore(line, host.nextSibling);
+ if (afterHost) {
+ parent.insertBefore(afterHost, line.nextSibling);
+ }
+
+ if (verse && isElementVisuallyEmpty(verse)) {
+ verse.remove();
+ }
+ if (host !== line && isElementVisuallyEmpty(host)) {
+ host.remove();
+ }
+ }
+}
+
function assignFootnoteKeys(doc: Document): void {
let introIdx = 0;
doc.querySelectorAll('.yv-n.f').forEach((fn) => {
@@ -377,15 +566,27 @@ export function transformBibleHtml(
// Older transformed output predates alternate-label spacing. Backfill it
// without repeating verse wrapping or footnote extraction.
addNbspToVerseLabels(doc);
- return { html: options.serializeHtml(doc), direction: resolveTextDirection(roots) };
+ appendFollowingPausePunctuation(doc);
+ extractPauseLines(doc);
+ // extractPauseLines can add top-level .yv-qs-line nodes; mark them so the
+ // next call stays on this backfill path instead of re-wrapping.
+ for (const el of topLevelElements(doc)) {
+ el.setAttribute(TRANSFORMED_ATTR, '');
+ }
+ return {
+ html: options.serializeHtml(doc),
+ direction: resolveTextDirection(topLevelElements(doc)),
+ };
}
wrapVerseContent(doc);
+ appendFollowingPausePunctuation(doc);
assignFootnoteKeys(doc);
const footnotes = Array.from(doc.querySelectorAll('.yv-n.f'));
replaceFootnotesWithAnchors(doc, footnotes);
+ extractPauseLines(doc);
addNbspToVerseLabels(doc);
fixIrregularTables(doc);
diff --git a/packages/core/src/styles/bible-reader-typography.test.ts b/packages/core/src/styles/bible-reader-typography.test.ts
index 5eb2ec2d..e2c2bd02 100644
--- a/packages/core/src/styles/bible-reader-typography.test.ts
+++ b/packages/core/src/styles/bible-reader-typography.test.ts
@@ -6,20 +6,23 @@ const css = readFileSync(resolve(import.meta.dirname, './bible-reader.css'), 'ut
const themeCss = readFileSync(resolve(import.meta.dirname, './theme.css'), 'utf8');
const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
-function rulesForExactClass(cls: string): string {
+function ruleBodies(selector: string): string {
+ const wanted = selector.replace(/\s+/g, ' ').trim();
const bodies: string[] = [];
for (const match of cssWithoutComments.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const rawSel = match[1];
const body = match[2];
if (rawSel === undefined || body === undefined) continue;
const sels = rawSel.split(',').map((s) => s.trim().replace(/\s+/g, ' '));
- if (sels.some((s) => s === `& .${cls}`)) {
- bodies.push(body);
- }
+ if (sels.some((s) => s === wanted)) bodies.push(body);
}
return bodies.join('\n');
}
+function rulesForExactClass(cls: string): string {
+ return ruleBodies(`& .${cls}`);
+}
+
describe('bible-reader Swift-aligned typography', () => {
it('styles body, s1, and q1 with Swift rhythm and logical spacing', () => {
const p = rulesForExactClass('p');
@@ -162,7 +165,6 @@ describe('bible-reader Swift-aligned typography', () => {
expect(rulesForExactClass('it')).toContain('font-style: italic');
expect(rulesForExactClass('add')).toContain('font-style: italic');
expect(rulesForExactClass('qs')).toContain('font-style: italic');
- expect(rulesForExactClass('qs')).not.toContain('display: block');
for (const cls of ['bk', 'add', 'it', 'tl', 'fq', 'fqa', 'qt', 'qs']) {
expect(rulesForExactClass(cls)).toContain('font-size: var(--yv-reader-font-size)');
@@ -174,6 +176,30 @@ describe('bible-reader Swift-aligned typography', () => {
expect(css).not.toMatch(/&\s*\.p\s*\+\s*\.q1\b/);
});
+ it('puts a non-refrain qs pause on its own end-aligned line, and leaves qr alone', () => {
+ const pause = rulesForExactClass('qs');
+ expect(pause).toContain('font-style: italic');
+ expect(pause).not.toContain('display: block');
+
+ const ownLine = rulesForExactClass('yv-qs-line');
+ expect(ownLine).toContain('display: block');
+ expect(ownLine).toContain('text-align: end');
+ expect(ownLine).toContain('text-indent: 0');
+ expect(ownLine).not.toContain('background-color: inherit');
+ expect(ruleBodies('& .qs:not(.qr .qs)')).toBe('');
+ expect(ruleBodies('& .qr .qs')).toBe('');
+ expect(rulesForExactClass('qr')).toContain('display: block');
+ expect(rulesForExactClass('qr')).toContain('text-align: end');
+ expect(rulesForExactClass('qr')).toContain('font-style: italic');
+
+ expect(
+ ruleBodies("&[data-show-notes='true'] .yv-qs-line .qs:has(+ [data-verse-footnote])"),
+ ).toBe('');
+ expect(ruleBodies("&[data-show-notes='true'] .yv-qs-line .qs + [data-verse-footnote]")).toBe(
+ '',
+ );
+ });
+
it('keeps WOC text unmixed: #94000c light / #e4bfc2 dark', () => {
expect(themeCss).toContain('--yv-wj: #94000c;');
expect(themeCss).toContain('--yv-wj: #e4bfc2;');
diff --git a/packages/core/src/styles/bible-reader.css b/packages/core/src/styles/bible-reader.css
index 549dbad7..25891bb0 100644
--- a/packages/core/src/styles/bible-reader.css
+++ b/packages/core/src/styles/bible-reader.css
@@ -1054,13 +1054,25 @@
font-style: italic;
}
- /* \qs - Selah (character style) */
+ /* \qs - Selah (character style). Non-refrain pauses are lifted onto
+ `.yv-qs-line` by the HTML transformer so we never put a block inside the
+ inline `.yv-v` highlight wrapper. */
& .qs {
font-size: var(--yv-reader-font-size);
font-weight: normal;
font-style: italic;
}
+ & .yv-qs-line {
+ display: block;
+ text-align: end;
+ text-indent: 0;
+ }
+
+ /* Footnote markers stay inline beside the pause on the end-aligned line.
+ The old float + negative margin lifted the marker when `.qs` itself was
+ the block; that offset is wrong now that `.yv-qs-line` is the block. */
+
/* \qac - Acrostic letter within a poetic line — Italic */
& .qac {
font-size: var(--yv-reader-font-size);
diff --git a/packages/ui/src/components/verse.stories.tsx b/packages/ui/src/components/verse.stories.tsx
index c798804c..0616ab3f 100644
--- a/packages/ui/src/components/verse.stories.tsx
+++ b/packages/ui/src/components/verse.stories.tsx
@@ -835,3 +835,123 @@ export const VerseSelection: Story = {
).toBeLessThan(1);
},
};
+
+const SELAH_PAUSE_RAW_HTML = `
+
+
3God came from Teman, and the Holy One from Mount Paran. SelahHebrew Selah.
+
His glory covered the heavens.
+
+`;
+
+const SELAH_PAUSE_RTL_RAW_HTML = `
+
+
3جاء الله من تيمان. سلاهسلاه.
+
+`;
+
+async function assertSelahOwnLine(root: HTMLElement, options: { rtl?: boolean } = {}) {
+ const line = root.querySelector('.yv-qs-line');
+ const pause = line?.querySelector('.qs');
+ const pauseVerse = line?.querySelector('.yv-v[v="3"]');
+
+ await expect(line).toBeTruthy();
+ await expect(pause).toBeTruthy();
+ await expect(pauseVerse).toBeTruthy();
+ await expect(root.querySelector('.yv-v .yv-qs-line')).toBeNull();
+ await expect(root.querySelector('.q .qs')).toBeNull();
+
+ const lineStyle = getComputedStyle(line!);
+ const pauseStyle = getComputedStyle(pause!);
+ await expect(lineStyle.display).toBe('block');
+ await expect(pauseStyle.display).not.toBe('block');
+ await expect(pauseStyle.fontStyle).toBe('italic');
+
+ if (options.rtl) {
+ await expect(['end', 'left']).toContain(lineStyle.textAlign);
+ } else {
+ await expect(['end', 'right']).toContain(lineStyle.textAlign);
+ }
+
+ // A block pause inside the inline verse wrapper used to leave blank lines and
+ // grow to ~3× one line. The shelf must stay about one reader line tall.
+ const fontSize = parseFloat(pauseStyle.fontSize);
+ const lineHeight =
+ lineStyle.lineHeight === 'normal' ? fontSize * 1.5 : parseFloat(lineStyle.lineHeight);
+ const height = line!.getBoundingClientRect().height;
+ await expect(height).toBeGreaterThan(fontSize * 0.8);
+ await expect(height).toBeLessThan(lineHeight * 2.1);
+
+ return { line: line!, pause: pause!, pauseVerse: pauseVerse! };
+}
+
+export const SelahPauseOwnLine: Story = {
+ args: { reference: 'HAB.3', versionId: 130 },
+ parameters: { layout: 'padded' },
+ tags: ['integration'],
+ render: () => {
+ const html = transformBibleHtml(SELAH_PAUSE_RAW_HTML).html;
+ const rtlHtml = transformBibleHtml(SELAH_PAUSE_RTL_RAW_HTML).html;
+
+ return (
+
+
+
+
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ await document.fonts.ready;
+
+ const canvas = within(canvasElement);
+ const notesOn = await canvas.findByTestId('selah-notes-on');
+ await waitFor(async () => {
+ await expect(notesOn.querySelector('.yv-qs-line')).toBeTruthy();
+ });
+ const notesOff = await canvas.findByTestId('selah-notes-off');
+ const highlight = await canvas.findByTestId('selah-highlight');
+ const rtl = await canvas.findByTestId('selah-rtl');
+
+ const on = await assertSelahOwnLine(notesOn);
+ await expect(on.pause.textContent).toBe('Selah.');
+ await expect(on.line.querySelector('[data-verse-footnote]')).toBeTruthy();
+
+ const off = await assertSelahOwnLine(notesOff);
+ await expect(off.pause.textContent).toBe('Selah.');
+ await expect(
+ notesOff.querySelector('[data-slot="yv-bible-renderer"]')?.dataset.showNotes,
+ ).toBe('false');
+
+ const painted = await assertSelahOwnLine(highlight);
+ await waitFor(async () => {
+ await expect(painted.pauseVerse.style.backgroundColor).not.toBe('');
+ });
+ await expect(painted.pauseVerse.style.backgroundColor).toContain('color-mix');
+
+ const rtlPause = await assertSelahOwnLine(rtl, { rtl: true });
+ await expect(rtlPause.pause.textContent).toBe('سلاه.');
+ },
+};