Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more

Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) {
return (
<Sheet open={open} onClose={onClose} title={`Bookmarks${entries.length ? ` (${entries.length})` : ''}`}>
{entries.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : entries.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</Sheet>
);
}
+94
View File
@@ -0,0 +1,94 @@
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
export default function MarksSheet({
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
}) {
const [tab, setTab] = useState('highlights');
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
const [editing, setEditing] = useState(false);
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
// Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length;
return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
</div>
{tab === 'highlights' && (
<>
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
{HIGHLIGHT_COLORS.map((c) => (
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
title={colorTitle(c, colorLabels)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
<span className="text-[var(--r-sub)]">{counts[c]}</span>
</button>
))}
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
</div>
{editing && (
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
{HIGHLIGHT_COLORS.map((c) => (
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span className="sr-only">{COLOR_NAMES[c]} means</span>
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
</label>
))}
</div>
)}
{highlights.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
) : shown.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
) : shown.map((h) => (
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
</button>
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</>
)}
{tab === 'bookmarks' && (
bookmarks.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : bookmarks.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))
)}
</Sheet>
);
}
+7 -3
View File
@@ -2,12 +2,13 @@
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { SaveBookOffline } from '../OfflineSave.jsx';
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo }) {
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
const close = () => { setPickedBook(null); onClose(); };
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
@@ -20,7 +21,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
);
return (
<Sheet open={open} onClose={close} title={book ? (
<Sheet open={open} onClose={close} label="Go to" title={book ? (
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
) : 'Go to'}>
{!book && (
@@ -41,6 +42,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</>
)}
{book && (
<>
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
@@ -49,6 +51,8 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</button>
))}
</div>
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
</>
)}
</Sheet>
);
+82 -10
View File
@@ -2,8 +2,18 @@
// serif face. Tap a verse to open the verse sheet; select text to highlight.
import { formatCrossRef } from '../../utils/wordStudy.js';
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
const FONT_STYLES = {
serif: { fontFamily: undefined },
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
};
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, notice, verses, loading, error, layout, fontSize, wide,
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer,
}) {
@@ -20,7 +30,12 @@ export default function ReaderText({
id: `reader-verse-${v.number}`,
'data-verse-num': v.number,
onClick: () => handleTap(v.number),
className: 'cursor-pointer rounded-sm transition-colors',
// Verses can be reached and chosen from the keyboard too
role: 'button',
tabIndex: 0,
'aria-pressed': selected,
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
style: selected
? { backgroundColor: 'var(--r-hl)' }
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
@@ -35,25 +50,71 @@ export default function ReaderText({
</span>
);
const num = (n) => <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup>;
const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
const parallelRows = parallel && !parallel.error ? (() => {
const second = new Map(parallel.verses.map((v) => [v.number, v]));
const first = new Map(verses.map((v) => [v.number, v]));
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
})() : null;
const sideBySide = !!parallel;
const bodyStyle = {
...FONT_STYLES[font],
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
};
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
// screen. Two columns / side-by-side take more room.
const pageStyle = {
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
};
return (
<article
className={`mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] ${wide ? 'max-w-5xl' : 'max-w-2xl'}`}
className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
style={pageStyle}
onPointerUp={onSelectionEnd}
>
<header className="mb-5 text-center">
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}</p>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
</header>
{loading && <p className="text-center font-sans text-sm text-[var(--r-sub)]">Loading…</p>}
{error && <p className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{loading && <ChapterSkeleton />}
{error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{!loading && !error && parallel?.error && (
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
)}
{!loading && !error && (
{!loading && !error && sideBySide && !parallel.error && (
<div style={bodyStyle} className="font-serif">
<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>
{parallelRows.map((row) => (
<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 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> : '—'}
</p>
</div>
))}
</div>
)}
{!loading && !error && !sideBySide && (
<div
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
style={{ fontSize: `${1.125 * fontSize}rem`, lineHeight: 1.85 }}
style={bodyStyle}
>
{layout === 'flow' ? (
<p className="text-pretty">
@@ -73,9 +134,20 @@ export default function ReaderText({
</div>
)}
{!loading && !error && footer}
{!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
</article>
);
}
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
function ChapterSkeleton() {
return (
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
<span className="sr-only">Loading…</span>
</div>
);
}
+41 -11
View File
@@ -1,42 +1,72 @@
// "Aa" sheet: text size, theme, and how verses are laid out.
// "Aa" sheet: text size, typeface, spacing, theme, how verses are laid out, and a second Bible
// to read beside the first.
import Sheet from './Sheet.jsx';
import { READER_THEMES } from './readerThemes.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
function Segmented({ options, value, onChange }) {
function Segmented({ options, value, onChange, label }) {
return (
<div className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, label]) => (
<button key={id} type="button" onClick={() => onChange(id)}
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, text]) => (
<button key={String(id)} type="button" onClick={() => onChange(id)} aria-pressed={value === id}
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
{label}
{text}
</button>
))}
</div>
);
}
export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide }) {
export default function SettingsSheet({
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
translation, parallel, onParallel,
}) {
const row = 'mb-4';
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
return (
<Sheet open={open} onClose={onClose} title="Reading settings">
<div className={row}>
<span className={label}>Text size</span>
<Segmented options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
<Segmented label="Text size" options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
</div>
<div className={row}>
<span className={label}>Typeface</span>
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={font} onChange={onFont} />
</div>
<div className={row}>
<span className={label}>Line spacing</span>
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={spacing} onChange={onSpacing} />
</div>
<div className={row}>
<span className={label}>Theme</span>
<Segmented options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
</div>
<div className={row}>
<span className={label}>Verses</span>
<Segmented options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
</div>
<div className={row}>
<span className={label}>Verse numbers</span>
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={verseNumbers} onChange={onVerseNumbers} />
</div>
<div className={row}>
<span className={label}>Width</span>
<Segmented options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
</div>
<div className={row}>
<span className={label}>Read beside another Bible</span>
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.id || 'Off'}
</button>
))}
</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>
</Sheet>
);
+37 -3
View File
@@ -2,17 +2,51 @@
// close on outside tap; non-modal ones (the verse sheet) leave the text
// tappable. With `pane`, screens >= lg show it as a right-hand side pane
// instead of a bottom sheet (tablet landscape / desktop).
import { useEffect, useRef } from 'react';
import { CloseIcon } from './icons.jsx';
export default function Sheet({ open, onClose, title, headerRight, modal = true, pane = false, children }) {
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
const dialogRef = useRef(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
useEffect(() => {
if (!open || !modal) return undefined;
const opener = document.activeElement;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
if (e.key !== 'Tab' || !dialog) return;
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) { e.preventDefault(); return; }
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
};
}, [open, modal]);
if (!open) return null;
return (
<>
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
<div
ref={dialogRef}
role="dialog"
aria-label={typeof title === 'string' ? title : undefined}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
aria-modal={modal ? 'true' : undefined}
aria-label={typeof title === 'string' ? title : label}
tabIndex={-1}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`}
>
+41 -3
View File
@@ -5,8 +5,9 @@ import Sheet from './Sheet.jsx';
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';
const TABS = [['commentary', 'Commentary'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
function OriginalTab({ words }) {
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
@@ -49,6 +50,42 @@ function RefsTab({ refs, loading, onJump }) {
);
}
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
function CompareTab({ book, chapter, verse, current }) {
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
useEffect(() => {
const controller = new AbortController();
setRows({});
READER_TRANSLATIONS.forEach((t) => {
fetchChapter(t.id, book, chapter, { signal: controller.signal })
.then(({ verses }) => {
const text = verses.find((v) => v.number === verse)?.text;
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
})
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
});
return () => controller.abort();
}, [book, chapter, verse]);
return (
<div className="space-y-3">
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
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>
{!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>}
</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>
</div>
);
}
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
@@ -86,15 +123,16 @@ export default function VerseSheet({
</>
)}
>
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
{TABS.map(([id, label]) => (
<button key={id} type="button" onClick={() => setTab(id)}
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{label}
</button>
))}
</div>
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
{tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
+3 -3
View File
@@ -3,15 +3,15 @@
export const READER_THEMES = {
paper: {
label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
},
sepia: {
label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
},
dark: {
label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
},
};
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { READER_THEMES } from './readerThemes.js';
// WCAG relative luminance and contrast ratio
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// Small text needs 4.5:1. These are the pairings the app actually draws.
describe.each(Object.entries(READER_THEMES))('%s theme contrast', (_id, { vars }) => {
const v = (name) => vars[`--r-${name}`];
test.each([
['text', 'bg'], ['text', 'bar'], ['text', 'hl'],
['sub', 'bg'], ['sub', 'bar'], ['sub', 'hl'],
['accent', 'bg'], ['accent', 'bar'],
['danger', 'bg'], ['danger', 'bar'],
['on', 'accent'],
])('%s on %s is at least 4.5:1', (fg, bg) => {
expect(contrast(v(fg), v(bg))).toBeGreaterThanOrEqual(4.5);
});
});