diff --git a/src/App.jsx b/src/App.jsx
index f3b2197..e00e5f1 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -23,6 +23,8 @@ const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
+const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
+const ExportPage = lazy(() => import('./pages/ExportPage.jsx'));
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
@@ -32,7 +34,7 @@ const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
const App = () => {
const [project, setProject] = useState(null);
- // 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
+ // 'home' | 'setup' | 'study' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
const [currentPage, setCurrentPage] = useState('home');
const [projectIndex, setProjectIndex] = useState([]);
const [saveStatus, setSaveStatus] = useState('');
@@ -245,7 +247,7 @@ const App = () => {
authUser, setAuthUser,
signIn,
logout,
- // Save / sync status (shown in ProjectHeader)
+ // Save / sync status (shown in the stage bar)
saveStatus, setSaveStatus,
syncStatus,
retrySync,
@@ -348,6 +350,8 @@ function AppRouter() {
if (currentPage === 'reader') return ;
if (currentPage === 'import') return ;
if (currentPage === 'study') return ;
+ if (currentPage === 'script') return ;
+ if (currentPage === 'export') return ;
if (currentPage === 'setup') return ;
if (currentPage === 'pronunciation') return ;
return ;
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 03471cc..0d5f39e 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -135,17 +135,18 @@ describe('Initial render', () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
- expect(await screen.findByText('Project Setup')).toBeInTheDocument();
+ expect(await screen.findByText('New study')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
- test('setup form shows translation, book, and chapter inputs', async () => {
+ test('setup form shows book and chapter inputs, and uses the BSB', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
- expect(screen.getByText('Translation')).toBeInTheDocument();
+ expect(screen.queryByText('Translation')).not.toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
expect(screen.getByText('Chapter')).toBeInTheDocument();
+ expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
});
});
@@ -200,7 +201,7 @@ describe('Chunk management', () => {
test('creates a chunk by clicking a verse range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Grace and peace');
- await screen.findByText(/1[-–]3/);
+ expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0);
});
test('chunk count increments after each addition', async () => {
@@ -250,7 +251,7 @@ async function goToStudyWith3Chunks() {
addChunk('Grace and peace', 'Grace and peace');
await screen.findByText('3 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
- await screen.findByText(/chunk editor/i);
+ await findChunkCounter(3, 3);
// addChunk always selects the newest chunk; navigate back to chunk 1
// so navigation tests start from a known position.
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
@@ -264,7 +265,7 @@ describe('Study view navigation', () => {
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
- await screen.findByText(/chunk editor/i);
+ await findChunkCounter(1, 1);
});
test('"Previous Chunk" is disabled on the first chunk', async () => {
@@ -305,8 +306,9 @@ async function goToStudyAndAddGreekWord(fetchMock) {
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
- await screen.findByText(/chunk editor/i);
- fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
+ await findChunkCounter(1, 1);
+ fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
+ fireEvent.click(screen.getByRole('button', { name: /add word/i }));
await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
}
@@ -507,12 +509,13 @@ describe('Admin page', () => {
// Import page (state is local to ImportPage)
// ---------------------------------------------------------------------------
describe('Import page', () => {
- test('opens from Home, lists fetched translations, and goes back', async () => {
+ test('opens from Home, uses the BSB (no translation choice), and goes back', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
- expect(await screen.findByRole('option', { name: 'KJV' })).toBeInTheDocument();
+ expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
+ expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /back/i }));
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
});
@@ -625,15 +628,17 @@ describe('Home page', () => {
});
// ---------------------------------------------------------------------------
-// Project header (ProjectHeader + useShareLink) and the shared read-only view
+// Export stage (useShareLink) and the shared read-only view
// ---------------------------------------------------------------------------
describe('Project header and sharing', () => {
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
- test('Export → Markdown downloads a file', async () => {
+ test('Export stage → Markdown downloads a file', async () => {
const user = await loadChapter();
- await user.click(screen.getByRole('button', { name: /export/i }));
- await user.click(screen.getByRole('button', { name: /export markdown/i }));
+ addChunk('Paul, a servant', 'Paul, a servant');
+ await screen.findByText('1 created');
+ await user.click(screen.getByRole('button', { name: /^export$/i }));
+ await user.click(await screen.findByRole('button', { name: /export markdown/i }));
expect(URL.createObjectURL).toHaveBeenCalled();
});
@@ -647,8 +652,10 @@ describe('Project header and sharing', () => {
});
vi.stubGlobal('fetch', fetchMock);
const user = await loadChapter();
- await user.click(screen.getByRole('button', { name: /share/i }));
- await user.click(screen.getByRole('button', { name: /enable sharing/i }));
+ addChunk('Paul, a servant', 'Paul, a servant');
+ await screen.findByText('1 created');
+ await user.click(screen.getByRole('button', { name: /^export$/i }));
+ await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
});
@@ -675,6 +682,7 @@ describe('Study editor', () => {
test('study-page messages now show as a status toast', async () => {
await goToStudyWith3Chunks();
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
+ fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
});
@@ -689,6 +697,7 @@ describe('Study editor', () => {
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
await goToStudyWith3Chunks();
+ fireEvent.click(screen.getByRole('tab', { name: /cross-refs/i }));
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
fireEvent.keyDown(input, { key: 'Enter' });
@@ -700,6 +709,7 @@ describe('Study editor', () => {
test('a tag can be added with Enter', async () => {
await goToStudyWith3Chunks();
+ fireEvent.click(screen.getByRole('button', { name: /^more/i }));
const input = screen.getByPlaceholderText(/add a tag/i);
fireEvent.change(input, { target: { value: 'grace' } });
fireEvent.keyDown(input, { key: 'Enter' });
@@ -722,49 +732,152 @@ describe('Chunk builder', () => {
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
const user = await loadChapter();
- expect(await addSecondChapter(user)).toHaveClass('bg-slate-900');
+ expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
});
- test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => {
+ test('the Outline stage reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter();
await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i }));
- await screen.findByText(/chunk editor/i);
- await user.click(screen.getByRole('button', { name: /back to chunks/i }));
- expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900');
+ await findChunkCounter(1, 1);
+ await user.click(screen.getByRole('button', { name: /^outline$/i }));
+ expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
});
});
// ---------------------------------------------------------------------------
-// Header redesign: Tools menu + one-dropdown-at-a-time
+// Stage bar (Outline / Study / Script / Export)
// ---------------------------------------------------------------------------
-describe('Header menus', () => {
- test('Tools is disabled until a chunk exists, then opens the Pronunciation Guide', async () => {
- const user = await loadChapter();
- expect(screen.getByRole('button', { name: /tools/i })).toBeDisabled();
+describe('Stage bar', () => {
+ test('later stages are disabled until a chunk exists', async () => {
+ await loadChapter();
+ for (const name of [/^study$/i, /^script$/i, /^export$/i]) {
+ expect(screen.getByRole('button', { name })).toBeDisabled();
+ }
+ expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
- await user.click(screen.getByRole('button', { name: /tools/i }));
- await user.click(screen.getByRole('button', { name: /pronunciation guide/i }));
- expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
});
- test('opening one dropdown closes the other; Escape and outside clicks close it', async () => {
+ test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => {
const user = await loadChapter();
- await user.click(screen.getByRole('button', { name: /export/i }));
- expect(screen.getByRole('button', { name: /export markdown/i })).toBeInTheDocument();
+ addChunk('Paul, a servant', 'Paul, a servant');
+ await screen.findByText('1 created');
+ await user.click(screen.getByRole('button', { name: /^script$/i }));
+ await user.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
+ expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /back to script/i }));
+ expect(await screen.findByLabelText(/final script/i)).toBeInTheDocument();
+ });
- await user.click(screen.getByRole('button', { name: /share/i }));
- expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument();
- expect(screen.getByText(/read-only share link/i)).toBeInTheDocument();
+ test('a script written in the Script stage is kept on the chunk', async () => {
+ const user = await loadChapter();
+ addChunk('Paul, a servant', 'Paul, a servant');
+ await screen.findByText('1 created');
+ await user.click(screen.getByRole('button', { name: /^script$/i }));
+ const script = await screen.findByLabelText(/final script/i);
+ fireEvent.change(script, { target: { value: 'Welcome to the episode.' } });
+ await user.click(screen.getByRole('button', { name: /^study$/i }));
+ await user.click(screen.getByRole('button', { name: /^script$/i }));
+ expect(await screen.findByDisplayValue('Welcome to the episode.')).toBeInTheDocument();
+ });
+});
- await user.keyboard('{Escape}');
- expect(screen.queryByText(/read-only share link/i)).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: /export/i }));
- await user.click(screen.getByText(/scripture & chunks/i));
- expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument();
+// ---------------------------------------------------------------------------
+// Study workspace: outline editing, notes accordion, and the resource tabs
+// ---------------------------------------------------------------------------
+describe('Study workspace', () => {
+ const okJson = (data) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), headers: { get: () => 'application/json' } });
+ // Wraps the default mock so individual tests can answer specific URLs.
+ const withRoutes = (routes) => {
+ const base = buildFetchMock();
+ return vi.fn((url, opts) => {
+ for (const [needle, data] of routes) if (String(url).includes(needle)) return okJson(data);
+ return base(url, opts);
+ });
+ };
+ async function studyOneChunk(routes = []) {
+ if (routes.length) vi.stubGlobal('fetch', withRoutes(routes));
+ const user = await loadChapter();
+ addChunk('Paul, a servant', 'In hope of eternal');
+ await screen.findByText('1 created');
+ await user.click(screen.getByRole('button', { name: /begin studying/i }));
+ await findChunkCounter(1, 1);
+ return user;
+ }
+
+ test('outline: clicking between verses ends a chunk; clicking again joins them', async () => {
+ const user = await loadChapter();
+ await user.click(screen.getByRole('button', { name: /end a chunk after verse 1/i }));
+ await screen.findByText('1 created');
+ await user.click(screen.getByRole('button', { name: /end a chunk after verse 2/i }));
+ await screen.findByText('2 created');
+ await user.click(screen.getByRole('button', { name: /join chunks at verse 1/i }));
+ await screen.findByText('1 created');
+ expect((await screen.findAllByText(/1[-–]2/)).length).toBeGreaterThan(0);
+ });
+
+ test('outline: "Suggest from headings" fills the chapter, and never touches existing chunks', async () => {
+ const user = await loadChapter();
+ addChunk('Paul, a servant', 'Paul, a servant');
+ await screen.findByText('1 created');
+ await user.click(screen.getByRole('button', { name: /suggest from headings/i }));
+ await screen.findByText('2 created');
+ expect(screen.getByRole('status')).toHaveTextContent(/added 1 chunk/i);
+ });
+
+ test('notes: one section is open at a time, and text is kept when switching', async () => {
+ const user = await studyOneChunk();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul calls himself a servant.' } });
+ await user.click(screen.getByRole('button', { name: /interpret/i }));
+ expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /observe/i }));
+ expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Paul calls himself a servant.');
+ });
+
+ test('notes: the outline dots and word count reflect what you wrote', async () => {
+ await studyOneChunk();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'one two three' } });
+ expect(screen.getByText('3 words')).toBeInTheDocument();
+ });
+
+ test('interlinear: clicking an original word adds it to the word study', async () => {
+ const user = await studyOneChunk([['/interlinear/TIT.json', { 1: { 1: [{ o: 'Παῦλος', t: 'Paulos', g: 'Paul', s: 'G3972' }] } }]]);
+ await user.click(await screen.findByRole('button', { name: '1' })); // verse number opens its original words
+ await user.click(await screen.findByRole('button', { name: /Παῦλος/ }));
+ expect(await screen.findByRole('status')).toHaveTextContent(/added/i);
+ expect(screen.getByRole('tab', { name: /word study · 1/i })).toBeInTheDocument();
+ expect(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Παῦλος/ })).toBeInTheDocument(); // the new card
+ });
+
+ test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
+ const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
+ await user.click(await screen.findByRole('button', { name: /add to notes/i }));
+ expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
+ await user.click(screen.getByRole('button', { name: /^more/i }));
+ expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
+ });
+
+ test('people & places: lists what is tagged in the chunk', async () => {
+ const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
+ await user.click(screen.getByRole('tab', { name: /people & places/i }));
+ const panel = screen.getByRole('tabpanel');
+ expect(await within(panel).findByRole('button', { name: /paul/i })).toBeInTheDocument();
+ expect(within(panel).queryByText('Elsewhere')).not.toBeInTheDocument();
+ });
+
+ test('script: "Build outline from notes" starts the script from your notes', async () => {
+ const user = await studyOneChunk();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul greets Titus.' } });
+ await user.click(screen.getByRole('button', { name: /^script$/i }));
+ await user.click(await screen.findByRole('button', { name: /build outline from notes/i }));
+ const script = screen.getByLabelText(/final script/i);
+ expect(script.value).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.');
+ expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument();
});
});
diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx
index 5cfc3d7..3dcb178 100644
--- a/src/components/AudioPlayer.jsx
+++ b/src/components/AudioPlayer.jsx
@@ -77,11 +77,11 @@ export default function AudioPlayer({
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
-
+
-
-
{title}
-
{subtitle}
+
+
{title}
+
{subtitle}
{status === 'error' && errorMessage && (
{errorMessage}
)}
@@ -99,11 +99,11 @@ export default function AudioPlayer({
value={Math.min(currentTime, duration || 0)}
onChange={(e) => onSeekTo?.(Number(e.target.value))}
disabled={!duration}
- className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-sky-600 disabled:cursor-default"
- style={{ background: `linear-gradient(to right, rgb(2 132 199) ${progressPct}%, rgb(226 232 240) ${progressPct}%)` }}
+ className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default"
+ style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }}
aria-label="Seek"
/>
-
+
{formatTime(currentTime)}
{formatTime(duration)}
@@ -118,7 +118,7 @@ export default function AudioPlayer({
onClick={onSeekBack}
title="Rewind 10 seconds"
aria-label="Rewind 10 seconds"
- className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
+ className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
@@ -126,7 +126,7 @@ export default function AudioPlayer({
type="button"
onClick={onTogglePause}
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
- className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500"
+ className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
>
{status === 'paused' ?
:
}
@@ -135,7 +135,7 @@ export default function AudioPlayer({
onClick={onSeekForward}
title="Fast-forward 10 seconds"
aria-label="Fast-forward 10 seconds"
- className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
+ className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
@@ -146,7 +146,7 @@ export default function AudioPlayer({
disabled={nextDisabled}
title="Next chapter"
aria-label="Next chapter"
- className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100 disabled:opacity-30 disabled:hover:bg-transparent"
+ className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent"
>
@@ -156,7 +156,7 @@ export default function AudioPlayer({
onClick={onStop}
title="Stop"
aria-label="Stop"
- className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
+ className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
@@ -165,7 +165,7 @@ export default function AudioPlayer({