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 = + '
Selah.
Next sentence.
'; + 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: '
Selah”
', 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 = + '
First Selah last
'; + 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 = + '
SelahA note.
'; + 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 = + '
SelahA note.
'; + 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 = + '
PausaHebrew Selah
'; + 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 = '
Selah.
'; + 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 = + '
Selah A note.
'; + 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 = + '
Selah.
'; + 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 ( +
+
+

Notes on

+ +
+
+

Notes off

+ +
+
+

Highlight

+ +
+
+

RTL

+ +
+
+ ); + }, + 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('سلاه.'); + }, +};