Verse of the day with picture backgrounds, shareable image, and study covers
Build and Push Docker Image / build-and-push (push) Failing after 21s

- Verse of the day: card over a picture (a different one each day, or chosen),
  12 drawn scenes plus photos from public/verse-backgrounds, and "Share as
  image" (also on the Reader's verse panel) that saves or shares a PNG with the
  same background, a theme look, or a photo from the device
- Study cards get a cover banner named for the book, coloured by kind of book
- Outline "Build from my notes": abbreviations like "A.D. 63." no longer end a
  sentence

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 09:21:44 -04:00
1 parent d2bbf1feb4
commit aabd4267b8
15 files changed
+825 -7

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
Photos for behind the verse of the day and the "Share as image" pictures.
1. Drop landscape photos (about 1200px wide or larger, .jpg/.png/.webp) into this folder.
2. List them in index.json, for example:
[
{ "file": "mountain-lake.jpg", "name": "Mountain lake" },
{ "file": "wheat.jpg", "name": "Wheat" }
]
(A plain list of file names also works: ["mountain-lake.jpg", "wheat.jpg"].)
They then appear under "Your photos" in the background menus, and "Picture of the day" rotates through them
along with the built-in scenes. With no index.json the built-in scenes are used. Free sources: pixabay.com,
unsplash.com. Check each photo's licence before sharing pictures made from it.
+1
View File
@@ -0,0 +1 @@
[]
+27
View File
@@ -0,0 +1,27 @@
// The picture on a study's card: a coloured cover for the kind of book, with the book's name.
import { coverFor } from '../utils/bookCovers.js';
export default function BookCover({ summary, className = '' }) {
const { group, name, extra } = coverFor(summary);
const [from, to] = group.colors;
const id = `cover-${group.id}`;
return (
<div className={`relative overflow-hidden rounded-xl ${className}`} role="img" aria-label={`${name}${extra ? ` and ${extra} more` : ''}, ${group.label}`}>
<svg viewBox="0 0 400 120" preserveAspectRatio="xMidYMid slice" className="block h-full w-full" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor={from} /><stop offset="1" stopColor={to} /></linearGradient>
</defs>
<rect width="400" height="120" fill={`url(#${id})`} />
<g fill="none" stroke="#fff" strokeOpacity="0.1" strokeWidth="1.5">
<circle cx="360" cy="20" r="70" /><circle cx="360" cy="20" r="50" /><circle cx="360" cy="20" r="30" />
<circle cx="30" cy="120" r="60" /><circle cx="30" cy="120" r="40" />
</g>
<rect x="20" y="22" width="28" height="3" rx="1.5" fill="#fff" fillOpacity="0.7" />
<text x="20" y="62" fill="#fff" fontFamily="Georgia, 'Times New Roman', serif" fontSize={name.length > 13 ? 26 : 32} fontWeight="600">{name}</text>
<text x="20" y="90" fill="#fff" fillOpacity="0.8" fontFamily="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" fontSize="13" letterSpacing="1.5">
{group.label.toUpperCase()}{extra ? ` · +${extra} MORE` : ''}
</text>
</svg>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
// "Share as image": previews a verse card, lets you pick its look (plain theme, a scene, one of your
// photos, or a photo from your device), then shares or saves the picture.
import { useEffect, useRef, useState } from 'react';
import { READER_THEMES } from './reader/readerThemes.js';
import { renderVerseCard, shareOrSaveImage, verseCardFileName } from '../utils/verseCard.js';
import { SCENES, loadImage, loadPhotoList, resolveBackground } from '../utils/verseBackgrounds.js';
import { useToast } from './Toasts.jsx';
const STYLE_KEY = 'verse-card-style';
export const BACKGROUND_KEY = 'votd-background';
export default function VerseShareImage({ text, reference, translation, notice = '', defaultStyle = 'paper', defaultBackground = 'plain', onClose }) {
const { toast } = useToast();
const [style, setStyle] = useState(() => { try { return localStorage.getItem(STYLE_KEY) || defaultStyle; } catch { return defaultStyle; } });
const [choice, setChoice] = useState(defaultBackground); // 'plain' | 'auto' | scene id | photo id | 'mine'
const [photos, setPhotos] = useState([]);
const [mine, setMine] = useState(null); // a photo picked from this device: { image, name }
const [image, setImage] = useState({ blob: null, url: '', error: '' });
const fileRef = useRef(null);
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
useEffect(() => {
let live = true;
let url = '';
const background = choice === 'mine' && mine ? { kind: 'image', image: mine.image } : resolveBackground(choice === 'mine' ? 'plain' : choice, photos);
renderVerseCard({ text, reference, translation, notice, themeId: style, background })
.then((blob) => { if (!live) return; url = URL.createObjectURL(blob); setImage({ blob, url, error: '' }); })
.catch((err) => { if (live) setImage({ blob: null, url: '', error: err.message }); });
return () => { live = false; if (url) URL.revokeObjectURL(url); };
}, [text, reference, translation, notice, style, choice, photos, mine]);
const chooseStyle = (id) => { setStyle(id); setChoice('plain'); try { localStorage.setItem(STYLE_KEY, id); } catch { /* storage unavailable */ } };
const pickPhoto = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
const url = URL.createObjectURL(file);
try { setMine({ image: await loadImage(url), name: file.name }); setChoice('mine'); } catch (err) { toast(err.message, { tone: 'error' }); } finally { URL.revokeObjectURL(url); }
};
const go = async () => {
const result = await shareOrSaveImage(image.blob, verseCardFileName(reference), { title: reference, text: `${reference} (${translation})` });
if (result === 'saved') toast('Image saved');
};
return (
<div className="mt-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label="Verse image">
<div className="mb-2 flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]">Background
<select value={choice} onChange={(e) => (e.target.value === 'upload' ? fileRef.current?.click() : setChoice(e.target.value))} aria-label="Image background"
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2 py-1 text-xs text-[var(--r-text)]">
<option value="plain">Plain</option>
<option value="auto">Picture of the day</option>
<optgroup label="Scenes">{SCENES.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
{mine && <option value="mine">{mine.name}</option>}
<option value="upload">Use a photo from this device…</option>
</select>
</label>
{onClose && <button type="button" onClick={onClose} aria-label="Close image" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>}
</div>
<input ref={fileRef} type="file" accept="image/*" onChange={pickPhoto} className="hidden" aria-label="Choose a background photo" />
{choice === 'plain' && (
<div role="group" aria-label="Image style" className="mb-2 flex w-fit overflow-hidden rounded-full border border-[var(--r-line)] text-xs">
{Object.entries(READER_THEMES).map(([id, t]) => (
<button key={id} type="button" aria-pressed={style === id} onClick={() => chooseStyle(id)}
className={`px-3 py-1 ${style === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
))}
</div>
)}
{image.error ? <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{image.error}</p>
: image.url ? <img src={image.url} alt={`${reference} (${translation}) as a picture`} className="mx-auto w-full max-w-xs rounded-lg shadow-sm" />
: <p className="text-sm text-[var(--r-sub)]">Making the image…</p>}
<div className="mt-3 flex gap-2">
<button type="button" onClick={go} disabled={!image.blob} className="flex-1 rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">
{typeof navigator !== 'undefined' && navigator.canShare ? 'Share image' : 'Save image'}
</button>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../utils/verseCard.js', async (orig) => ({
...(await orig()),
renderVerseCard: vi.fn(async () => new Blob(['png'], { type: 'image/png' })),
shareOrSaveImage: vi.fn(async () => 'saved'),
}));
vi.mock('./Toasts.jsx', () => ({ useToast: () => ({ toast: vi.fn() }) }));
import VerseShareImage from './VerseShareImage.jsx';
import { renderVerseCard, shareOrSaveImage } from '../utils/verseCard.js';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
URL.createObjectURL = vi.fn(() => 'blob:card');
URL.revokeObjectURL = vi.fn();
});
const props = { text: 'For God so loved the world.', reference: 'John 3:16', translation: 'BSB' };
test('previews the card, redraws when the look changes, and remembers the look', async () => {
const user = userEvent.setup();
render(<VerseShareImage {...props} />);
expect(await screen.findByAltText(/John 3:16 \(BSB\) as a picture/)).toBeInTheDocument();
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'paper', reference: 'John 3:16' }));
await user.click(screen.getByRole('button', { name: 'Dark' }));
await screen.findByAltText(/as a picture/);
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'dark' }));
expect(localStorage.getItem('verse-card-style')).toBe('dark');
});
test('Save/Share sends the image under a file name from the reference', async () => {
const user = userEvent.setup();
render(<VerseShareImage {...props} />);
await screen.findByAltText(/as a picture/);
await user.click(screen.getByRole('button', { name: /save image|share image/i }));
expect(shareOrSaveImage).toHaveBeenCalledWith(expect.any(Blob), 'john-3-16.png', expect.objectContaining({ title: 'John 3:16' }));
});
test('says so if the picture can\'t be drawn', async () => {
renderVerseCard.mockRejectedValueOnce(new Error('This browser can’t draw the image.'));
render(<VerseShareImage {...props} />);
expect(await screen.findByRole('alert')).toHaveTextContent(/can’t draw/);
});
test('a background picture can be chosen, and is passed to the card; the plain looks come back with Plain', async () => {
const user = userEvent.setup();
render(<VerseShareImage {...props} />);
await screen.findByAltText(/as a picture/);
expect(screen.getByRole('group', { name: /image style/i })).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText('Image background'), 'sea');
await screen.findByAltText(/as a picture/);
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: { kind: 'scene', id: 'sea' } }));
expect(screen.queryByRole('group', { name: /image style/i })).toBeNull(); // theme looks only apply to the plain card
await user.selectOptions(screen.getByLabelText('Image background'), 'plain');
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: null }));
});
test('the picture of the day is used when the Home card passes it in', async () => {
render(<VerseShareImage {...props} defaultBackground="auto" />);
await screen.findByAltText(/as a picture/);
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: expect.objectContaining({ kind: 'scene' }) }));
});
+43 -4
View File
@@ -2,6 +2,8 @@
import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import VerseShareImage, { BACKGROUND_KEY } from '../VerseShareImage.jsx';
import { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
@@ -12,7 +14,7 @@ export default function IqDailyCard() {
}
function Card() {
const { jumpToReaderVerse, openBibleReader, readerTranslation } = useApp();
const { jumpToReaderVerse, openBibleReader, readerTranslation, readerTheme } = useApp();
// Today's verse is chosen by IQ Bible (the same for everyone); its words come from the Bible you pick.
const votd = useIq('bibles/eng_kjv/verse-of-the-day');
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem('votd-translation') || ''; } catch { return ''; } });
@@ -20,6 +22,14 @@ function Card() {
const pick = (id) => { setChosen(id); try { localStorage.setItem('votd-translation', id); } catch { /* storage unavailable */ } };
const ref = votd.data?.data;
const [text, setText] = useState({ key: '', value: '', error: false });
const [sharing, setSharing] = useState(false);
// The picture behind the verse: a different one each day by default, or the one you chose.
const [bgChoice, setBgChoice] = useState(() => { try { return localStorage.getItem(BACKGROUND_KEY) || 'auto'; } catch { return 'auto'; } });
const [photos, setPhotos] = useState([]);
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
const background = resolveBackground(bgChoice, photos);
const backgroundUrl = background?.kind === 'photo' ? background.url : background ? sceneDataUrl(background.id) : '';
const pickBackground = (id) => { setBgChoice(id); try { localStorage.setItem(BACKGROUND_KEY, id); } catch { /* storage unavailable */ } };
const key = ref ? `${translation}/${ref.book_usfm}/${ref.chapter_number}/${ref.verse_number}` : '';
useEffect(() => {
if (!ref) return undefined;
@@ -36,6 +46,9 @@ function Card() {
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
const verse = votd.data?.data;
const reading = dev.data?.data?.[part];
const loaded = text.key === key;
const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…';
const shownTranslation = loaded && text.error ? 'KJV' : translation;
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
if (!verse && !reading) return null;
return (
@@ -44,16 +57,42 @@ function Card() {
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
<span className="flex items-center gap-1.5">
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)}
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
<option value="auto">Picture of the day</option>
<option value="plain">Plain</option>
<optgroup label="Scenes">{SCENES.map((sc) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
</select>
<select aria-label="Verse of the day translation" value={translation} onChange={(e) => pick(e.target.value)}
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
</select>
</span>
</div>
{/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */}
<p className="mt-1 font-serif text-[15px] leading-relaxed">{text.key === key && text.value ? text.value : text.key === key && text.error ? verse.text : '…'}</p>
{text.key === key && text.error && translation !== 'KJV' && <p className="text-[11px] text-[var(--r-sub)]">Couldn’t load {translation} just now, so this is the KJV.</p>}
{backgroundUrl ? (
<figure className="relative mt-2 overflow-hidden rounded-xl bg-cover bg-center" style={{ backgroundImage: `url(${backgroundUrl})` }}>
<div className="bg-gradient-to-b from-black/35 via-black/45 to-black/60 px-6 py-8 text-center text-white" style={{ textShadow: '0 1px 8px rgba(0,0,0,0.55)' }}>
<blockquote className="font-serif text-[18px] leading-relaxed sm:text-[20px]">{shown}</blockquote>
<figcaption className="mt-3 text-sm font-semibold tracking-wide text-[#ffe0b0]">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} <span className="font-normal text-white/80">· {shownTranslation}</span></figcaption>
</div>
</figure>
) : (
<blockquote className="mt-2 rounded-xl border border-[var(--r-line)] border-l-4 border-l-[var(--r-accent)] bg-[var(--r-bg)] px-4 py-3 font-serif text-[16px] leading-relaxed">{shown}</blockquote>
)}
{loaded && text.error && translation !== 'KJV' && <p className="mt-1 text-[11px] text-[var(--r-sub)]">Couldn’t load {translation} just now, so this is the KJV.</p>}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
<button type="button" onClick={() => { jumpToReaderVerse(verse.book_usfm, verse.chapter_number, verse.verse_number); openBibleReader(); }}
className="mt-1 text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
className="text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
<button type="button" onClick={() => setSharing((o) => !o)} aria-expanded={sharing} disabled={!loaded || !shown || shown === '…'}
className="text-xs font-medium text-[var(--r-accent)] hover:underline disabled:opacity-50">Share as image</button>
</div>
{sharing && loaded && shown && (
<VerseShareImage text={shown} reference={`${name(verse.book_usfm)} ${verse.chapter_number}:${verse.verse_number}`} translation={shownTranslation}
notice={loaded && !text.error ? READER_TRANSLATIONS.find((t) => t.id === translation)?.notice ?? '' : ''} defaultStyle={readerTheme || 'paper'} defaultBackground={bgChoice} onClose={() => setSharing(false)} />
)}
{text.key === key && text.value && READER_TRANSLATIONS.find((t) => t.id === translation)?.notice && (
<p className="mt-2 text-[10px] leading-snug text-[var(--r-sub)]">{READER_TRANSLATIONS.find((t) => t.id === translation).notice}</p>
)}
+12 -1
View File
@@ -10,6 +10,7 @@ import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import VerseShareImage from '../VerseShareImage.jsx';
import IqLexicon from '../iq/IqLexicon.jsx';
import IqStudyAids from '../iq/IqStudyAids.jsx';
@@ -160,6 +161,7 @@ export default function VerseSheet({
const TABS = iqOn ? [...BASE_TABS, ['aids', 'Study aids']] : BASE_TABS;
const [tab, setTab] = useState('commentary');
const [copied, setCopied] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
const appliedRequest = useRef(0);
// Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the
@@ -177,7 +179,7 @@ export default function VerseSheet({
}
}, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setCopied(false); }, [verse?.number]);
useEffect(() => { setCopied(false); setImageOpen(false); }, [verse?.number]);
if (!verse) return null;
const copy = () => {
@@ -201,12 +203,21 @@ export default function VerseSheet({
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
</button>
)}
<button type="button" onClick={() => setImageOpen((o) => !o)} aria-pressed={imageOpen} className={`${actionBtn} text-xs`} aria-label="Share verse as image" title="Share as image">
Image
</button>
<button type="button" onClick={copy} className={actionBtn} aria-label="Copy verse">
{copied ? <span className="px-1 text-xs">Copied</span> : <CopyIcon />}
</button>
</>
)}
>
{imageOpen && (
<div className="mb-3">
<VerseShareImage text={verse.text} reference={`${bookName} ${chapter}:${verse.number}`} translation={translation}
notice={READER_TRANSLATIONS.find((t) => t.id === translation)?.notice ?? ''} onClose={() => setImageOpen(false)} />
</div>
)}
{onHighlightVerse && (
// One-tap highlighting of the whole verse (to mark just some words, select them in the text)
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
+2
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import BookCover from '../components/BookCover.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import PageShell from '../components/study/PageShell.jsx';
@@ -201,6 +202,7 @@ export default function HomePage() {
<div className="grid gap-3 sm:grid-cols-2">
{visible.map((entry) => (
<div key={entry.id} className="flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<BookCover summary={entry.chapterSummary} className="h-24" />
<div className="flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
+40
View File
@@ -0,0 +1,40 @@
// A cover for each study, named for the book it covers. The picture is drawn (a colour for the kind of book,
// a soft pattern, the book's name), so it works offline and needs no image files.
import { bookOptions } from './bibleUtils.jsx';
const GROUPS = [
{ id: 'law', label: 'Law', colors: ['#0f766e', '#134e4a'], books: ['GEN', 'EXO', 'LEV', 'NUM', 'DEU'] },
{ id: 'history', label: 'History', colors: ['#3b5b8c', '#1e3a5f'], books: ['JOS', 'JDG', 'RUT', '1SA', '2SA', '1KI', '2KI', '1CH', '2CH', 'EZR', 'NEH', 'EST'] },
{ id: 'wisdom', label: 'Wisdom', colors: ['#7e3f8f', '#4a1d5c'], books: ['JOB', 'PSA', 'PRO', 'ECC', 'SNG'] },
{ id: 'major', label: 'Major prophets', colors: ['#9f2f3f', '#5e1420'], books: ['ISA', 'JER', 'LAM', 'EZK', 'DAN'] },
{ id: 'minor', label: 'Minor prophets', colors: ['#b4531f', '#6e2c0c'], books: ['HOS', 'JOL', 'AMO', 'OBA', 'JON', 'MIC', 'NAM', 'HAB', 'ZEP', 'HAG', 'ZEC', 'MAL'] },
{ id: 'gospel', label: 'Gospel', colors: ['#b7791f', '#6b4410'], books: ['MAT', 'MRK', 'LUK', 'JHN'] },
{ id: 'acts', label: 'Acts', colors: ['#2f7d4f', '#14442a'], books: ['ACT'] },
{ id: 'paul', label: 'Letter of Paul', colors: ['#2b4a8f', '#142550'], books: ['ROM', '1CO', '2CO', 'GAL', 'EPH', 'PHP', 'COL', '1TH', '2TH', '1TI', '2TI', 'TIT', 'PHM'] },
{ id: 'general', label: 'General letter', colors: ['#4f46a5', '#27235e'], books: ['HEB', 'JAS', '1PE', '2PE', '1JN', '2JN', '3JN', 'JUD'] },
{ id: 'revelation', label: 'Revelation', colors: ['#a31d3d', '#4b0a1c'], books: ['REV'] },
];
const FALLBACK = { id: 'other', label: 'Study', colors: ['#57534e', '#292524'], books: [] };
export const groupOfBook = (abbrev) => GROUPS.find((g) => g.books.includes(abbrev)) ?? FALLBACK;
// The book a study opens with, found from its passage summary ("Titus 1, Titus 2" or "1 John 2"), or null.
export function bookFromSummary(summary) {
const first = String(summary ?? '').split(',')[0].trim();
if (!first) return null;
const named = [...bookOptions].sort((a, b) => b.name.length - a.name.length);
return named.find((b) => first === b.name || first.startsWith(`${b.name} `)) ?? null;
}
// Every distinct book in a summary, in order ("John 3, Romans 8" -> [John, Romans]).
export function booksFromSummary(summary) {
const seen = new Set();
return String(summary ?? '').split(',').map((part) => bookFromSummary(part)).filter((b) => b && !seen.has(b.abbrev) && seen.add(b.abbrev));
}
export function coverFor(summary) {
const books = booksFromSummary(summary);
const book = books[0] ?? null;
const group = groupOfBook(book?.abbrev);
return { book, group, name: book?.name ?? 'Bible study', extra: Math.max(0, books.length - 1) };
}
+31
View File
@@ -0,0 +1,31 @@
import { test, expect } from 'vitest';
import { bookFromSummary, booksFromSummary, coverFor, groupOfBook } from './bookCovers.js';
import { wrapLines, fitText } from './verseCard.js';
test('the book comes from the study\'s passage summary, including numbered books', () => {
expect(bookFromSummary('Titus 1, Titus 2').abbrev).toBe('TIT');
expect(bookFromSummary('1 John 2').abbrev).toBe('1JN');
expect(bookFromSummary('Song of Solomon 2').abbrev).toBe('SNG');
expect(bookFromSummary('Nowhere 3')).toBeNull();
expect(bookFromSummary('')).toBeNull();
expect(booksFromSummary('John 3, John 4, Romans 8').map((b) => b.abbrev)).toEqual(['JHN', 'ROM']);
});
test('every book has a cover colour group, and unknown ones get a plain cover', () => {
expect(groupOfBook('GEN').id).toBe('law');
expect(groupOfBook('PSA').id).toBe('wisdom');
expect(groupOfBook('REV').id).toBe('revelation');
expect(groupOfBook('XXX').id).toBe('other');
expect(coverFor('John 3, Romans 8')).toMatchObject({ name: 'John', extra: 1 });
expect(coverFor('').name).toBe('Bible study');
});
test('verse text wraps to the width and shrinks to fit', () => {
const measure = (s) => s.length * 10;
expect(wrapLines('For God so loved the world', 100, measure)).toEqual(['For God so', 'loved the', 'world']);
expect(wrapLines('Supercalifragilistic word', 50, measure)).toEqual(['Supercalifragilistic', 'word']);
const text = 'word '.repeat(40).trim();
const fit = fitText(text, { maxWidth: 400, maxHeight: 300, maxSize: 60, minSize: 20, measureAt: (size, s) => s.length * size * 0.5 });
expect(fit.size).toBeLessThan(60);
expect(fit.lines.length * fit.size * fit.lineHeight).toBeLessThanOrEqual(300);
});
+15 -1
View File
@@ -214,6 +214,20 @@ const MAX_ITEMS = 15;
const HEADLINE = 110;
const MARKER = /^\s*(?:[-*•–]|\d{1,2}[.)]|[a-z][.)])\s+/i;
// The first sentence of a line: up to a . ! or ? that is followed by a capital letter, skipping abbreviations
// such as "A.D." or "e.g." and numbers like "63.".
const ABBREVIATIONS = new Set(['mr', 'mrs', 'ms', 'dr', 'st', 'vs', 'v', 'vv', 'cf', 'ch', 'etc', 'no', 'e.g', 'i.e', 'a.d', 'b.c', 'c', 'ca']);
export function firstSentence(full) {
const end = /[.!?]["'”’)\]]*\s+(?=["'“‘(]?[A-Z])/g;
let m;
while ((m = end.exec(full))) {
const before = full.slice(0, m.index + 1).split(/\s+/).pop().replace(/[.!?]+$/, '').toLowerCase();
if (m.index < 19 || ABBREVIATIONS.has(before) || /^[a-z]$/.test(before)) continue;
return full.slice(0, m.index + m[0].match(/^[.!?]["'”’)\]]*/)[0].length);
}
return full;
}
// One note box -> [{ text, note }]: a point per line (bullet markers dropped, indented lines join the line above).
export function notesToPoints(raw) {
const items = [];
@@ -223,7 +237,7 @@ export function notesToPoints(raw) {
items.push(line.replace(MARKER, '').trim());
}
return items.filter(Boolean).slice(0, MAX_ITEMS).map((full) => {
const sentence = /^(.{20,}?[.!?])(\s+|$)/.exec(full)?.[1] ?? full;
const sentence = firstSentence(full);
let headline = sentence;
if (headline.length > HEADLINE) headline = `${headline.slice(0, HEADLINE).replace(/\s+\S*$/, '')}…`;
const rest = headline.endsWith('…') ? full : full.slice(sentence.length).trim();
+8 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH, notesToPoints, buildFromNotes, buildChunkFromNotes, notesBuildCount,
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH, notesToPoints, firstSentence, buildFromNotes, buildChunkFromNotes, notesBuildCount,
} from './outline.js';
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children });
@@ -179,6 +179,13 @@ describe('an outline for each chunk', () => {
});
describe('building from the notes', () => {
test('abbreviations and numbers do not end a sentence', () => {
expect(firstSentence('Written by Paul to Titus on Crete, about A.D. 63. Crete was unruly.')).toBe('Written by Paul to Titus on Crete, about A.D. 63. Crete was unruly.'.replace(' Crete was unruly.', ''));
expect(firstSentence('He wrote this letter to Titus, e.g. as a guide. Then he closed it.')).toBe('He wrote this letter to Titus, e.g. as a guide.');
expect(firstSentence('Short. Then more words follow after that, which is fine.')).toBe('Short. Then more words follow after that, which is fine.');
expect(notesToPoints('Written by Paul to Titus on Crete, about A.D. 63.')).toEqual([{ text: 'Written by Paul to Titus on Crete, about A.D. 63', note: '' }]);
});
const chunkWith = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: '', interpretation: '', application: '', generalNotes: '', crossReferences: [], greekWords: [], ...extra });
const study = (chunks) => ({ id: 's', title: 'T', translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks }] });
+317
View File
@@ -0,0 +1,317 @@
// Pictures to put behind a verse: scenes drawn in the app (so they work offline), and any photos dropped into
// public/verse-backgrounds (listed in its index.json). The Home card and the shareable image both use these.
// A small repeatable random-number generator, so a scene looks the same every time it is drawn.
function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const sky = (ctx, w, h, stops, to = 0.75) => {
const g = ctx.createLinearGradient(0, 0, 0, h * to);
stops.forEach(([at, color]) => g.addColorStop(at, color));
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
};
const glow = (ctx, x, y, r, color) => {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, color);
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
};
// A ridge line across the picture, filled below with `color`.
function ridge(ctx, w, h, { base, amp, detail = 6, color, seed }) {
const r = rng(seed);
const phases = Array.from({ length: detail }, () => [r() * Math.PI * 2, 0.5 + r() * 1.8]);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, h);
for (let x = 0; x <= w; x += 6) {
let y = 0;
phases.forEach(([p, f], i) => { y += Math.sin((x / w) * f * Math.PI * 2 + p) / (i + 1); });
ctx.lineTo(x, base + y * amp * 0.55);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
}
// A row of fir trees standing on `base`, as stacked triangles.
function firs(ctx, w, { base, size, color, seed, count }) {
const r = rng(seed);
ctx.fillStyle = color;
for (let i = 0; i < count; i += 1) {
const x = (i / count) * w + (r() - 0.5) * (w / count) * 1.4;
const h = size * (0.6 + r() * 0.7);
const wd = h * 0.38;
for (let tier = 0; tier < 4; tier += 1) {
const top = base - h + tier * h * 0.22;
const bottom = base - h + (tier + 1.4) * h * 0.26;
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x - wd * (0.45 + tier * 0.22), bottom);
ctx.lineTo(x + wd * (0.45 + tier * 0.22), bottom);
ctx.closePath();
ctx.fill();
}
ctx.fillRect(x - 1.5, base - h * 0.12, 3, h * 0.14);
}
}
const fog = (ctx, w, y, height, alpha = 0.5, color = '255,255,255') => {
const g = ctx.createLinearGradient(0, y, 0, y + height);
g.addColorStop(0, `rgba(${color},0)`);
g.addColorStop(0.5, `rgba(${color},${alpha})`);
g.addColorStop(1, `rgba(${color},0)`);
ctx.fillStyle = g;
ctx.fillRect(0, y, w, height);
};
// A soft cloud: overlapping translucent circles.
function cloud(ctx, x, y, size, alpha, color = '255,255,255', seed = 1) {
const r = rng(seed);
for (let i = 0; i < 9; i += 1) {
const cx = x + (r() - 0.5) * size * 1.6;
const cy = y + (r() - 0.5) * size * 0.35;
const rad = size * (0.25 + r() * 0.3);
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
g.addColorStop(0, `rgba(${color},${alpha})`);
g.addColorStop(1, `rgba(${color},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(cx, cy, rad, 0, Math.PI * 2); ctx.fill();
}
}
export const SCENES = [
{ id: 'dawn', name: 'Sunrise', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#1d2a5b'], [0.45, '#8f5a8e'], [0.75, '#f0866a'], [1, '#f9d58a']], 0.8);
glow(ctx, w * 0.68, h * 0.66, h * 0.55, 'rgba(255,236,170,0.95)');
ridge(ctx, w, h, { base: h * 0.72, amp: h * 0.12, color: '#4a2f55', seed: 11 });
ridge(ctx, w, h, { base: h * 0.82, amp: h * 0.1, color: '#2a1c3c', seed: 12 });
} },
{ id: 'hills', name: 'Green hills', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#7fb8e6'], [1, '#eaf6ff']], 0.7);
glow(ctx, w * 0.8, h * 0.18, h * 0.4, 'rgba(255,250,214,0.9)');
ridge(ctx, w, h, { base: h * 0.58, amp: h * 0.1, color: '#a8d19a', seed: 21 });
ridge(ctx, w, h, { base: h * 0.68, amp: h * 0.12, color: '#79b36f', seed: 22 });
ridge(ctx, w, h, { base: h * 0.8, amp: h * 0.1, color: '#4f9150', seed: 23 });
ridge(ctx, w, h, { base: h * 0.92, amp: h * 0.07, color: '#2f6d3b', seed: 24 });
} },
{ id: 'sea', name: 'Sea at sunset', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#33316b'], [0.5, '#d6577b'], [1, '#ffb36b']], 0.66);
glow(ctx, w * 0.5, h * 0.62, h * 0.5, 'rgba(255,230,160,0.95)');
const sea = ctx.createLinearGradient(0, h * 0.64, 0, h);
sea.addColorStop(0, '#e98a5e'); sea.addColorStop(0.35, '#6b4a8a'); sea.addColorStop(1, '#1f2352');
ctx.fillStyle = sea;
ctx.fillRect(0, h * 0.64, w, h * 0.36);
const r = rng(33);
for (let i = 0; i < 70; i += 1) {
const y = h * 0.65 + r() * h * 0.33;
const spread = 0.04 + ((y - h * 0.64) / h) * 0.5;
ctx.fillStyle = `rgba(255,224,170,${0.55 - (y / h - 0.64) * 1.2})`;
ctx.fillRect(w * 0.5 - (r() * spread * w) / 2, y, r() * spread * w, 2);
}
} },
{ id: 'mountains', name: 'Mountains', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#5e8fc7'], [1, '#e7f0f8']], 0.75);
glow(ctx, w * 0.3, h * 0.25, h * 0.35, 'rgba(255,255,255,0.7)');
ridge(ctx, w, h, { base: h * 0.5, amp: h * 0.28, detail: 9, color: '#9db4cf', seed: 41 });
ridge(ctx, w, h, { base: h * 0.64, amp: h * 0.24, detail: 8, color: '#6f8cb0', seed: 42 });
ridge(ctx, w, h, { base: h * 0.8, amp: h * 0.16, detail: 7, color: '#3f5f87', seed: 43 });
const mist = ctx.createLinearGradient(0, h * 0.6, 0, h);
mist.addColorStop(0, 'rgba(255,255,255,0)'); mist.addColorStop(1, 'rgba(230,240,250,0.55)');
ctx.fillStyle = mist;
ctx.fillRect(0, h * 0.6, w, h * 0.4);
} },
{ id: 'night', name: 'Night sky', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#070b1f'], [0.7, '#1a2a63'], [1, '#34457f']], 0.9);
const r = rng(55);
for (let i = 0; i < 260; i += 1) {
ctx.fillStyle = `rgba(255,255,255,${0.25 + r() * 0.75})`;
const size = r() < 0.06 ? 2.4 : 1.2;
ctx.fillRect(r() * w, r() * h * 0.75, size, size);
}
glow(ctx, w * 0.78, h * 0.22, h * 0.3, 'rgba(255,248,214,0.55)');
ctx.fillStyle = '#fff6d6';
ctx.beginPath(); ctx.arc(w * 0.78, h * 0.22, h * 0.06, 0, Math.PI * 2); ctx.fill();
ridge(ctx, w, h, { base: h * 0.84, amp: h * 0.1, color: '#0c1230', seed: 56 });
} },
{ id: 'field', name: 'Wheat field', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#f3b66b'], [0.6, '#fbe3a8'], [1, '#fff4d6']], 0.62);
glow(ctx, w * 0.25, h * 0.5, h * 0.5, 'rgba(255,244,200,0.9)');
const g = ctx.createLinearGradient(0, h * 0.58, 0, h);
g.addColorStop(0, '#d9a441'); g.addColorStop(1, '#8c5f1a');
ctx.fillStyle = g;
ctx.fillRect(0, h * 0.58, w, h * 0.42);
const r = rng(66);
for (let i = 0; i < 1400; i += 1) {
const y = h * 0.58 + r() * h * 0.42;
const len = 6 + ((y - h * 0.58) / h) * 70;
ctx.strokeStyle = `rgba(${200 + r() * 50},${150 + r() * 60},${50 + r() * 40},${0.5 + r() * 0.4})`;
ctx.lineWidth = 1 + ((y - h * 0.58) / h) * 2.2;
const x = r() * w;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + (r() - 0.5) * 8, y - len); ctx.stroke();
}
} },
{ id: 'lake', name: 'Mountain lake', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#7d8fc9'], [0.55, '#f2b6b6'], [1, '#fde3c4']], 0.55);
glow(ctx, w * 0.62, h * 0.3, h * 0.35, 'rgba(255,240,205,0.85)');
ridge(ctx, w, h, { base: h * 0.34, amp: h * 0.42, detail: 11, color: '#6d7fae', seed: 74 });
ridge(ctx, w, h, { base: h * 0.46, amp: h * 0.3, detail: 10, color: '#465b8d', seed: 75 });
const water = ctx.createLinearGradient(0, h * 0.52, 0, h);
water.addColorStop(0, '#8493c2'); water.addColorStop(1, '#27375f');
ctx.fillStyle = water;
ctx.fillRect(0, h * 0.52, w, h * 0.48);
ctx.save(); // the mountains, upside down in the water
ctx.globalAlpha = 0.35;
ctx.translate(0, h * 1.04); ctx.scale(1, -0.6);
ridge(ctx, w, h * 0.6, { base: h * 0.34, amp: h * 0.42, detail: 11, color: '#2b3a66', seed: 74 });
ctx.restore();
ctx.fillStyle = water; ctx.globalAlpha = 0.25; ctx.fillRect(0, h * 0.52, w, h * 0.03); ctx.globalAlpha = 1;
const r = rng(73);
for (let i = 0; i < 60; i += 1) { ctx.fillStyle = `rgba(255,255,255,${0.05 + r() * 0.12})`; ctx.fillRect(r() * w, h * 0.55 + r() * h * 0.43, 20 + r() * 90, 1.5); }
} },
{ id: 'forest', name: 'Misty forest', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#9fc3c0'], [1, '#e8f1ea']], 0.8);
glow(ctx, w * 0.5, h * 0.3, h * 0.5, 'rgba(255,255,235,0.8)');
firs(ctx, w, { base: h * 0.62, size: h * 0.34, color: '#8fb3a8', seed: 81, count: 26 });
fog(ctx, w, h * 0.45, h * 0.3, 0.55);
firs(ctx, w, { base: h * 0.76, size: h * 0.5, color: '#5d8c80', seed: 82, count: 20 });
fog(ctx, w, h * 0.58, h * 0.3, 0.45);
firs(ctx, w, { base: h * 0.95, size: h * 0.5, color: '#2f5b53', seed: 83, count: 10 });
ctx.fillStyle = '#1e3f3a'; ctx.fillRect(0, h * 0.92, w, h * 0.08);
} },
{ id: 'desert', name: 'Desert dunes', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#e9a05e'], [0.55, '#f7cf98'], [1, '#fdecc8']], 0.6);
glow(ctx, w * 0.72, h * 0.36, h * 0.5, 'rgba(255,248,214,0.95)');
ctx.fillStyle = '#fff3cf'; ctx.beginPath(); ctx.arc(w * 0.72, h * 0.36, h * 0.07, 0, Math.PI * 2); ctx.fill();
ridge(ctx, w, h, { base: h * 0.58, amp: h * 0.08, detail: 3, color: '#e0a566', seed: 91 });
ridge(ctx, w, h, { base: h * 0.7, amp: h * 0.1, detail: 3, color: '#c9824a', seed: 92 });
ridge(ctx, w, h, { base: h * 0.84, amp: h * 0.09, detail: 3, color: '#a35f32', seed: 93 });
ridge(ctx, w, h, { base: h * 0.95, amp: h * 0.05, detail: 2, color: '#7a4224', seed: 94 });
} },
{ id: 'rays', name: 'Light through clouds', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#2c3e6e'], [0.5, '#6f86b5'], [1, '#c9d6ec']], 0.8);
const sx = w * 0.55; const sy = h * 0.12;
const r = rng(101);
for (let i = 0; i < 16; i += 1) {
const angle = Math.PI * (0.18 + r() * 0.64);
const spread = 0.025 + r() * 0.05;
const g = ctx.createLinearGradient(sx, sy, sx + Math.cos(angle) * h, sy + Math.sin(angle) * h);
g.addColorStop(0, `rgba(255,244,200,${0.3 + r() * 0.25})`); g.addColorStop(1, 'rgba(255,244,200,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(sx, sy);
ctx.lineTo(sx + Math.cos(angle - spread) * h * 1.3, sy + Math.sin(angle - spread) * h * 1.3);
ctx.lineTo(sx + Math.cos(angle + spread) * h * 1.3, sy + Math.sin(angle + spread) * h * 1.3);
ctx.closePath(); ctx.fill();
}
glow(ctx, sx, sy, h * 0.45, 'rgba(255,248,220,0.95)');
cloud(ctx, w * 0.25, h * 0.28, h * 0.45, 0.8, '255,255,255', 102);
cloud(ctx, w * 0.8, h * 0.22, h * 0.4, 0.75, '245,248,255', 103);
cloud(ctx, w * 0.5, h * 0.12, h * 0.35, 0.7, '255,250,235', 104);
ridge(ctx, w, h, { base: h * 0.84, amp: h * 0.09, color: '#1f3047', seed: 105 });
} },
{ id: 'winter', name: 'Winter snow', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#9fb6d4'], [1, '#eef4fb']], 0.7);
glow(ctx, w * 0.2, h * 0.25, h * 0.4, 'rgba(255,255,255,0.8)');
ridge(ctx, w, h, { base: h * 0.58, amp: h * 0.14, detail: 6, color: '#cfdced', seed: 111 });
firs(ctx, w, { base: h * 0.72, size: h * 0.26, color: '#6b8aa6', seed: 112, count: 16 });
ridge(ctx, w, h, { base: h * 0.74, amp: h * 0.1, color: '#e9f1fa', seed: 113 });
firs(ctx, w, { base: h * 0.9, size: h * 0.42, color: '#3d5e7c', seed: 114, count: 7 });
ridge(ctx, w, h, { base: h * 0.9, amp: h * 0.06, color: '#ffffff', seed: 115 });
const r = rng(116);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
for (let i = 0; i < 160; i += 1) { ctx.beginPath(); ctx.arc(r() * w, r() * h, 0.8 + r() * 2.2, 0, Math.PI * 2); ctx.fill(); }
} },
{ id: 'autumn', name: 'Autumn hills', paint(ctx, w, h) {
sky(ctx, w, h, [[0, '#f0b27a'], [0.6, '#fbe0b5'], [1, '#fff3da']], 0.65);
glow(ctx, w * 0.35, h * 0.35, h * 0.45, 'rgba(255,245,210,0.9)');
ridge(ctx, w, h, { base: h * 0.55, amp: h * 0.1, color: '#d9a05b', seed: 121 });
ridge(ctx, w, h, { base: h * 0.66, amp: h * 0.12, color: '#c4693a', seed: 122 });
ridge(ctx, w, h, { base: h * 0.78, amp: h * 0.1, color: '#a14a2a', seed: 123 });
ridge(ctx, w, h, { base: h * 0.9, amp: h * 0.08, color: '#6e2f1c', seed: 124 });
const r = rng(125);
for (let i = 0; i < 200; i += 1) {
ctx.fillStyle = ['#f2b33d', '#d9542b', '#e8832e', '#b5381f'][Math.floor(r() * 4)];
ctx.globalAlpha = 0.5 + r() * 0.4;
ctx.beginPath(); ctx.arc(r() * w, h * 0.62 + r() * h * 0.36, 2 + r() * 5, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
} },
];
export const sceneById = (id) => SCENES.find((s) => s.id === id) ?? null;
export function paintScene(ctx, id, w, h) {
(sceneById(id) ?? SCENES[0]).paint(ctx, w, h);
}
// Draws a photo to fill the box without stretching it (cropping the overflow).
export function drawCover(ctx, img, w, h) {
const scale = Math.max(w / img.width, h / img.height);
const dw = img.width * scale;
const dh = img.height * scale;
ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
}
const sceneUrls = new Map();
// A small picture of a scene, as a data address for CSS backgrounds (made once per size).
export function sceneDataUrl(id, w = 900, h = 520) {
const key = `${id}/${w}x${h}`;
if (!sceneUrls.has(key)) {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
paintScene(ctx, id, w, h);
sceneUrls.set(key, canvas.toDataURL('image/jpeg', 0.85));
}
return sceneUrls.get(key);
}
export function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not load that picture.'));
img.src = src;
});
}
// Photos listed in /verse-backgrounds/index.json: [{ "file": "mountain.jpg", "name": "Mountain" }, ...] (or bare file names).
export async function loadPhotoList() {
try {
const res = await fetch('/verse-backgrounds/index.json', { cache: 'no-cache' });
if (!res.ok) return [];
const list = await res.json();
if (!Array.isArray(list)) return [];
return list
.map((item) => (typeof item === 'string' ? { file: item } : item))
.filter((p) => p && /^[\w.-]+\.(jpe?g|png|webp)$/i.test(p.file ?? ''))
.map((p) => ({ id: p.file, name: p.name || p.file.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' '), url: `/verse-backgrounds/${p.file}` }));
} catch { return []; }
}
// The choices a person can make: 'auto' (a different picture each day), 'plain', or a scene/photo id.
// -> { kind: 'scene', id } | { kind: 'photo', url, id } | null for plain.
export function resolveBackground(choice, photos = [], date = new Date()) {
if (choice === 'plain') return null;
const all = [...SCENES.map((s) => ({ kind: 'scene', id: s.id })), ...photos.map((p) => ({ kind: 'photo', id: p.id, url: p.url }))];
if (!choice || choice === 'auto') {
const day = Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
return all[day % all.length];
}
return all.find((b) => b.id === choice) ?? all[0];
}
+43
View File
@@ -0,0 +1,43 @@
import { test, expect, vi, afterEach } from 'vitest';
import { SCENES, loadPhotoList, resolveBackground, sceneById } from './verseBackgrounds.js';
afterEach(() => vi.unstubAllGlobals());
test('there are several distinct built-in scenes', () => {
expect(SCENES.length).toBeGreaterThanOrEqual(5);
expect(new Set(SCENES.map((s) => s.id)).size).toBe(SCENES.length);
expect(sceneById('night').name).toBe('Night sky');
expect(sceneById('nope')).toBeNull();
});
test('"auto" is a different picture each day and the same all day', () => {
const mon = resolveBackground('auto', [], new Date(2026, 9, 5, 8));
const monLater = resolveBackground('auto', [], new Date(2026, 9, 5, 22));
const tue = resolveBackground('auto', [], new Date(2026, 9, 6, 8));
expect(mon).toEqual(monLater);
expect(tue).not.toEqual(mon);
const days = new Set(Array.from({ length: SCENES.length }, (_, i) => resolveBackground('auto', [], new Date(2026, 9, 1 + i)).id));
expect(days.size).toBe(SCENES.length); // every scene gets its day
});
test('choices resolve to plain, a scene or a photo', () => {
const photos = [{ id: 'lake.jpg', name: 'Lake', url: '/verse-backgrounds/lake.jpg' }];
expect(resolveBackground('plain', photos)).toBeNull();
expect(resolveBackground('sea', photos)).toEqual({ kind: 'scene', id: 'sea' });
expect(resolveBackground('lake.jpg', photos)).toEqual({ kind: 'photo', id: 'lake.jpg', url: '/verse-backgrounds/lake.jpg' });
expect(resolveBackground('gone.jpg', photos).kind).toBe('scene'); // a removed photo falls back to a scene
const withPhoto = new Set(Array.from({ length: SCENES.length + 1 }, (_, i) => resolveBackground('auto', photos, new Date(2026, 9, 1 + i)).id));
expect(withPhoto.has('lake.jpg')).toBe(true); // photos join the daily rotation
});
test('your photos come from index.json; bad entries and missing lists are ignored', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ['wheat-field.jpg', { file: 'lake.png', name: 'Mountain lake' }, '../evil.jpg', 'notes.txt', null] })));
expect(await loadPhotoList()).toEqual([
{ id: 'wheat-field.jpg', name: 'wheat field', url: '/verse-backgrounds/wheat-field.jpg' },
{ id: 'lake.png', name: 'Mountain lake', url: '/verse-backgrounds/lake.png' },
]);
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false })));
expect(await loadPhotoList()).toEqual([]);
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
expect(await loadPhotoList()).toEqual([]);
});
+125
View File
@@ -0,0 +1,125 @@
// A shareable picture of a verse: the words, the reference and the translation on a square card in one of
// the app's reading themes, drawn on a canvas so it works for every translation (including licensed ones,
// whose copyright line is printed on the card). No server involved.
import { READER_THEMES } from '../components/reader/readerThemes.js';
import { drawCover, loadImage, paintScene } from './verseBackgrounds.js';
export const CARD_SIZE = 1080;
const FONT = 'Georgia, "Times New Roman", serif';
const SANS = '-apple-system, "Segoe UI", Helvetica, Arial, sans-serif';
// Breaks `text` into lines no wider than maxWidth. `measure(string)` gives a string's width, so this is
// testable without a canvas. A single word wider than the line is left on its own line.
export function wrapLines(text, maxWidth, measure) {
const lines = [];
let line = '';
for (const word of String(text).split(/\s+/).filter(Boolean)) {
const next = line ? `${line} ${word}` : word;
if (line && measure(next) > maxWidth) { lines.push(line); line = word; } else line = next;
}
if (line) lines.push(line);
return lines;
}
// The biggest font size (from maxSize down to minSize) at which the text fits the box; its lines too.
export function fitText(text, { maxWidth, maxHeight, maxSize = 76, minSize = 30, lineHeight = 1.38, measureAt }) {
for (let size = maxSize; size >= minSize; size -= 2) {
const lines = wrapLines(text, maxWidth, (s) => measureAt(size, s));
if (lines.length * size * lineHeight <= maxHeight) return { size, lines, lineHeight };
}
const lines = wrapLines(text, maxWidth, (s) => measureAt(minSize, s));
return { size: minSize, lines, lineHeight };
}
const themeColors = (id) => {
const v = (READER_THEMES[id] ?? READER_THEMES.paper).vars;
return { bg: v['--r-bg'], text: v['--r-text'], sub: v['--r-sub'], accent: v['--r-accent'], line: v['--r-line'], bar: v['--r-bar'] };
};
// -> Promise<Blob> (PNG). `notice` is the translation's copyright line, shown small at the foot.
// `background` puts a picture behind the words: { kind: 'scene', id }, { kind: 'photo', url } or { kind: 'image', image }.
export async function renderVerseCard({ text, reference, translation, notice = '', themeId = 'paper', background = null }) {
const photo = background?.kind === 'photo' ? await loadImage(background.url) : background?.kind === 'image' ? background.image : null;
const canvas = document.createElement('canvas');
canvas.width = CARD_SIZE;
canvas.height = CARD_SIZE;
const ctx = canvas.getContext('2d');
if (!ctx) return Promise.reject(new Error('This browser can’t draw the image.'));
const pad = 96;
let c = themeColors(themeId);
if (background) {
// a picture behind the words, darkened so they stay easy to read
if (photo) drawCover(ctx, photo, CARD_SIZE, CARD_SIZE); else paintScene(ctx, background.id, CARD_SIZE, CARD_SIZE);
const scrim = ctx.createLinearGradient(0, 0, 0, CARD_SIZE);
scrim.addColorStop(0, 'rgba(0,0,0,0.3)'); scrim.addColorStop(0.5, 'rgba(0,0,0,0.4)'); scrim.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = scrim;
ctx.fillRect(0, 0, CARD_SIZE, CARD_SIZE);
c = { bg: '#000', text: '#ffffff', sub: 'rgba(255,255,255,0.86)', accent: '#ffe0b0', line: 'rgba(255,255,255,0.4)', bar: '#000' };
ctx.shadowColor = 'rgba(0,0,0,0.55)';
ctx.shadowBlur = 14;
} else {
ctx.fillStyle = c.bg;
ctx.fillRect(0, 0, CARD_SIZE, CARD_SIZE);
}
ctx.strokeStyle = c.line;
ctx.lineWidth = 3;
ctx.strokeRect(36, 36, CARD_SIZE - 72, CARD_SIZE - 72);
// accent mark
ctx.fillStyle = c.accent;
ctx.fillRect(CARD_SIZE / 2 - 36, 112, 72, 6);
// the verse, as large as will fit
const box = { top: 170, height: 560 };
const fit = fitText(text, {
maxWidth: CARD_SIZE - pad * 2, maxHeight: box.height,
measureAt: (size, s) => { ctx.font = `${size}px ${FONT}`; return ctx.measureText(s).width; },
});
ctx.font = `${fit.size}px ${FONT}`;
ctx.fillStyle = c.text;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const blockHeight = fit.lines.length * fit.size * fit.lineHeight;
let y = box.top + (box.height - blockHeight) / 2 + fit.size;
for (const line of fit.lines) { ctx.fillText(line, CARD_SIZE / 2, y); y += fit.size * fit.lineHeight; }
// reference and translation
ctx.fillStyle = c.accent;
ctx.font = `bold 46px ${FONT}`;
ctx.fillText(reference, CARD_SIZE / 2, 800);
ctx.fillStyle = c.sub;
ctx.font = `28px ${SANS}`;
ctx.fillText(translation, CARD_SIZE / 2, 850);
// the translation's copyright line, then the app's name
if (notice) {
ctx.font = `17px ${SANS}`;
wrapLines(notice, CARD_SIZE - pad * 2, (s) => ctx.measureText(s).width).slice(0, 3)
.forEach((l, i) => ctx.fillText(l, CARD_SIZE / 2, 910 + i * 24));
}
ctx.fillStyle = c.sub;
ctx.font = `22px ${SANS}`;
ctx.fillText('Bible Study', CARD_SIZE / 2, 1010);
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Could not make the image.'))), 'image/png'));
}
export const verseCardFileName = (reference) => `${reference.replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').toLowerCase()}.png`;
// Shares the image with the device's share sheet when it can take files; otherwise saves it. -> 'shared' | 'saved'
export async function shareOrSaveImage(blob, fileName, { title = '', text = '' } = {}) {
const file = new File([blob], fileName, { type: 'image/png' });
if (navigator.canShare?.({ files: [file] })) {
try { await navigator.share({ files: [file], title, text }); return 'shared'; } catch (err) { if (err?.name === 'AbortError') return 'shared'; /* otherwise fall through to saving */ }
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
return 'saved';
}