Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0e9edee
Feat: Add intro chapter button to bible picker
cameronapak Feb 16, 2026
c58cd20
Refactor bible reader to conditionally render header
cameronapak Feb 16, 2026
46d765b
Fix: Conditionally render grid columns in Bible reader toolbar
cameronapak Feb 16, 2026
2443fdf
Fix: Display chapter title or "Intro" in picker
cameronapak Feb 16, 2026
b72671c
Add '.ili1' CSS class to bible reader styles
cameronapak Feb 16, 2026
4a3f615
fix: Correct typo in bible reader styles
cameronapak Feb 16, 2026
7b05d1e
Update bible reader styles with new CSS classes
cameronapak Feb 17, 2026
e73ae5e
Add styling for verse labels
cameronapak Feb 17, 2026
8be19f3
Make the font of the h1 Bible book and number be our serif font
cameronapak Feb 17, 2026
2ae9aa8
Add USFM styling for reader component
cameronapak Feb 17, 2026
92b4bd8
In Arabic/Hebrew Bible versions, this puts the footnote reference
cameronapak Feb 17, 2026
0781b1d
fix(ui): show unavailable message when chapter doesn't exist in selected
cameronapak Feb 17, 2026
dfba9b4
fix(ui): improve intro chapter detection and add missing test coverage
cameronapak Feb 17, 2026
9dc55a9
Fix: Wait for intro label in bible reader story
cameronapak Feb 17, 2026
23ec149
Update bible reader intro chapter story
cameronapak Feb 17, 2026
63e5a27
fix(ui): add top margin to section headings and intro chapter story
cameronapak Feb 17, 2026
1cd3e71
Refactor bible picker to use chapter label
cameronapak Feb 17, 2026
e19bcab
rm unnecessary story test
cameronapak Feb 17, 2026
d28b668
Refactor bible reader CSS for clarity and consistency
cameronapak Feb 17, 2026
570ae82
Align bible reader text to start
cameronapak Feb 17, 2026
3b905f4
Refactor bible reader CSS margins and indents
cameronapak Feb 17, 2026
c6db4e7
Refactor: Adjust bible reader list indentations
cameronapak Feb 17, 2026
aa95330
Add spacing to introduction list items
cameronapak Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions packages/ui/src/components/bible-chapter-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,25 @@ function Root({
<AccordionContent>
{bookItem.chapters && bookItem.chapters.length > 0 ? (
<div className="yv:grid yv:grid-cols-5 yv:gap-2">
{bookItem.intro?.id && bookItem.intro?.passage_id ? (
<PopoverClose asChild key={`${bookItem.id}-${bookItem.intro.passage_id}`}>
<Button
variant="secondary"
size="icon"
className="yv:aspect-square yv:w-full yv:h-full yv:flex yv:items-center yv:justify-center yv:rounded-[4px]"
onClick={() => {
const chapterId = bookItem.intro?.passage_id.split('.').pop();
if (chapterId) {
setBook(bookItem.id);
setChapter(chapterId);
setSearchQuery('');
}
}}
Comment on lines +175 to +181
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): Is there a reason we're not abstracting this into a helper function?

>
<InfoIcon />
</Button>
</PopoverClose>
) : null}
{bookItem.chapters.map((chapterRef) => {
const chapterId = chapterRef.passage_id.split('.').pop() || '';
return (
Expand All @@ -179,11 +198,7 @@ function Root({
setSearchQuery('');
}}
>
{!chapterId.toLowerCase().includes('intro') ? (
chapterId
) : (
<InfoIcon />
)}
{chapterId}
</Button>
</PopoverClose>
);
Expand Down Expand Up @@ -229,7 +244,10 @@ export type TriggerProps = Omit<React.ComponentProps<typeof PopoverTrigger>, 'ch
| React.ReactNode
| ((props: {
book: string;
/** Raw chapter ID as passed to the Root component (e.g. "GEN.1", "GEN.INTRO"). */
chapter: string;
/** Display label for the current chapter (e.g. "1", "Intro"). */
chapterLabel: string;
currentBook: BibleBook | undefined;
loading: boolean;
}) => React.ReactNode);
Expand All @@ -243,13 +261,18 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) {
const theme = background || providerTheme;

const currentBook = books?.data?.find((bookItem) => bookItem.id === book);
let chapterLabel: string =
currentBook?.chapters?.find((ch) => ch.id === chapter)?.title || chapter;
if (!!currentBook?.intro && chapter === currentBook.intro.id) {
chapterLabel = currentBook.intro.title;
}
const buttonText = loading
? 'Loading...'
: `${currentBook?.title || 'Select a chapter'}${chapter ? ` ${chapter}` : ''}`;
: `${currentBook?.title || 'Select a chapter'}${chapterLabel ? ` ${chapterLabel}` : ''}`;

const content =
typeof children === 'function'
? children({ book, chapter, currentBook, loading })
? children({ book, chapter, chapterLabel, currentBook, loading })
: children || <Button variant="secondary">{buttonText}</Button>;

return (
Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/components/bible-reader.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,3 +638,82 @@ export const WithoutAuth: Story = {
await expect(settingsButton).toBeInTheDocument();
},
};

/**
* Tests that a rich intro chapter (Joshua) renders correctly with real-world content
* including structured sections (At a Glance, Purpose, Major Themes), italic spans,
* bold-italic spans, and special formatting classes (imt1, imt2, is, ili, ip, etc.).
* The h1 header should be hidden and all intro content should render.
*/
export const JoshuaIntroChapter: Story = {
tags: ['integration'],
args: {
defaultVersionId: 1849,
book: 'JOS',
chapter: 'INTRO',
},
render: (args) => (
<div className="yv:h-screen yv:bg-background">
<BibleReader.Root {...args}>
<BibleReader.Content />
<BibleReader.Toolbar />
</BibleReader.Root>
</div>
),
play: async ({ canvasElement }) => {
// Wait for the intro content to fully load (not just the renderer element)
await waitFor(
async () => {
const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
await expect(verseContainer).toBeInTheDocument();
await expect(verseContainer?.textContent).toContain('Joshua');
},
{ timeout: 5000 },
);

const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]')!;

// The h1 header should not be present for intro chapters
const h1 = canvasElement.querySelector('h1');
await expect(h1).not.toBeInTheDocument();

// The unavailable message should not appear
const hasUnavailableText = verseContainer.textContent?.includes('not available');
await expect(hasUnavailableText).not.toBeTruthy();

// Verify key intro content rendered — title and structured sections
await expect(verseContainer.textContent).toContain('Introduction');
await expect(verseContainer.textContent).toContain('At a Glance');
await expect(verseContainer.textContent).toContain('Traditionally Joshua');
await expect(verseContainer.textContent).toContain('Purpose');
await expect(verseContainer.textContent).toContain('Major Themes');

// Verify structured list items rendered (ili class content)
await expect(verseContainer.textContent).toContain('Entering the Land');
await expect(verseContainer.textContent).toContain('Conquering the Land');
await expect(verseContainer.textContent).toContain('Dividing the Land');

// Verify paragraph content rendered (ip class)
await expect(verseContainer.textContent).toContain(
'wilderness wanderers to courageous conquerors',
);

// Verify special formatting rendered (bdit = bold-italic, nd = divine name)
await expect(verseContainer.textContent).toContain('The Land of Promise');
await expect(verseContainer.textContent).toContain('Covenant and Obedience');
await expect(verseContainer.textContent).toContain('The Typology of Jesus');
await expect(verseContainer.textContent).toContain('Yahweh');

// Toolbar trigger should show "Intro" (title-case label), NOT "INTRO" (raw chapter ID)
await waitFor(
async () => {
const chapterButton = screen.getByRole('button', {
name: /change bible book and chapter/i,
});
await expect(chapterButton.textContent).toContain('Intro');
await expect(chapterButton.textContent).not.toContain('INTRO');
},
{ timeout: 5000 },
);
},
};
78 changes: 52 additions & 26 deletions packages/ui/src/components/bible-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,32 +194,51 @@ function Content() {
}, [books?.data, book]);

const usfmReference = `${book}.${chapter}`;
const chapterIsNumerical = /^\d+$/.test(chapter || '');

// Check if the current chapter is available in this version
const chapterUnavailable = useMemo(() => {
if (!bookData || !chapter) return false;
const inChapters = bookData.chapters?.some((ch) => ch.passage_id.split('.').pop() === chapter);
const isIntro = bookData.intro?.id === chapter;
return !inChapters && !isIntro;
}, [bookData, chapter]);

return (
<main className="yv:*:max-w-lg yv:flex yv:flex-col yv:items-center yv:gap-6 yv:overflow-y-auto yv:px-6 yv:max-sm:px-4 yv:py-12 yv:h-full">
<h1 className="yv:flex yv:gap-2 yv:flex-col yv:justify-center yv:items-center yv:font-serif yv:text-muted-foreground yv:font-medium">
<span
className={cn(
'yv:leading-none yv:block yv:text-2xl yv:transition-[filter]',
!bookData?.title && 'yv:blur-sm',
)}
>
{bookData?.title || 'Loading...'}
</span>
<span className="yv:leading-none yv:block yv:text-[2.5rem] yv:font-normal">
{chapter || '-'}
</span>
</h1>

<BibleTextView
reference={usfmReference}
versionId={versionId}
fontFamily={currentFontFamily}
fontSize={currentFontSize}
lineHeight={lineHeight}
showVerseNumbers={showVerseNumbers}
theme={background}
/>
{chapterIsNumerical ? (
<h1 className="yv:flex yv:gap-2 yv:flex-col yv:justify-center yv:items-center yv:text-muted-foreground yv:font-medium">
<span
className={cn(
'yv:font-serif yv:leading-none yv:block yv:text-2xl yv:transition-[filter]',
!bookData?.title && 'yv:blur-sm',
)}
>
{bookData?.title || 'Loading...'}
</span>
<span className="yv:font-serif yv:leading-none yv:block yv:text-[2.5rem] yv:font-normal">
{chapter || '-'}
</span>
</h1>
) : null}

{chapterUnavailable ? (
// This copy was taken from bible.com (e.g. https://www.bible.com/bible/4253/ACT.INTRO1.AFV)
<p className="yv:text-center yv:text-balance yv:text-muted-foreground">
This chapter is not available in this version. Please choose a different chapter or
version.
</p>
) : (
<BibleTextView
reference={usfmReference}
versionId={versionId}
fontFamily={currentFontFamily}
fontSize={currentFontSize}
lineHeight={lineHeight}
showVerseNumbers={showVerseNumbers}
theme={background}
/>
)}

{version?.copyright && (
<footer
Expand Down Expand Up @@ -309,7 +328,12 @@ function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) {
border === 'bottom' && 'yv:border-b',
)}
>
<div className="yv:grid yv:w-full yv:grid-cols-[auto_1fr_auto] yv:items-center yv:max-w-lg yv:gap-4">
<div
className={cn(
'yv:grid yv:w-full yv:items-center yv:max-w-lg yv:gap-4',
yvContext?.authEnabled ? 'yv:grid-cols-[auto_1fr_auto]' : 'yv:grid-cols-[1fr_auto]',
)}
>
{!!yvContext?.authEnabled && <UserMenu />}

<div className="yv:grid yv:grid-cols-2 yv:gap-0.5">
Expand All @@ -322,13 +346,15 @@ function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) {
background={background}
>
<BibleChapterPicker.Trigger aria-label="Change Bible book and chapter">
{({ chapter, currentBook, loading }) => (
{({ chapterLabel, currentBook, loading }) => (
<Button
variant="secondary"
className="yv:rounded-r-none yv:font-bold yv:text-foreground"
disabled={loading}
>
{loading ? 'Loading...' : `${currentBook?.title || 'Select'} ${chapter || ''}`}
{loading
? 'Loading...'
: `${currentBook?.title || 'Select'} ${chapterLabel || ''}`}
</Button>
)}
</BibleChapterPicker.Trigger>
Expand Down
Loading