From 39362b9b20368a160a593945aa63b468ae843b47 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Tue, 6 Jan 2026 15:25:08 -0600 Subject: [PATCH 01/10] fix(ui): fix how the parsed html and notes gets rendered - the dom was getting re-rendered multiple times especially when adding the bible reader settings. - this change allows the verse text to only be re-rendered when a different html string is passed in explicitly. --- .../src/components/bible-reader.stories.tsx | 63 +++++++++++++++++++ packages/ui/src/components/verse.tsx | 10 ++- packages/ui/src/test/mock-data/passages.json | 3 +- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 01d75bd..931b480 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -225,3 +225,66 @@ export const RealAPI: Story = { ), }; + +export const FootnotesPersistAfterFontSizeChange: Story = { + tags: ['integration'], + args: { + versionId: 111, + book: 'JHN', + chapter: '1', + background: 'light', + }, + render: (args) => ( +
+ + + + +
+ ), + play: async ({ canvasElement }) => { + await waitFor( + async () => { + const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(verseContainer).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + const getFootnoteButtons = () => canvasElement.querySelectorAll('[data-verse-footnote] button'); + + await waitFor( + async () => { + const footnoteButtons = getFootnoteButtons(); + await expect(footnoteButtons.length).toBe(9); + }, + { timeout: 5000 }, + ); + + const initialFootnoteCount = getFootnoteButtons().length; + + const settingsButton = screen.getByRole('button', { name: /settings/i }); + await userEvent.click(settingsButton); + + await waitFor(async () => { + await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + }); + + const increaseFontButton = screen.getByTestId('increase-font-size'); + await userEvent.click(increaseFontButton); + + await waitFor(async () => { + const footnoteButtons = getFootnoteButtons(); + await expect(footnoteButtons.length).toBe(initialFootnoteCount); + }); + + const decreaseFontButton = screen.getByTestId('decrease-font-size'); + await userEvent.click(decreaseFontButton); + await userEvent.click(decreaseFontButton); + + await waitFor(async () => { + const footnoteButtons = getFootnoteButtons(); + await expect(footnoteButtons.length).toBe(initialFootnoteCount); + }); + }, +}; diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 0e1936d..a8df5d2 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -171,10 +171,18 @@ function HtmlWithNotes({ }) { const contentRef = useRef(null); const rootsRef = useRef>(new Map()); + const currentHtmlRef = useRef(''); useEffect(() => { if (!contentRef.current) return; + if (currentHtmlRef.current !== html) { + rootsRef.current.forEach((root) => root.unmount()); + rootsRef.current.clear(); + contentRef.current.innerHTML = html; + currentHtmlRef.current = html; + } + const roots = rootsRef.current; Object.entries(notes).forEach(([verseNum, verseNotes]) => { @@ -198,7 +206,7 @@ function HtmlWithNotes({ }; }, [html, notes, reference]); - return
; + return
; } // Configure DOMPurify to allow specific attributes safe for Bible content diff --git a/packages/ui/src/test/mock-data/passages.json b/packages/ui/src/test/mock-data/passages.json index ffeac7f..6b7b51a 100644 --- a/packages/ui/src/test/mock-data/passages.json +++ b/packages/ui/src/test/mock-data/passages.json @@ -49,8 +49,7 @@ }, "JHN.1": { "id": "JHN.1", - "content": "
1In the beginning was the Word, and the Word was with God, and the Word was God. 2He was with God in the beginning. 3Through him all things were made; without him nothing was made that has been made. 4In him was life, and that life was the light of all mankind. 5The light shines in the darkness, and the darkness has not overcome it.
6There was a man sent from God whose name was John. 7He came as a witness to testify concerning that light, so that through him all might believe. 8He himself was not the light; he came only as a witness to the light.
9The true light that gives light to everyone was coming into the world. 10He was in the world, and though the world was made through him, the world did not recognize him. 11He came to that which was his own, but his own did not receive him. 12Yet to all who did receive him, to those who believed in his name, he gave the right to become children of God— 13children born not of natural descent, nor of human decision or a husband’s will, but born of God.
14The Word became flesh and made his dwelling among us. We have seen his glory, the glory of the one and only Son, who came from the Father, full of grace and truth.
15(John testified concerning him. He cried out, saying, “This is the one I spoke about when I said, ‘He who comes after me has surpassed me because he was before me.’ ”) 16Out of his fullness we have all received grace in place of grace already given. 17For the law was given through Moses; grace and truth came through Jesus Christ. 18No one has ever seen God, but the one and only Son, who is himself God and is in closest relationship with the Father, has made him known.
19Now this was John’s testimony when the Jewish leaders in Jerusalem sent priests and Levites to ask him who he was. 20He did not fail to confess, but confessed freely, “I am not the Messiah.”
21They asked him, “Then who are you? Are you Elijah?”
He said, “I am not.”
“Are you the Prophet?”
He answered, “No.”
22Finally they said, “Who are you? Give us an answer to take back to those who sent us. What do you say about yourself?”
23John replied in the words of Isaiah the prophet, “I am the voice of one calling in the wilderness, ‘Make straight the way for the Lord.’ ”
24Now the Pharisees who had been sent 25questioned him, “Why then do you baptize if you are not the Messiah, nor Elijah, nor the Prophet?”
26“I baptize with water,” John replied, “but among you stands one you do not know. 27He is the one who comes after me, the straps of whose sandals I am not worthy to untie.”
28This all happened at Bethany on the other side of the Jordan, where John was baptizing.
29The next day John saw Jesus coming toward him and said, “Look, the Lamb of God, who takes away the sin of the world! 30This is the one I meant when I said, ‘A man who comes after me has surpassed me because he was before me.’ 31I myself did not know him, but the reason I came baptizing with water was that he might be revealed to Israel.”
32Then John gave this testimony: “I saw the Spirit come down from heaven as a dove and remain on him. 33And I myself did not know him, but the one who sent me to baptize with water told me, ‘The man on whom you see the Spirit come down and remain is the one who will baptize with the Holy Spirit.’ 34I have seen and I testify that this is God’s Chosen One.”
35The next day John was there again with two of his disciples. 36When he saw Jesus passing by, he said, “Look, the Lamb of God!”
37When the two disciples heard him say this, they followed Jesus. 38Turning around, Jesus saw them following and asked, “What do you want?”
They said, “Rabbi” (which means “Teacher”), “where are you staying?”
39 “Come,” he replied, “and you will see.”
So they went and saw where he was staying, and they spent that day with him. It was about four in the afternoon.
40Andrew, Simon Peter’s brother, was one of the two who heard what John had said and who had followed Jesus. 41The first thing Andrew did was to find his brother Simon and tell him, “We have found the Messiah” (that is, the Christ). 42And he brought him to Jesus.
Jesus looked at him and said, “You are Simon son of John. You will be called Cephas” (which, when translated, is Peter).
43The next day Jesus decided to leave for Galilee. Finding Philip, he said to him, “Follow me.”
44Philip, like Andrew and Peter, was from the town of Bethsaida. 45Philip found Nathanael and told him, “We have found the one Moses wrote about in the Law, and about whom the prophets also wrote—Jesus of Nazareth, the son of Joseph.”
46“Nazareth! Can anything good come from there?” Nathanael asked.
“Come and see,” said Philip.
47When Jesus saw Nathanael approaching, he said of him, “Here truly is an Israelite in whom there is no deceit.”
48“How do you know me?” Nathanael asked.
Jesus answered, “I saw you while you were still under the fig tree before Philip called you.”
49Then Nathanael declared, “Rabbi, you are the Son of God; you are the king of Israel.”
50Jesus said, “You believe because I told you I saw you under the fig tree. You will see greater things than that.” 51He then added, “Very truly I tell you, you will see ‘heaven open, and the angels of God ascending and descending on’ the Son of Man.”
", - "bible_id": 111, + "content": "
The Word Became Flesh
1In the beginning was the Word, and the Word was with God, and the Word was God. 2He was with God in the beginning. 3Through him all things were made; without him nothing was made that has been made. 4In him was life, and that life was the light of all mankind. 5The light shines in the darkness, and the darkness has not overcome1:5 Or understood it.
6There was a man sent from God whose name was John. 7He came as a witness to testify concerning that light, so that through him all might believe. 8He himself was not the light; he came only as a witness to the light.
9The true light that gives light to everyone was coming into the world. 10He was in the world, and though the world was made through him, the world did not recognize him. 11He came to that which was his own, but his own did not receive him. 12Yet to all who did receive him, to those who believed in his name, he gave the right to become children of God— 13children born not of natural descent, nor of human decision or a husband’s will, but born of God.
14The Word became flesh and made his dwelling among us. We have seen his glory, the glory of the one and only Son, who came from the Father, full of grace and truth.
15(John testified concerning him. He cried out, saying, “This is the one I spoke about when I said, ‘He who comes after me has surpassed me because he was before me.’ ”) 16Out of his fullness we have all received grace in place of grace already given. 17For the law was given through Moses; grace and truth came through Jesus Christ. 18No one has ever seen God, but the one and only Son, who is himself God and1:18 Some manuscripts but the only Son, who is in closest relationship with the Father, has made him known.
John the Baptist Denies Being the Messiah
19Now this was John’s testimony when the Jewish leaders1:19 The Greek term traditionally translated the Jews (hoi Ioudaioi) refers here and elsewhere in John’s Gospel to those Jewish leaders who opposed Jesus; also in 5:10, 15,16; 7:1,11,13; 9:22; 18:14,28,36; 19:7,12,31,38; 20:19. in Jerusalem sent priests and Levites to ask him who he was. 20He did not fail to confess, but confessed freely, “I am not the Messiah.”
21They asked him, “Then who are you? Are you Elijah?”
He said, “I am not.”
“Are you the Prophet?”
He answered, “No.”
22Finally they said, “Who are you? Give us an answer to take back to those who sent us. What do you say about yourself?”
23John replied in the words of Isaiah the prophet, “I am the voice of one calling in the wilderness, ‘Make straight the way for the Lord.’ ”1:23 Isaiah 40:3
24Now the Pharisees who had been sent 25questioned him, “Why then do you baptize if you are not the Messiah, nor Elijah, nor the Prophet?”
26“I baptize with1:26 Or in; also in verses 31 and 33 (twice) water,” John replied, “but among you stands one you do not know. 27He is the one who comes after me, the straps of whose sandals I am not worthy to untie.”
28This all happened at Bethany on the other side of the Jordan, where John was baptizing.
John Testifies About Jesus
29The next day John saw Jesus coming toward him and said, “Look, the Lamb of God, who takes away the sin of the world! 30This is the one I meant when I said, ‘A man who comes after me has surpassed me because he was before me.’ 31I myself did not know him, but the reason I came baptizing with water was that he might be revealed to Israel.”
32Then John gave this testimony: “I saw the Spirit come down from heaven as a dove and remain on him. 33And I myself did not know him, but the one who sent me to baptize with water told me, ‘The man on whom you see the Spirit come down and remain is the one who will baptize with the Holy Spirit.’ 34I have seen and I testify that this is God’s Chosen One.”1:34 See Isaiah 42:1; many manuscripts is the Son of God.
John’s Disciples Follow Jesus
35The next day John was there again with two of his disciples. 36When he saw Jesus passing by, he said, “Look, the Lamb of God!”
37When the two disciples heard him say this, they followed Jesus. 38Turning around, Jesus saw them following and asked, “What do you want?”
They said, “Rabbi” (which means “Teacher”), “where are you staying?”
39 “Come,” he replied, “and you will see.”
So they went and saw where he was staying, and they spent that day with him. It was about four in the afternoon.
40Andrew, Simon Peter’s brother, was one of the two who heard what John had said and who had followed Jesus. 41The first thing Andrew did was to find his brother Simon and tell him, “We have found the Messiah” (that is, the Christ). 42And he brought him to Jesus.
Jesus looked at him and said, “You are Simon son of John. You will be called Cephas” (which, when translated, is Peter1:42 Cephas (Aramaic) and Peter (Greek) both mean rock.).
Jesus Calls Philip and Nathanael
43The next day Jesus decided to leave for Galilee. Finding Philip, he said to him, “Follow me.”
44Philip, like Andrew and Peter, was from the town of Bethsaida. 45Philip found Nathanael and told him, “We have found the one Moses wrote about in the Law, and about whom the prophets also wrote—Jesus of Nazareth, the son of Joseph.”
46“Nazareth! Can anything good come from there?” Nathanael asked.
“Come and see,” said Philip.
47When Jesus saw Nathanael approaching, he said of him, “Here truly is an Israelite in whom there is no deceit.”
48“How do you know me?” Nathanael asked.
Jesus answered, “I saw you while you were still under the fig tree before Philip called you.”
49Then Nathanael declared, “Rabbi, you are the Son of God; you are the king of Israel.”
50Jesus said, “You believe1:50 Or Do you believe…? because I told you I saw you under the fig tree. You will see greater things than that.” 51He then added, “Very truly I tell you,1:51 The Greek is plural. you1:51 The Greek is plural. will see ‘heaven open, and the angels of God ascending and descending on’1:51 Gen. 28:12 the Son of Man.”
", "reference": "John 1" }, "ISA.43.19": { From d1c5e40784e69595aaddd435895e6b5ad68d550d Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Tue, 6 Jan 2026 15:48:52 -0600 Subject: [PATCH 02/10] refactor(ui): sanitize html before parsing/transforming --- packages/ui/src/components/verse.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index a8df5d2..45294e4 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -26,8 +26,9 @@ type ExtractedNotes = { function extractNotesFromHtml(html: string): ExtractedNotes { if (typeof window === 'undefined') return { html, notes: {} }; + const sanitizedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG); const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); + const doc = parser.parseFromString(sanitizedHtml, 'text/html'); const noteElements = doc.querySelectorAll('span.yv-n.f'); const verseData: Record = {}; @@ -227,14 +228,13 @@ function yvDomTransformer(html: string, extractNotes: boolean = false): Extracte const result = extractNotesFromHtml(html); processedHtml = result.html; extractedNotes = result.notes; + } else { + processedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG); } - // Sanitize HTML to remove any XSS payloads - const sanitizedHtml = DOMPurify.sanitize(processedHtml, DOMPURIFY_CONFIG); - // Safely parse and modify HTML to add spaces to paragraph elements const parser = new DOMParser(); - const doc = parser.parseFromString(sanitizedHtml, 'text/html'); + const doc = parser.parseFromString(processedHtml, 'text/html'); // Adds non-breaking space to the end of verse labels for better copying and pasting // (i.e. "3For God so loved..." to "3 For God so loved...") From fe0626992ad490c292fdb43ccbd66ce56e59e29b Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Tue, 6 Jan 2026 16:46:46 -0600 Subject: [PATCH 03/10] refactor(ui): use react portals for footnotes --- packages/ui/src/components/verse.tsx | 68 ++++++++++++++-------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 45294e4..69ae953 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -1,7 +1,15 @@ 'use client'; -import { useEffect, forwardRef, useState, useRef, type ReactNode } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; +import { + useLayoutEffect, + useEffect, + forwardRef, + useState, + useRef, + memo, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; import DOMPurify from 'isomorphic-dompurify'; import { usePassage } from '@youversion/platform-react-hooks'; import { Popover, PopoverContent, PopoverTrigger, PopoverClose } from '@/components/ui/popover'; @@ -108,7 +116,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { return { html: doc.body.innerHTML, notes }; } -function VerseFootnoteButton({ +const VerseFootnoteButton = memo(function VerseFootnoteButton({ verseNum, verseNotes, reference, @@ -159,7 +167,7 @@ function VerseFootnoteButton({ ); -} +}); function HtmlWithNotes({ html, @@ -171,43 +179,33 @@ function HtmlWithNotes({ reference?: string; }) { const contentRef = useRef(null); - const rootsRef = useRef>(new Map()); - const currentHtmlRef = useRef(''); + const [placeholders, setPlaceholders] = useState>(new Map()); - useEffect(() => { + useLayoutEffect(() => { if (!contentRef.current) return; + contentRef.current.innerHTML = html; - if (currentHtmlRef.current !== html) { - rootsRef.current.forEach((root) => root.unmount()); - rootsRef.current.clear(); - contentRef.current.innerHTML = html; - currentHtmlRef.current = html; - } - - const roots = rootsRef.current; - - Object.entries(notes).forEach(([verseNum, verseNotes]) => { - const placeholder = contentRef.current?.querySelector(`[data-verse-footnote="${verseNum}"]`); + const map = new Map(); + Object.keys(notes).forEach((verseNum) => { + const el = contentRef.current?.querySelector(`[data-verse-footnote="${verseNum}"]`); + if (el) map.set(verseNum, el); + }); + setPlaceholders(map); + }, [html, notes]); - if (placeholder) { - let root = roots.get(verseNum); - if (!root) { - root = createRoot(placeholder); - roots.set(verseNum, root); - } - root.render( + return ( + <> +
+ {Array.from(placeholders.entries()).map(([verseNum, el]) => { + const verseNotes = notes[verseNum]; + if (!verseNotes) return null; + return createPortal( , + el, ); - } - }); - - return () => { - roots.forEach((root) => root.unmount()); - roots.clear(); - }; - }, [html, notes, reference]); - - return
; + })} + + ); } // Configure DOMPurify to allow specific attributes safe for Bible content From 08da782a65a98333f025f703d0a24352182d4a9b Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Tue, 6 Jan 2026 17:16:37 -0600 Subject: [PATCH 04/10] fix(ui): fix footnotes popover - uses new props on our popover component to reduce redundancy in the header of the popover. --- packages/ui/src/components/verse.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 69ae953..b11e5d8 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -12,10 +12,8 @@ import { import { createPortal } from 'react-dom'; import DOMPurify from 'isomorphic-dompurify'; import { usePassage } from '@youversion/platform-react-hooks'; -import { Popover, PopoverContent, PopoverTrigger, PopoverClose } from '@/components/ui/popover'; -import { Button } from './ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Footnote } from './icons/footnote'; -import { X as XIcon } from 'lucide-react'; const NON_BREAKING_SPACE = '\u00A0'; @@ -136,16 +134,10 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({ - -
- Footnotes - - - -
+
{verseReference}
Date: Wed, 7 Jan 2026 09:54:21 -0600 Subject: [PATCH 05/10] test(ui): fix flaky test --- .changeset/puny-pigs-sniff.md | 7 +++++++ packages/ui/src/components/bible-widget-view.stories.tsx | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/puny-pigs-sniff.md diff --git a/.changeset/puny-pigs-sniff.md b/.changeset/puny-pigs-sniff.md new file mode 100644 index 0000000..bb826df --- /dev/null +++ b/.changeset/puny-pigs-sniff.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-react-hooks': patch +'@youversion/platform-core': patch +'@youversion/platform-react-ui': patch +--- + +Refactors footnotes implementation to use React portals, improves HTML sanitization, and fixes footnote popover behavior. diff --git a/packages/ui/src/components/bible-widget-view.stories.tsx b/packages/ui/src/components/bible-widget-view.stories.tsx index f187f51..ab784d9 100644 --- a/packages/ui/src/components/bible-widget-view.stories.tsx +++ b/packages/ui/src/components/bible-widget-view.stories.tsx @@ -92,7 +92,10 @@ export const WithVersionPicker: Story = { ); }); - await expect(screen.getByText(/luke 1:39-45 amp/i)).toBeVisible(); + await waitFor(async () => { + const heading = screen.getByRole('heading', { level: 2, name: /luke 1:39-45/i }); + await expect(heading).toHaveTextContent(/amp/i); + }); }, }; From 541f33ded8e803aca0d1658db57139dddbc93482 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Wed, 7 Jan 2026 10:44:09 -0600 Subject: [PATCH 06/10] fix(ui): fix footnotes icon positioning and size as font size changes --- packages/ui/src/components/verse.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index b11e5d8..c242378 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -129,9 +129,9 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({ Date: Wed, 7 Jan 2026 12:31:12 -0600 Subject: [PATCH 07/10] feat(ui): rewrite footnote extraction with proper verse boundary detection - Fix verse boundaries to work across paragraph elements using TreeWalker - Add `v` and `usfm` to DOMPurify allowed attributes (was stripping verse markers) - Place footnote icons at actual verse end, not next verse's paragraph - Exclude headers (.yv-h) from popover verse text - Add spacing between paragraphs in reconstructed verse text - Style footnote markers (A, B, C) with muted foreground color - Inherit reader font size in popover verse content - Remove verse number from popover verse text (shown in header already) - Add tests for footnote extraction and placement - Simplify and refactor extraction logic (~145 to ~85 lines) --- packages/ui/src/components/verse.test.tsx | 84 ++++++++++ packages/ui/src/components/verse.tsx | 178 +++++++++++++--------- 2 files changed, 187 insertions(+), 75 deletions(-) diff --git a/packages/ui/src/components/verse.test.tsx b/packages/ui/src/components/verse.test.tsx index ca9b89b..ac9398d 100644 --- a/packages/ui/src/components/verse.test.tsx +++ b/packages/ui/src/components/verse.test.tsx @@ -220,6 +220,90 @@ describe('Verse.Html - XSS Protection', () => { }); }); +describe('Verse.Html - Footnotes', () => { + it('should extract footnotes and create placeholders', async () => { + const htmlWithFootnotes = ` +
+ 5The light shines in the darkness, and the + darkness has not overcome1:5 Or understood + it. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const placeholder = container.querySelector('[data-verse-footnote="5"]'); + expect(placeholder).not.toBeNull(); + }); + }); + + it('should remove original footnote elements', async () => { + const htmlWithFootnotes = ` +
+ 5The light shines1:5 Or understood it. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const footnoteElements = container.querySelectorAll('.yv-n.f'); + expect(footnoteElements.length).toBe(0); + }); + }); + + it('should place footnote at end of correct verse (v42 not v43)', async () => { + const htmlWithFootnotes = ` +
+ 42And he brought him to Jesus. +
+
+ Jesus looked at him and said, + "You are Simon son of John. You will be called Cephas" + (which, when translated, is Peter1:42 Cephas (Aramaic) and Peter (Greek) both mean rock.). +
+
Jesus Calls Philip and Nathanael
+
+ 43The next day Jesus decided to leave for Galilee. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const placeholder42 = container.querySelector('[data-verse-footnote="42"]'); + expect(placeholder42).not.toBeNull(); + + const verse43Marker = container.querySelector('.yv-v[v="43"]'); + expect(verse43Marker).not.toBeNull(); + + const position42 = placeholder42?.compareDocumentPosition(verse43Marker!); + expect(position42! & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + }); + + it('should handle multiple footnotes in a single verse', async () => { + const htmlWithMultipleNotes = ` +
+ 51He then added, + "Very truly I tell you,1:51 The Greek is plural. + you1:51 The Greek is plural. + will see heaven open." +
+ `; + + const { container } = render(); + + await waitFor(() => { + const placeholder = container.querySelector('[data-verse-footnote="51"]'); + expect(placeholder).not.toBeNull(); + const footnoteElements = container.querySelectorAll('.yv-n.f'); + expect(footnoteElements.length).toBe(0); + }); + }); +}); + describe('Verse.Text', () => { it('should render verse with number and text (default size)', () => { const { container } = render(); diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index c242378..32d841b 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -29,88 +29,105 @@ type ExtractedNotes = { notes: Record; }; +function isExcludedNode(node: Node): boolean { + if (!(node instanceof Element)) return false; + if (node.classList.contains('yv-v') || node.classList.contains('yv-vlbl')) return true; + if (node.classList.contains('yv-h') || node.closest('.yv-h')) return true; + if (node.classList.contains('yv-n') || node.closest('.yv-n')) return true; + return false; +} + function extractNotesFromHtml(html: string): ExtractedNotes { if (typeof window === 'undefined') return { html, notes: {} }; - const sanitizedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG); - const parser = new DOMParser(); - const doc = parser.parseFromString(sanitizedHtml, 'text/html'); - const noteElements = doc.querySelectorAll('span.yv-n.f'); - const verseData: Record = {}; - - // Build the verse html, and store notes for the footnotes popover - noteElements.forEach((element) => { - let label: Element | null = null; - let node: Node | null = element.previousSibling; - while (node) { - if (node instanceof Element && node.classList.contains('yv-vlbl')) { - label = node; - break; - } - node = node.previousSibling; + const doc = new DOMParser().parseFromString( + DOMPurify.sanitize(html, DOMPURIFY_CONFIG), + 'text/html', + ); + const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); + if (!verseMarkers.length) return { html: doc.body.innerHTML, notes: {} }; + + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + const allNodes: Node[] = []; + do { + allNodes.push(walker.currentNode); + } while (walker.nextNode()); + + const nodeIndex = new Map(allNodes.map((n, i) => [n, i])); + const footnotes = doc.querySelectorAll('.yv-n.f'); + + const verses = verseMarkers.map((marker, i) => ({ + num: marker.getAttribute('v') || '0', + start: nodeIndex.get(marker) ?? 0, + end: verseMarkers[i + 1] + ? (nodeIndex.get(verseMarkers[i + 1]) ?? allNodes.length) + : allNodes.length, + fns: [] as Element[], + })); + + footnotes.forEach((fn) => { + const idx = nodeIndex.get(fn); + if (idx !== undefined) { + const verse = [...verses].reverse().find((v) => idx > v.start); + if (verse) verse.fns.push(fn); } + }); - const verseNum = label?.textContent?.trim() || '0'; - - if (!verseData[verseNum]) { - let verseHtml = `${verseNum} `; - let current: Node | null = label?.nextSibling || null; - let noteIdx = 0; - - while (current) { - if (current instanceof Element && current.classList.contains('yv-v')) break; - if (current instanceof Element) { - if (current.classList.contains('yv-n') && current.classList.contains('f')) { - verseHtml += `${LETTERS[noteIdx] || noteIdx + 1}`; - noteIdx++; - } else { - verseHtml += current.outerHTML; - } - } else if (current.nodeType === Node.TEXT_NODE) { - verseHtml += current.textContent || ''; + const withNotes = verses.filter((v) => v.fns.length > 0); + + const notes: Record = {}; + withNotes.forEach((verse) => { + let text = ''; + let noteIdx = 0; + let lastP: Element | null = null; + + for (let i = verse.start; i < verse.end; i++) { + const node = allNodes[i]; + const parent = node.parentNode as Element | null; + + if (node instanceof Element) { + if (node.classList.contains('yv-h') || node.closest('.yv-h')) continue; + if (node.classList.contains('yv-n') && node.classList.contains('f')) { + text += `${LETTERS[noteIdx++] || noteIdx}`; + } else if ( + !node.classList.contains('yv-v') && + !node.classList.contains('yv-vlbl') && + !node.childNodes.length + ) { + text += node.textContent || ''; } - current = current.nextSibling; + } else if (node.nodeType === Node.TEXT_NODE && parent) { + if (parent.closest('.yv-h') || parent.closest('.yv-n.f')) continue; + if (parent.classList.contains('yv-v') || parent.classList.contains('yv-vlbl')) continue; + const curP = parent.closest('.p, p, div.p'); + if (lastP && curP && lastP !== curP) text += ' '; + text += node.textContent || ''; + if (curP) lastP = curP; } - - verseData[verseNum] = { - verseHtml, - notes: [], - elements: [], - }; } - verseData[verseNum].notes.push(element.innerHTML || ''); - verseData[verseNum].elements.push(element); - }); - - // Place the popovers at the end of the verse if notes exist in the verse - // and remove the note elements from the DOM as they are now in the popover - // element. - Object.entries(verseData).forEach(([verseNum, { elements }]) => { - const lastElement = elements[elements.length - 1]; - let endNode: Node | null = lastElement || null; - let current: Node | null = lastElement?.nextSibling || null; - - while (current) { - if (current instanceof Element && current.classList.contains('yv-v')) break; - endNode = current; - current = current.nextSibling; - } - - const placeholder = doc.createElement('span'); - placeholder.setAttribute('data-verse-footnote', verseNum); - if (endNode?.parentNode) { - endNode.parentNode.insertBefore(placeholder, endNode.nextSibling); + notes[verse.num] = { verseHtml: text, notes: verse.fns.map((fn) => fn.innerHTML) }; + + for (let i = verse.end - 1; i > verse.start; i--) { + const node = allNodes[i]; + const parent = node.parentNode as Element | null; + if ( + node.nodeType === Node.TEXT_NODE && + node.textContent?.trim() && + parent && + !isExcludedNode(parent) && + !parent.closest('.yv-n') && + !parent.closest('.yv-h') + ) { + const placeholder = doc.createElement('span'); + placeholder.setAttribute('data-verse-footnote', verse.num); + parent.insertBefore(placeholder, node.nextSibling); + break; + } } - - elements.forEach((el) => el.remove()); - }); - - const notes: Record = {}; - Object.entries(verseData).forEach(([verseNum, { verseHtml, notes: noteContents }]) => { - notes[verseNum] = { verseHtml, notes: noteContents }; }); + footnotes.forEach((fn) => fn.remove()); return { html: doc.body.innerHTML, notes }; } @@ -118,10 +135,12 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({ verseNum, verseNotes, reference, + fontSize, }: { verseNum: string; verseNotes: VerseNotes; reference?: string; + fontSize?: number; }) { const verseReference = reference ? `${reference}:${verseNum}` : `Verse ${verseNum}`; return ( @@ -135,13 +154,14 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({ -
+
{verseReference}
    @@ -165,10 +185,12 @@ function HtmlWithNotes({ html, notes, reference, + fontSize, }: { html: string; notes: Record; reference?: string; + fontSize?: number; }) { const contentRef = useRef(null); const [placeholders, setPlaceholders] = useState>(new Map()); @@ -192,7 +214,12 @@ function HtmlWithNotes({ const verseNotes = notes[verseNum]; if (!verseNotes) return null; return createPortal( - , + , el, ); })} @@ -202,7 +229,7 @@ function HtmlWithNotes({ // Configure DOMPurify to allow specific attributes safe for Bible content const DOMPURIFY_CONFIG = { - ALLOWED_ATTR: ['class', 'style', 'id'], + ALLOWED_ATTR: ['class', 'style', 'id', 'v', 'usfm'], ALLOW_DATA_ATTR: true, }; @@ -382,6 +409,7 @@ export const Verse = { html={transformedData.html} notes={transformedData.notes} reference={reference} + fontSize={fontSize} /> ); From 706b89d466f76b27fa584ad99f86633dbf3c5ee9 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 7 Jan 2026 13:17:23 -0600 Subject: [PATCH 08/10] fix: resolve type linting --- packages/ui/src/components/verse.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 32d841b..3e02e0b 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -56,14 +56,15 @@ function extractNotesFromHtml(html: string): ExtractedNotes { const nodeIndex = new Map(allNodes.map((n, i) => [n, i])); const footnotes = doc.querySelectorAll('.yv-n.f'); - const verses = verseMarkers.map((marker, i) => ({ - num: marker.getAttribute('v') || '0', - start: nodeIndex.get(marker) ?? 0, - end: verseMarkers[i + 1] - ? (nodeIndex.get(verseMarkers[i + 1]) ?? allNodes.length) - : allNodes.length, - fns: [] as Element[], - })); + const verses = verseMarkers.map((marker, i) => { + const nextMarker = verseMarkers[i + 1]; + return { + num: marker.getAttribute('v') || '0', + start: nodeIndex.get(marker) ?? 0, + end: nextMarker ? (nodeIndex.get(nextMarker) ?? allNodes.length) : allNodes.length, + fns: [] as Element[], + }; + }); footnotes.forEach((fn) => { const idx = nodeIndex.get(fn); @@ -83,6 +84,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { for (let i = verse.start; i < verse.end; i++) { const node = allNodes[i]; + if (!node) continue; const parent = node.parentNode as Element | null; if (node instanceof Element) { @@ -110,6 +112,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { for (let i = verse.end - 1; i > verse.start; i--) { const node = allNodes[i]; + if (!node) continue; const parent = node.parentNode as Element | null; if ( node.nodeType === Node.TEXT_NODE && From 8807d085a656dda88c3dc4353fb2daea4de9ddcd Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 7 Jan 2026 13:22:19 -0600 Subject: [PATCH 09/10] docs(verse): add comments to improve code readability --- packages/ui/src/components/verse.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 3e02e0b..a358dd7 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -29,6 +29,10 @@ type ExtractedNotes = { notes: Record; }; +/** + * Checks if a node should be excluded from verse text reconstruction. + * Excludes: verse markers (.yv-v), verse labels (.yv-vlbl), headers (.yv-h), and footnotes (.yv-n). + */ function isExcludedNode(node: Node): boolean { if (!(node instanceof Element)) return false; if (node.classList.contains('yv-v') || node.classList.contains('yv-vlbl')) return true; @@ -37,6 +41,20 @@ function isExcludedNode(node: Node): boolean { return false; } +/** + * Extracts footnotes from Bible HTML and prepares data for footnote popovers. + * + * This function does three things: + * 1. Identifies verse boundaries using `.yv-v[v]` markers (verses can span multiple paragraphs) + * 2. For each verse with footnotes, builds a plain-text version with A/B/C markers for the popover + * 3. Inserts placeholder spans at the end of each verse (where the footnote icon will render) + * + * The challenge: verses don't respect paragraph boundaries. A verse starts at `.yv-v[v="X"]` + * and ends at the next `.yv-v[v]` marker, potentially spanning multiple `
    ` elements. + * We use a TreeWalker to flatten the DOM into document order, then use index ranges to define verses. + * + * @returns Modified HTML with footnotes removed and placeholders inserted, plus notes data for popovers + */ function extractNotesFromHtml(html: string): ExtractedNotes { if (typeof window === 'undefined') return { html, notes: {} }; @@ -47,6 +65,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); if (!verseMarkers.length) return { html: doc.body.innerHTML, notes: {} }; + // Flatten DOM into document order so we can define verse boundaries by index ranges const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); const allNodes: Node[] = []; do { @@ -56,6 +75,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { const nodeIndex = new Map(allNodes.map((n, i) => [n, i])); const footnotes = doc.querySelectorAll('.yv-n.f'); + // Define verse boundaries: each verse spans from its marker to the next marker (or end of content) const verses = verseMarkers.map((marker, i) => { const nextMarker = verseMarkers[i + 1]; return { @@ -66,6 +86,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { }; }); + // Assign each footnote to its containing verse (find verse whose range contains the footnote) footnotes.forEach((fn) => { const idx = nodeIndex.get(fn); if (idx !== undefined) { @@ -78,6 +99,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { const notes: Record = {}; withNotes.forEach((verse) => { + // Build plain-text verse content for popover, replacing footnotes with A/B/C markers let text = ''; let noteIdx = 0; let lastP: Element | null = null; @@ -101,6 +123,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { } else if (node.nodeType === Node.TEXT_NODE && parent) { if (parent.closest('.yv-h') || parent.closest('.yv-n.f')) continue; if (parent.classList.contains('yv-v') || parent.classList.contains('yv-vlbl')) continue; + // Add space when transitioning between paragraphs (verses can span multiple

    elements) const curP = parent.closest('.p, p, div.p'); if (lastP && curP && lastP !== curP) text += ' '; text += node.textContent || ''; @@ -110,6 +133,7 @@ function extractNotesFromHtml(html: string): ExtractedNotes { notes[verse.num] = { verseHtml: text, notes: verse.fns.map((fn) => fn.innerHTML) }; + // Insert placeholder at end of verse content (walk backwards to find last text node) for (let i = verse.end - 1; i > verse.start; i--) { const node = allNodes[i]; if (!node) continue; From c1075858718989a574c3250cf665fb2aa8e77f2c Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Fri, 9 Jan 2026 11:48:25 -0600 Subject: [PATCH 10/10] YPE-999: feat(ui): add dark mode to footnotes popover feat(ui): add dark mode to footnotes --- .../src/components/bible-reader.stories.tsx | 52 ++++++++++++ packages/ui/src/components/bible-reader.tsx | 2 + packages/ui/src/components/verse.stories.tsx | 83 +++++++++++++++++++ packages/ui/src/components/verse.tsx | 22 ++++- 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 931b480..8d9ea63 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -288,3 +288,55 @@ export const FootnotesPersistAfterFontSizeChange: Story = { }); }, }; + +export const ThemeOverridesProvider: Story = { + tags: ['integration'], + args: { + versionId: 111, + book: 'JHN', + chapter: '1', + background: 'light', + }, + globals: { + theme: 'dark', + }, + render: (args) => ( +

    + + + + +
    + ), + play: async ({ canvasElement }) => { + await waitFor( + async () => { + const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(verseContainer).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + const readerTheme = canvasElement.querySelector('[data-yv-theme="light"]'); + await expect(readerTheme).toBeInTheDocument(); + + await waitFor( + async () => { + const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + await expect(footnoteButton).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); + + await userEvent.click(footnoteButton!); + + await waitFor(async () => { + const popover = document.querySelector('[data-slot="popover-content"]'); + await expect(popover).toBeInTheDocument(); + await expect(popover?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); + }); + }, +}; diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index fa8529b..f014508 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -151,6 +151,7 @@ function Root({ function Content() { const { + background, book, chapter, versionId, @@ -191,6 +192,7 @@ function Content() { fontSize={currentFontSize} lineHeight={lineHeight} showVerseNumbers={showVerseNumbers} + theme={background} /> {version?.copyright && ( diff --git a/packages/ui/src/components/verse.stories.tsx b/packages/ui/src/components/verse.stories.tsx index df13b54..fc80fbc 100644 --- a/packages/ui/src/components/verse.stories.tsx +++ b/packages/ui/src/components/verse.stories.tsx @@ -226,3 +226,86 @@ export const FootnoteInteraction: Story = { }); }, }; + +export const FootnotePopoverThemeLight: Story = { + args: { + reference: 'JHN.1', + versionId: 111, + renderNotes: true, + showVerseNumbers: true, + theme: 'light', + }, + tags: ['integration'], + play: async ({ canvasElement }) => { + await waitFor( + async () => { + const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(verseContainer).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + await waitFor( + async () => { + const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + await expect(footnoteButton).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); + + await userEvent.click(footnoteButton!); + + await waitFor(async () => { + const popover = document.querySelector('[data-slot="popover-content"]'); + await expect(popover).toBeInTheDocument(); + await expect(popover?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); + }); + }, +}; + +export const FootnotePopoverThemeDark: Story = { + args: { + reference: 'JHN.1', + versionId: 111, + renderNotes: true, + showVerseNumbers: true, + theme: 'dark', + }, + tags: ['integration'], + render: (args) => ( +
    + +
    + ), + play: async ({ canvasElement }) => { + await waitFor( + async () => { + const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(verseContainer).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + await waitFor( + async () => { + const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + await expect(footnoteButton).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + await expect(footnoteButton?.closest('[data-yv-theme="dark"]')).toBeInTheDocument(); + + await userEvent.click(footnoteButton!); + + await waitFor(async () => { + const popover = document.querySelector('[data-slot="popover-content"]'); + await expect(popover).toBeInTheDocument(); + await expect(popover?.closest('[data-yv-theme="dark"]')).toBeInTheDocument(); + }); + }, +}; diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index a358dd7..4d933f0 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -11,7 +11,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import DOMPurify from 'isomorphic-dompurify'; -import { usePassage } from '@youversion/platform-react-hooks'; +import { usePassage, useTheme } from '@youversion/platform-react-hooks'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Footnote } from './icons/footnote'; @@ -163,16 +163,18 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({ verseNotes, reference, fontSize, + theme, }: { verseNum: string; verseNotes: VerseNotes; reference?: string; fontSize?: number; + theme: 'light' | 'dark'; }) { const verseReference = reference ? `${reference}:${verseNum}` : `Verse ${verseNum}`; return ( - +