diff --git a/src/App.test.jsx b/src/App.test.jsx
index 0d5f39e..f9eae9b 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -76,6 +76,12 @@ async function renderApp() {
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
+// The account menu (avatar button in the app bar) holds the email, Settings, Admin and Log out.
+async function openAccountMenu(user = userEvent.setup()) {
+ await user.click(await screen.findByRole('button', { name: /account menu/i }));
+ return user;
+}
+
async function loadChapter() {
const user = userEvent.setup();
await renderApp();
@@ -412,6 +418,7 @@ describe('Authentication', () => {
const u = userEvent.setup();
render( );
await signInWithForm(u);
+ await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
@@ -424,6 +431,7 @@ describe('Authentication', () => {
await signInWithForm(u);
await u.type(await screen.findByPlaceholderText('123456'), '123456');
await u.click(screen.getByRole('button', { name: /verify/i }));
+ await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
});
@@ -432,8 +440,9 @@ describe('Authentication', () => {
const fetchMock = stubApi({ session: user });
const u = userEvent.setup();
render( );
+ await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
- await u.click(screen.getByRole('button', { name: /log out/i }));
+ await u.click(screen.getByRole('menuitem', { name: /log out/i }));
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
});
@@ -442,7 +451,8 @@ describe('Authentication', () => {
stubApi({ session: user });
const u = userEvent.setup();
render( );
- await u.click(await screen.findByRole('button', { name: /settings/i }));
+ await openAccountMenu(u);
+ await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
});
});
@@ -479,7 +489,8 @@ describe('Admin page', () => {
stubAdminApi(admin);
const u = userEvent.setup();
render( );
- await u.click(await screen.findByRole('button', { name: /admin/i }));
+ await openAccountMenu(u);
+ await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
@@ -490,7 +501,8 @@ describe('Admin page', () => {
window.confirm.mockReturnValue(true);
const u = userEvent.setup();
render( );
- await u.click(await screen.findByRole('button', { name: /admin/i }));
+ await openAccountMenu(u);
+ await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
const row = (await screen.findByText('student@example.com')).closest('tr');
await u.click(within(row).getByRole('button', { name: /delete/i }));
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
@@ -500,8 +512,10 @@ describe('Admin page', () => {
test('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false });
render( );
+ await openAccountMenu();
expect(await screen.findByText(admin.email)).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: /admin/i })).not.toBeInTheDocument();
});
});
@@ -881,3 +895,58 @@ describe('Study workspace', () => {
expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument();
});
});
+
+
+// ---------------------------------------------------------------------------
+// App bar and study layout
+// ---------------------------------------------------------------------------
+describe('App bar', () => {
+ test('shows the three destinations, and the account menu switches the theme', async () => {
+ const user = userEvent.setup();
+ await renderApp();
+ expect(screen.getByRole('button', { name: /^studies$/i })).toHaveAttribute('aria-current', 'page');
+ expect(screen.getByRole('button', { name: /read bible/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /reading plans/i })).toBeInTheDocument();
+ await openAccountMenu(user);
+ await user.click(screen.getByRole('button', { name: /^dark$/i }));
+ expect(localStorage.getItem('reader-theme')).toBe('dark');
+ // Local-only mode has no signed-in user, so there is no Settings/Log out to show
+ expect(screen.queryByRole('menuitem', { name: /log out/i })).not.toBeInTheDocument();
+ });
+
+ test('the account menu closes on Escape', async () => {
+ const user = userEvent.setup();
+ await renderApp();
+ await openAccountMenu(user);
+ expect(screen.getByRole('button', { name: /^paper$/i })).toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('button', { name: /^paper$/i })).not.toBeInTheDocument();
+ });
+
+ test('the brand button and Studies return Home from another page', async () => {
+ const user = userEvent.setup();
+ await renderApp();
+ await user.click(screen.getByRole('button', { name: /import session list/i }));
+ await screen.findByRole('heading', { name: /import session list/i });
+ await user.click(screen.getByRole('button', { name: /bible study home/i }));
+ expect(await screen.findByText('My Studies')).toBeInTheDocument();
+ });
+});
+
+describe('Study layout', () => {
+ test('notes sit in their own box above the resources, not under the passage', async () => {
+ const u = await loadChapter();
+ addChunk('Paul, a servant', 'In hope of eternal');
+ await screen.findByText('1 created');
+ await u.click(screen.getByRole('button', { name: /begin studying/i }));
+ await findChunkCounter(1, 1);
+ const notes = screen.getByRole('region', { name: 'Notes' });
+ const resources = screen.getByRole('region', { name: 'Resources' });
+ expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
+ expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument();
+ // notes come first in the right column
+ expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ // and the passage column no longer holds them
+ expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/AuthStatus.jsx b/src/components/AuthStatus.jsx
deleted file mode 100644
index c43cbce..0000000
--- a/src/components/AuthStatus.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-// Signed-in email plus Admin / Settings / Log out buttons for page headers.
-import { useApp } from '../context/AppContext.js';
-
-// `themed` swaps the dark-header look for the reader themes' colours.
-export default function AuthStatus({ themed = false }) {
- const { authUser, setCurrentPage, logout } = useApp();
- if (!authUser) return null;
- const btn = themed
- ? 'rounded-lg px-2.5 py-1.5 text-xs text-[var(--r-sub)] transition hover:bg-black/5'
- : 'rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15';
- return (
-
- {authUser.email}
- {authUser.isAdmin && (
- setCurrentPage('admin')}
- className={btn}
- >
- 🛡 Admin
-
- )}
- setCurrentPage('settings')}
- className={btn}
- >
- ⚙ Settings
-
-
- Log out
-
-
- );
-}
diff --git a/src/components/plans/PlanDetailSheet.jsx b/src/components/plans/PlanDetailSheet.jsx
index ae7f226..f34e1f6 100644
--- a/src/components/plans/PlanDetailSheet.jsx
+++ b/src/components/plans/PlanDetailSheet.jsx
@@ -44,7 +44,7 @@ function ActiveBody({ plan, progress, onClose, actions }) {
{ if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart
- { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-red-600 hover:underline">Remove
+ { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove
>
);
@@ -91,10 +91,10 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
{ onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan
fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}
- {plan.image &&
actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-red-600">Remove cover }
-
{ if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-red-600 hover:underline">Delete
+ {plan.image &&
actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover }
+
{ if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete
- {imageError &&
{imageError}
}
+ {imageError &&
{imageError}
}
)}
>
diff --git a/src/components/reader/BookmarksSheet.jsx b/src/components/reader/BookmarksSheet.jsx
index ab983cf..b128651 100644
--- a/src/components/reader/BookmarksSheet.jsx
+++ b/src/components/reader/BookmarksSheet.jsx
@@ -12,7 +12,7 @@ export default function BookmarksSheet({ open, onClose, entries, onJump, onRemov
onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
- onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-red-600">
+ onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">
))}
diff --git a/src/components/reader/ReaderText.jsx b/src/components/reader/ReaderText.jsx
index ce2d69d..5dc367f 100644
--- a/src/components/reader/ReaderText.jsx
+++ b/src/components/reader/ReaderText.jsx
@@ -48,7 +48,7 @@ export default function ReaderText({
{loading && Loading…
}
- {error && {error}
}
+ {error && {error}
}
{!loading && !error && (
{indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')}
- {indexStatus === 'error' &&
Couldn’t load the full Bible. Try again
}
+ {indexStatus === 'error' &&
Couldn’t load the full Bible. Try again
}
{indexStatus === 'ready' && (!deferredBibleQuery
? note('Type a word to search all 66 books.')
: bibleMatches.length === 0
@@ -87,7 +87,7 @@ export default function SearchSheet({
{scope === 'bible' && !isLocal && (
<>
{remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')}
- {!remote.tooShort && remote.error &&
{remote.error}
}
+ {!remote.tooShort && remote.error &&
{remote.error}
}
{!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')}
{!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
{remote.results.length > 0 && (
diff --git a/src/components/reader/readerThemes.js b/src/components/reader/readerThemes.js
index d5a6d0d..a6472a0 100644
--- a/src/components/reader/readerThemes.js
+++ b/src/components/reader/readerThemes.js
@@ -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' },
+ 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' },
},
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' },
+ 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' },
},
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' },
+ 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' },
},
};
diff --git a/src/components/study/CrossRefsTab.jsx b/src/components/study/CrossRefsTab.jsx
index cb8c22d..a24e39b 100644
--- a/src/components/study/CrossRefsTab.jsx
+++ b/src/components/study/CrossRefsTab.jsx
@@ -13,7 +13,7 @@ function RefChip({ label, onRemove, loadVerseText }) {
{label}
- ×
+ ×
{open &&
{text ?? 'Loading…'}
}
diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx
index 6f45ea7..fba90f6 100644
--- a/src/components/study/NotesPane.jsx
+++ b/src/components/study/NotesPane.jsx
@@ -50,7 +50,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
{isOpen && (
-
)}
@@ -77,7 +77,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
{tags.map((tag) => (
{tag}
- updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-red-600">×
+ updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×
))}
setTagInput(e.target.value)} list="study-tag-suggestions"
diff --git a/src/components/study/PageShell.jsx b/src/components/study/PageShell.jsx
index 2549376..0d651ea 100644
--- a/src/components/study/PageShell.jsx
+++ b/src/components/study/PageShell.jsx
@@ -1,29 +1,91 @@
-// Themed page frame for pages that aren't part of a study project (Home, Import):
-// slim top bar with a title, optional back button, and actions on the right.
+// Themed frame for pages outside a study project (Home, Import, Settings, Admin):
+// a calm app bar (brand, three destinations, account menu) and a page heading row.
+import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx';
-import AuthStatus from '../AuthStatus.jsx';
-const THEME_ORDER = ['paper', 'sepia', 'dark'];
+const NAV = [
+ { id: 'studies', label: 'Studies' },
+ { id: 'reader', label: 'Read Bible' },
+ { id: 'plans', label: 'Reading Plans' },
+];
-export default function PageShell({ title, onBack, actions, maxWidth = 'max-w-6xl', children }) {
- const { readerTheme, setReaderTheme } = useApp();
- const cycle = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
+// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
+function AccountMenu() {
+ const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return undefined;
+ const onDown = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
+ document.addEventListener('mousedown', onDown);
+ document.addEventListener('keydown', onKey);
+ return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
+ }, [open]);
+
+ const go = (page) => { setOpen(false); setCurrentPage(page); };
+ const item = 'block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5';
+
+ return (
+
+
setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
+ className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
+ {authUser?.email?.[0] ?? '·'}
+
+ {open && (
+
+ {authUser &&
{authUser.email}
}
+
+
Theme
+
+ {Object.entries(READER_THEMES).map(([id, t]) => (
+ setReaderTheme(id)} aria-pressed={readerTheme === id}
+ className={`flex-1 px-2 py-1.5 text-xs ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}
+ ))}
+
+
+ {authUser && (
+
+ go('settings')} className={item}>Settings
+ {authUser.isAdmin && go('admin')} className={item}>Admin }
+ { setOpen(false); logout(); }} className={item}>Log out
+
+ )}
+
+ )}
+
+ );
+}
+
+export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
+ const { readerTheme, goHome, openBibleReader, openReadingPlans } = useApp();
+ const go = { studies: goHome, reader: openBibleReader, plans: openReadingPlans };
return (
-
-
- {onBack && (
-
- )}
-
{title}
-
- {actions}
-
{READER_THEMES[readerTheme]?.label}
-
-
+
+
+
+
+ Bible Study
+
+
+ {NAV.map((n) => (
+ {n.label}
+ ))}
+
+
+
+
+ {onBack && (
+
+ )}
+
{title}
+ {actions &&
{actions}
}
{children}
diff --git a/src/components/study/ResourcePane.jsx b/src/components/study/ResourcePane.jsx
index 722257e..f358812 100644
--- a/src/components/study/ResourcePane.jsx
+++ b/src/components/study/ResourcePane.jsx
@@ -16,7 +16,7 @@ export const RESOURCE_TABS = [
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
return (
-
+
{RESOURCE_TABS.map((t) => (
setTab(t.id)}
diff --git a/src/components/study/StudioShell.jsx b/src/components/study/StudioShell.jsx
index 103f799..66ad02d 100644
--- a/src/components/study/StudioShell.jsx
+++ b/src/components/study/StudioShell.jsx
@@ -48,7 +48,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
: syncStatus === 'syncing' ? Syncing…
: syncStatus === 'synced' ? Saved
: syncStatus === 'error' ? (
- Sync failed Retry
+ Sync failed Retry
) : null}
{READER_THEMES[readerTheme]?.label}
diff --git a/src/components/study/WordStudyTab.jsx b/src/components/study/WordStudyTab.jsx
index 5855529..a9c6672 100644
--- a/src/components/study/WordStudyTab.jsx
+++ b/src/components/study/WordStudyTab.jsx
@@ -34,7 +34,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}
lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}
{word.lexeme &&
speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak }
-
removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-red-700`}>Delete
+
removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete
{field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')}
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index 597e81a..c2555bc 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
-import AuthStatus from '../components/AuthStatus.jsx';
+import PageShell from '../components/study/PageShell.jsx';
import { buildExportHtml } from '../utils/bibleUtils.jsx';
import {
adminListUsers,
@@ -68,46 +68,27 @@ export default function AdminPage() {
};
return (
-
-
-
-
-
Bible Study Project
-
🛡 Admin
-
-
-
-
-
-
+
+
setAdminTab('users')}
- className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
+ className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'users' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Users ({adminUsers.length})
setAdminTab('projects')}
- className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
+ className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Projects ({adminProjects.length})
- {adminLoading && Loading…
}
- {adminError && {adminError}
}
+ {adminLoading && Loading…
}
+ {adminError && {adminError}
}
{!adminLoading && !adminError && adminTab === 'users' && (
-
+
-
+
Email
Joined
@@ -118,22 +99,22 @@ export default function AdminPage() {
{adminUsers.map((u) => (
-
-
- {u.email}{u.id === authUser.id && (you) }
+
+
+ {u.email}{u.id === authUser.id && (you) }
- {new Date(u.createdAt).toLocaleDateString()}
- {u.totpEnabled ? '✓' : '—'}
- {u.projectCount}
+ {new Date(u.createdAt).toLocaleDateString()}
+ {u.totpEnabled ? '✓' : '—'}
+ {u.projectCount}
{u.id !== authUser.id && (
handleResetPasswordAdmin(u.id, u.email)}
- className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
+ className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
Reset Password
handleDeleteUserAdmin(u.id, u.email)}
- className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
+ className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
Delete
@@ -148,10 +129,10 @@ export default function AdminPage() {
)}
{!adminLoading && !adminError && adminTab === 'projects' && (
-
+
-
+
Title
Owner
@@ -163,22 +144,22 @@ export default function AdminPage() {
{adminProjects.map((p) => (
-
- {p.title}
-
- {p.ownerEmail ?? orphaned }
+
+ {p.title}
+
+ {p.ownerEmail ?? orphaned }
- {p.chapterSummary}
- {new Date(p.lastEdited).toLocaleDateString()}
- {p.shareToken ? '🔗' : '—'}
+ {p.chapterSummary}
+ {new Date(p.lastEdited).toLocaleDateString()}
+ {p.shareToken ? '🔗' : '—'}
handleViewProjectAdmin(p.id)}
- className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
+ className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
View
handleDeleteProjectAdmin(p.id, p.title)}
- className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
+ className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
Delete
@@ -194,11 +175,11 @@ export default function AdminPage() {
{adminViewProject && (
setAdminViewProject(null)}>
-
e.stopPropagation()}>
-
-
{adminViewProject.title}
+
e.stopPropagation()}>
+
+
{adminViewProject.title}
setAdminViewProject(null)}
- className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
+ className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
Close
@@ -214,22 +195,22 @@ export default function AdminPage() {
{adminResetResult && (
-
-
Password reset
-
- Relay this to {adminResetResult.email} yourself
+
+
Password reset
+
+ Relay this to {adminResetResult.email} yourself
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
-
+
{adminResetResult.temporaryPassword}
setAdminResetResult(null)}
- className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
+ className="mt-4 w-full rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90">
Done
)}
-
+
);
}
diff --git a/src/pages/AuthPage.jsx b/src/pages/AuthPage.jsx
index 4afe6d6..097bb28 100644
--- a/src/pages/AuthPage.jsx
+++ b/src/pages/AuthPage.jsx
@@ -1,9 +1,11 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
+import { themeVars } from '../components/reader/readerThemes.js';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
export default function AuthPage() {
- const { signIn } = useApp();
+ const { signIn, readerTheme } = useApp();
+ const shell = { style: themeVars(readerTheme) };
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState('');
@@ -49,11 +51,11 @@ export default function AuthPage() {
if (authMfaPending) {
return (
-
-
-
Bible Study Project
-
Two-factor verification
-
+
+
+
Bible Study Project
+
Two-factor verification
+
{authMfaUseBackup
? 'Enter one of your saved backup codes.'
: 'Enter the 6-digit code from your authenticator app.'}
@@ -67,13 +69,13 @@ export default function AuthPage() {
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
value={authMfaCode}
onChange={(e) => setAuthMfaCode(e.target.value)}
- className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
+ className="block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-center text-lg tracking-widest focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
- {authError &&
{authError}
}
+ {authError &&
{authError}
}
{authBusy ? 'Verifying…' : 'Verify'}
@@ -85,7 +87,7 @@ export default function AuthPage() {
setAuthMfaCode('');
setAuthError('');
}}
- className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
+ className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
>
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
@@ -97,7 +99,7 @@ export default function AuthPage() {
setAuthMfaCode('');
setAuthError('');
}}
- className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
+ className="mt-2 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-sub)]"
>
Cancel
@@ -107,41 +109,41 @@ export default function AuthPage() {
}
return (
-
+
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
-
Bible Study Project
-
- Deep Bible study, organized chunk by chunk.
+ Bible Study Project
+
+ Deep Bible study, one chunk at a time.
-
- Split any passage into chunks and work through Observation, Interpretation, and
- Application notes alongside Greek & Hebrew word studies, cross-references, and commentary.
+
+ Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
+ at a time with commentary, word studies, and cross-references beside it.
-
- 📖 Chunk-by-chunk OIA notes on any passage
- 🔤 Greek & Hebrew word studies with pronunciation
- 🔗 Cross-references and commentary, one click away
- 🎙 Turn your notes into a podcast-ready script
- 🔒 Your studies stay private to your account, with optional 2FA
+
+ Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search
+ Reading plans that open straight into the Reader
+ Observe, Interpret, Apply notes with Greek & Hebrew word studies
+ Turn your notes into a script, then export or share
+ Your studies stay private to your account, with optional 2FA
-
+
"Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth."
-
+
{/* Sign in / register form */}
-
-
Bible Study Project
-
+
+
Bible Study Project
+
{authMode === 'login' ? 'Sign in' : 'Create an account'}
- {share.shareBusy ? 'Revoking…' : 'Revoke link'}
+ {share.shareBusy ? 'Revoking…' : 'Revoke link'}
) : (
{share.shareBusy ? 'Enabling…' : 'Enable sharing'}
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index ec18cb3..81ee8db 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -29,7 +29,6 @@ export default function HomePage() {
audioNarrator, setAudioNarrator,
audioState,
audioProgress,
- openBibleReader,
openImportProject,
openNewProject,
pullLatestFromServer,
@@ -110,10 +109,9 @@ export default function HomePage() {
return (
- Read Bible
- Reading Plans
Import Session List
+ New Project
>
@@ -137,8 +135,8 @@ export default function HomePage() {
)}
{readingPlanSyncStatus === 'error' && (
- Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.
- Retry
+ Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.
+ Retry
)}
@@ -227,7 +225,7 @@ export default function HomePage() {
resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume
- deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-red-700 hover:bg-black/5">Delete
+ deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete
))}
diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx
index f946c04..69d1832 100644
--- a/src/pages/ImportPage.jsx
+++ b/src/pages/ImportPage.jsx
@@ -150,7 +150,7 @@ export default function ImportPage() {
const field = 'mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
return (
-
setCurrentPage('home')} maxWidth="max-w-4xl">
+ setCurrentPage('home')} maxWidth="max-w-3xl">
@@ -187,7 +187,7 @@ export default function ImportPage() {
{importBusy &&
Working…
}
- {importError &&
{importError}
}
+ {importError &&
{importError}
}
{importPreview && !importBusy && (
@@ -205,7 +205,7 @@ export default function ImportPage() {
{spec.title}
{spec.parsed === 'invalid'
- ? unrecognized — skipped
+ ? unrecognized — skipped
: spec.parsed ? spec.passage : marker (no passage) }
diff --git a/src/pages/ReadingPlansPage.jsx b/src/pages/ReadingPlansPage.jsx
index bbaaa7a..9d430bf 100644
--- a/src/pages/ReadingPlansPage.jsx
+++ b/src/pages/ReadingPlansPage.jsx
@@ -113,11 +113,11 @@ export default function ReadingPlansPage() {
{readingPlanSyncStatus === 'error' && (
-
+
Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.
{readingPlanSyncError && {readingPlanSyncError} }
- Retry
+ Retry
)}
@@ -217,7 +217,7 @@ export default function ReadingPlansPage() {
restartPlan(p.id)} className="font-medium hover:underline">Start again
- { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-red-600 hover:underline">Remove
+ { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove
);
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index 46898a0..cc1113f 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
-import AuthStatus from '../components/AuthStatus.jsx';
+import PageShell from '../components/study/PageShell.jsx';
import {
startMfaSetup,
confirmMfaSetup,
@@ -160,52 +160,32 @@ export default function SettingsPage() {
};
return (
-
-
-
-
-
Bible Study Project
-
Account Settings
-
-
-
-
-
-
-
- Account
-
- Signed in as {authUser.email}
+
+
+
+ Account
+
+ Signed in as {authUser.email}
-
+
-
Change password
-
Changing your password signs you out of every other device — this one stays signed in.
+
Change password
+
Changing your password signs you out of every other device — this one stays signed in.
-
+
-
Podcast / show name
-
- Only needed if you use "🎙 Prepare for Podcast" on the study page — it fills in your show's
+
Podcast / show name
+
+ Only needed if you use "Podcast → Claude" on a study's Script stage — it fills in your show's
name when asking Claude to write an episode script. Leave blank if you're just doing personal
study; that button still works, it just won't name a specific show.
@@ -256,23 +236,23 @@ export default function SettingsPage() {
value={podcastNameInput}
onChange={(e) => setPodcastNameInput(e.target.value)}
placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture"
- className="w-full flex-1 rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
+ className="w-full flex-1 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
{podcastNameSaving ? 'Saving…' : 'Save'}
- {podcastNameSaved &&
Saved.
}
+ {podcastNameSaved &&
Saved.
}
-
+
-
My reading journal
-
+
My reading journal
+
A document of your bookmarked verses, highlighted passages, and completed reading plans.
Bookmarks and highlights only live on this device (they're not synced), so this reflects
what's saved here.
@@ -283,7 +263,7 @@ export default function SettingsPage() {
type="button"
onClick={downloadJournalDocx}
disabled={journalBusy !== ''}
- className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
+ className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{journalBusy === 'docx' ? 'Building…' : 'Download as Word (.docx)'}
@@ -291,83 +271,83 @@ export default function SettingsPage() {
type="button"
onClick={printJournal}
disabled={journalBusy !== ''}
- className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
+ className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50"
>
{journalBusy === 'pdf' ? 'Building…' : 'Print / Save as PDF'}
- {journalError && {journalError}
}
+ {journalError && {journalError}
}
-
+
-
Reading reminders
-
+
Reading reminders
+
Get a daily nudge via{' '}
- ntfy.sh
+ ntfy.sh
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || ''}
{' '}in a browser) and subscribe to the topic below to receive it.
-
+
-
Two-factor authentication
-
+
Two-factor authentication
+
{authUser.totpEnabled
? "Enabled — you'll need a code from your authenticator app to sign in."
: 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'}
@@ -375,39 +355,39 @@ export default function SettingsPage() {
{mfaBackupCodes ? (
-
-
+
+
Save these backup codes now — each works once if you ever lose your device. They won't be shown again.
-
+
{mfaBackupCodes.map((code) => (
-
{code}
+
{code}
))}
setMfaBackupCodes(null)}
- className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400"
+ className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90"
>
I've saved these codes
) : authUser.totpEnabled ? (
-
{error ? {error} : 'Text: Berean Standard Bible (BSB).'}
+
{error ? {error} : 'Text: Berean Standard Bible (BSB).'}
{loading ? 'Loading…' : 'Load chapter'}
@@ -180,7 +180,7 @@ export default function SetupPage() {
b.moveChunk(chunk.id, -1)} disabled={index === 0} aria-label="Move chunk up" className={smallBtn}>↑
b.moveChunk(chunk.id, 1)} disabled={index === activeChapter.chunks.length - 1} aria-label="Move chunk down" className={smallBtn}>↓
- b.deleteChunk(chunk.id)} className={`${smallBtn} ml-auto text-red-700`}>× Delete
+ b.deleteChunk(chunk.id)} className={`${smallBtn} ml-auto text-[var(--r-danger,#b42318)]`}>× Delete
))}
diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx
index 9978b85..ba3587f 100644
--- a/src/pages/StudyPage.jsx
+++ b/src/pages/StudyPage.jsx
@@ -78,7 +78,7 @@ export default function StudyPage() {
return (
-
+
setCurrentPage('setup')} />
@@ -107,21 +107,30 @@ export default function StudyPage() {
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
addedStrongs={addedStrongs} onPickWord={pickWord}
/>
-
) : (
Add a chunk in the Outline stage to start studying.
)}
-
+
{selectedChunk && chapter && (
-
+ <>
+
+
+ >
)}