From ecb1c701fab33a43e15ac4fd0346996db8a49e86 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:27:02 -0400 Subject: [PATCH] Rework app bar, notes layout, Settings/Admin/login restyle App bar: brand, Studies / Read Bible / Reading Plans, and one account menu (theme, Settings, Admin, Log out). Study stage: notes get their own box above the resources in the right column. Settings, Admin and the login page use the reader themes; add a theme-aware danger colour. Co-Authored-By: Claude Sonnet 5.5 --- src/App.test.jsx | 79 ++++++++++- src/components/AuthStatus.jsx | 39 ------ src/components/plans/PlanDetailSheet.jsx | 8 +- src/components/reader/BookmarksSheet.jsx | 2 +- src/components/reader/ReaderText.jsx | 2 +- src/components/reader/SearchSheet.jsx | 4 +- src/components/reader/readerThemes.js | 6 +- src/components/study/CrossRefsTab.jsx | 2 +- src/components/study/NotesPane.jsx | 4 +- src/components/study/PageShell.jsx | 98 +++++++++++--- src/components/study/ResourcePane.jsx | 2 +- src/components/study/StudioShell.jsx | 2 +- src/components/study/WordStudyTab.jsx | 2 +- src/pages/AdminPage.jsx | 97 ++++++-------- src/pages/AuthPage.jsx | 74 +++++----- src/pages/ExportPage.jsx | 2 +- src/pages/HomePage.jsx | 10 +- src/pages/ImportPage.jsx | 6 +- src/pages/ReadingPlansPage.jsx | 6 +- src/pages/SettingsPage.jsx | 164 ++++++++++------------- src/pages/SetupPage.jsx | 4 +- src/pages/StudyPage.jsx | 27 ++-- 22 files changed, 351 insertions(+), 289 deletions(-) delete mode 100644 src/components/AuthStatus.jsx 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 && ( - - )} - - -
- ); -} 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 }) {
- +
); @@ -91,10 +91,10 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
- {plan.image && } - + {plan.image && } + - {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 - + ))} 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.

} + {indexStatus === 'error' &&

Couldn’t load the full Bible.

} {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 }) {
- +
{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 && (
-