Reader: show how much another translation differs from the one being read

- Word-level comparison (ignores case and punctuation): differing words are
  marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
  with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
  being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
  always win where they overlap a difference mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:36:46 -04:00
1 parent 7101be393e
commit aa27a5bb18
10 files changed
+296 -9

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
// Text with the words that differ from the other translation marked.
export function DiffText({ text, ranges = [] }) {
if (!ranges.length) return text;
const parts = [];
let cursor = 0;
ranges.forEach((r, i) => {
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
cursor = r.end;
});
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}</>;
}
+33 -3
View File
@@ -1,6 +1,9 @@
// The page itself: chapter heading, translation notice, and verses set in a
// serif face. Tap a verse to open the verse sheet; select text to highlight.
import { useMemo } from 'react';
import { formatCrossRef } from '../../utils/wordStudy.js';
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
const FONT_STYLES = {
@@ -13,10 +16,18 @@ const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
export default function ReaderText({
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer,
}) {
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
const diff = useMemo(
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
[parallel, showDiff, verses],
);
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
const handleTap = (n) => {
const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
@@ -92,6 +103,23 @@ export default function ReaderText({
{!loading && !error && sideBySide && !parallel.error && (
<div style={bodyStyle} className="font-serif">
{diff && (
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
<p>
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
</p>
{diff.mostDifferent.length > 0 && (
<p className="mt-1 text-xs text-[var(--r-sub)]">
Most different:{' '}
{diff.mostDifferent.map((v, i) => (
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
))}
</p>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
</div>
)}
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
<span>{translationId}</span><span>{parallel.translation}</span>
</div>
@@ -99,12 +127,14 @@ export default function ReaderText({
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
<p>
{row.first
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
</p>
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
{row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
</p>
</div>
))}
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect } from 'vitest';
import ReaderText from './ReaderText.jsx';
const verses = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was formless and void.' },
];
const second = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was without shape and empty.' },
];
const base = {
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
};
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
test('side by side, it says how different the other Bible is and marks the differing words', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
expect(marked).toContain('without shape'); // the other Bible's side
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
});
test('identical wording is reported as the same words', () => {
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
});
test('with highlighting off there is no summary and no marks', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
});
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
});
test('the left Bible gets its own differing words passed to the verse renderer', () => {
const renderVerseText = vi.fn((_n, text) => text);
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
});
+7 -1
View File
@@ -22,7 +22,7 @@ function Segmented({ options, value, onChange, label }) {
export default function SettingsSheet({
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
translation, parallel, onParallel,
translation, parallel, onParallel, showDiff, onShowDiff,
}) {
const row = 'mb-4';
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
@@ -68,6 +68,12 @@ export default function SettingsSheet({
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
</div>
{parallel && (
<div className={row}>
<span className={label}>Highlight differences from {translation}</span>
<Segmented label="Highlight differences" options={[[true, 'Show'], [false, 'Hide']]} value={showDiff} onChange={onShowDiff} />
</div>
)}
</Sheet>
);
}
+14 -3
View File
@@ -6,6 +6,8 @@ import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
@@ -68,20 +70,29 @@ function CompareTab({ book, chapter, verse, current }) {
return () => controller.abort();
}, [book, chapter, verse]);
const mine = rows[current]?.text;
return (
<div className="space-y-3">
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
// How this wording differs from the Bible being read, word by word
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
return (
<div key={t.id}>
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
</p>
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>}
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
</div>
);
})}
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
Copyright notices for each translation appear at the foot of the chapter when you read it.
</p>
</div>
);
}