diff --git a/src/components/FamilyTree.jsx b/src/components/FamilyTree.jsx
new file mode 100644
index 0000000..c2dd902
--- /dev/null
+++ b/src/components/FamilyTree.jsx
@@ -0,0 +1,61 @@
+// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and
+// descendants a few generations out. From Theographic Bible Metadata.
+import { useEffect, useState } from 'react';
+import { loadPerson } from '../hooks/useVerseStudy.js';
+import { relatives, walk } from '../utils/family.js';
+
+function Branch({ node, onOpen }) {
+ return (
+
;
+}
+
+// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked.
+export default function Family({ person, renderPerson }) {
+ const groups = relatives(person);
+ const [shown, setShown] = useState(null); // a relative opened from a name or the tree
+ const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened
+ if (groups.length === 0) return null;
+ return (
+
+ {groups.map((g) => (
+
+ {g.label}
+ {g.people.map((p) => (
+
+ ))}
+
+ ))}
+ setTreeOpen(e.currentTarget.open)}>
+ Family tree
+ {treeOpen && setShown(id)} />}
+
+ {shown &&
+ );
+}
diff --git a/src/components/ScriptureRefs.test.jsx b/src/components/ScriptureRefs.test.jsx
new file mode 100644
index 0000000..ed30231
--- /dev/null
+++ b/src/components/ScriptureRefs.test.jsx
@@ -0,0 +1,47 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, test, expect, beforeEach } from 'vitest';
+import { AppContext } from '../context/AppContext.js';
+import ScriptureRefs from './ScriptureRefs.jsx';
+
+const jump = vi.fn();
+const open = vi.fn();
+beforeEach(() => {
+ jump.mockClear(); open.mockClear();
+ vi.stubGlobal('fetch', vi.fn(async () => ({
+ ok: true, status: 200,
+ json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }),
+ })));
+});
+const setup = (text) => render();
+
+test('references in a note become chips, and a note with none shows nothing', () => {
+ const { container } = setup('Nothing to see here.');
+ expect(container).toBeEmptyDOMElement();
+});
+
+test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => {
+ const user = userEvent.setup();
+ setup('God works all things together, Rom 8:28-29. See also Romans 8:28.');
+ expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references
+ await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
+ const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ });
+ expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument();
+ expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument();
+ expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument();
+ await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' }));
+ expect(jump).toHaveBeenCalledWith('ROM', 8, 28);
+ expect(open).toHaveBeenCalled();
+ await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
+ expect(screen.queryByRole('region')).not.toBeInTheDocument();
+});
+
+test('a failure to load the verse still leaves the link to the Bible', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
+ const user = userEvent.setup();
+ setup('Psalm 23:1');
+ await user.click(screen.getByRole('button', { name: 'Psalms 23:1' }));
+ const preview = await screen.findByRole('region');
+ expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument();
+ expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument();
+});
diff --git a/src/components/StudyPanel.jsx b/src/components/StudyPanel.jsx
index 52f83ba..738e24a 100644
--- a/src/components/StudyPanel.jsx
+++ b/src/components/StudyPanel.jsx
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useIqAvailable } from '../hooks/useIq.js';
import IqCommentary from './iq/IqCommentary.jsx';
+import Family from './FamilyTree.jsx';
import {
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
} from '../hooks/useVerseStudy.js';
@@ -71,6 +72,7 @@ function PersonDetails({ id }) {
{(data.description ?? []).map((d, i) =>
{d}
)}
{!data.description?.length &&
No description available.
}
+ } />
{data.minYear != null && (
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
diff --git a/src/components/iq/IqDictionary.jsx b/src/components/iq/IqDictionary.jsx
new file mode 100644
index 0000000..53e8797
--- /dev/null
+++ b/src/components/iq/IqDictionary.jsx
@@ -0,0 +1,75 @@
+// A Bible dictionary lookup (Easton, Hastings, Hitchcock, Schaff, Smith: all public domain, from IQ Bible).
+// Type a word, pick a dictionary; the entry named for the word comes first and the entries that only mention it
+// are folded below. Scripture references in an entry open in the Reader.
+import { useState } from 'react';
+import { useApp } from '../../context/AppContext.js';
+import { useIq } from '../../hooks/useIq.js';
+import { pickEntries, preview, refTarget } from '../../utils/dictionary.js';
+
+const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
+
+function Entry({ entry, credit, open: startOpen }) {
+ const { jumpToReaderVerse, openBibleReader } = useApp();
+ const [open, setOpen] = useState(startOpen);
+ const p = preview(entry.definition);
+ const refs = [...new Set(entry.scripture_refs ?? [])].map(refTarget).filter(Boolean).slice(0, 14);
+ return (
+
+
+
+ );
+}
diff --git a/src/components/iq/IqDictionary.test.jsx b/src/components/iq/IqDictionary.test.jsx
new file mode 100644
index 0000000..44f1dc1
--- /dev/null
+++ b/src/components/iq/IqDictionary.test.jsx
@@ -0,0 +1,52 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, test, expect, beforeEach } from 'vitest';
+import { AppContext } from '../../context/AppContext.js';
+import IqDictionary from './IqDictionary.jsx';
+
+const LIST = { count: 2, data: [
+ { id: 'easton', name: "Easton's Bible Dictionary", author: 'Matthew George Easton', year: '1897', license: 'Public domain', entry_count: 3962 },
+ { id: 'smith', name: "Smith's Bible Dictionary", author: 'William Smith', year: '1863', license: 'Public domain', entry_count: 4488 },
+] };
+const ANSWER = { count: 2, data: [
+ { term: 'Alabaster', slug: 'alabaster', definition: 'A stone box. It mentions a talent.', scripture_refs: ['Matthew 26:7'] },
+ { term: 'Talent', slug: 'talent', definition: 'The largest Hebrew weight, about 75 pounds.', scripture_refs: ['Exodus 38:25', 'Matthew 25:15'] },
+] };
+
+const jump = vi.fn();
+const open = vi.fn();
+const asked = [];
+beforeEach(() => {
+ asked.length = 0; jump.mockClear(); open.mockClear(); localStorage.clear();
+ vi.stubGlobal('fetch', vi.fn(async (url) => {
+ asked.push(String(url));
+ const body = String(url).includes('/api/iq/dictionaries/') ? ANSWER : LIST;
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
+ }));
+});
+
+test('look up a word: the entry named for it comes first, its Scripture references open in the Reader', async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByText('Bible dictionary'));
+ await user.type(await screen.findByRole('textbox', { name: 'Look up a word' }), 'talent{Enter}');
+ const entry = (await screen.findByRole('heading', { name: 'Talent' })).closest('article');
+ expect(within(entry).getByText(/about 75 pounds/)).toBeInTheDocument();
+ expect(asked.some((u) => /dictionaries\/easton\?q=talent/.test(u))).toBe(true);
+ await user.click(within(entry).getByRole('button', { name: 'Matthew 25:15' }));
+ expect(jump).toHaveBeenCalledWith('MAT', 25, 15);
+ expect(open).toHaveBeenCalled();
+ expect(within(entry).getByText(/Easton's Bible Dictionary, Matthew George Easton, 1897\. Public domain\./)).toBeInTheDocument();
+ expect(screen.getByText(/Entries that mention|Other entries that mention/)).toBeInTheDocument(); // Alabaster only mentions it
+});
+
+test('the chosen dictionary is remembered and used for the next lookup', async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByText('Bible dictionary'));
+ await user.selectOptions(await screen.findByRole('combobox', { name: 'Dictionary' }), 'smith');
+ expect(localStorage.getItem('dictionary')).toBe('smith');
+ await user.type(screen.getByRole('textbox', { name: 'Look up a word' }), 'cubit{Enter}');
+ await screen.findByRole('heading', { name: 'Talent' });
+ expect(asked.some((u) => /dictionaries\/smith\?q=cubit/.test(u))).toBe(true);
+});
diff --git a/src/components/settings/DataSection.jsx b/src/components/settings/DataSection.jsx
index f23386d..2d0c840 100644
--- a/src/components/settings/DataSection.jsx
+++ b/src/components/settings/DataSection.jsx
@@ -106,7 +106,7 @@ export default function DataSection() {
Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.
{offline.job ? : (
- {['BSB', 'NET'].map((t) => (
+ {['BSB', 'NET', 'KJV', 'ASV', 'LSV'].map((t) => (
diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx
index 67c644e..b567876 100644
--- a/src/components/study/NotesPane.jsx
+++ b/src/components/study/NotesPane.jsx
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.jsx';
+import ScriptureRefs from '../ScriptureRefs.jsx';
import { usePasteCheck } from './PastePrompt.jsx';
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
@@ -108,6 +109,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
)}
diff --git a/src/components/study/WordStudyTab.jsx b/src/components/study/WordStudyTab.jsx
index 0c5525a..69228cb 100644
--- a/src/components/study/WordStudyTab.jsx
+++ b/src/components/study/WordStudyTab.jsx
@@ -6,6 +6,8 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
import { useIqAvailable } from '../../hooks/useIq.js';
import { normalizeStrongs } from '../../utils/iqbible.js';
import IqLexicon from '../iq/IqLexicon.jsx';
+import GrammarPanel from '../GrammarPanel.jsx';
+import IqDictionary from '../iq/IqDictionary.jsx';
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
@@ -50,6 +52,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
{field('English word', 'englishGloss', 'e.g. grace')}
+ {word.parsing && }
{field('Short definition', 'shortDefinition')}
{word.definitionHtml && (
@@ -76,8 +79,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
+ const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key
return (
+ {iq && }
suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}
diff --git a/src/hooks/useWordStudy.js b/src/hooks/useWordStudy.js
index 8e3c050..c72fd4a 100644
--- a/src/hooks/useWordStudy.js
+++ b/src/hooks/useWordStudy.js
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
lexeme: w.o || entry?.lemma || '',
transliteration: w.t || entry?.translit || entry?.xlit || '',
partOfSpeech: '',
+ parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
shortDefinition: entry?.kjv_def || w.g || '',
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
loading: false,
diff --git a/src/pages/ScriptPage.jsx b/src/pages/ScriptPage.jsx
index abcfc7a..126ca4f 100644
--- a/src/pages/ScriptPage.jsx
+++ b/src/pages/ScriptPage.jsx
@@ -2,6 +2,7 @@
// write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
+import ScriptureRefs from '../components/ScriptureRefs.jsx';
import PresentMode from '../components/PresentMode.jsx';
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
import { useIqAvailable } from '../hooks/useIq.js';
@@ -174,6 +175,7 @@ export default function ScriptPage() {
set({ finalScript: e.target.value })}
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
+