From bbbb581af19be7d681879dd0476a51dd727929db Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:38:26 -0400 Subject: [PATCH] Study page: chunk list as a slide-over card, notes and resources as tabs in one column - Chunks: a tab on the left edge opens a card over the passage; choosing a chunk, Esc, or clicking the passage closes it (state remembered) - Right column: Notes is the first tab, followed by the resource tabs; the separate notes and resources boxes and their fold state are gone - Column can be dragged wider (up to 960px) - Pop-out icon opens the whole column in a large window (PopOut) - Tests updated for the tabbed layout; new tests for the chunk card and pop-out Co-Authored-By: Claude Sonnet 5.5 --- src/App.flow.test.jsx | 2 +- src/components/study/PopOut.jsx | 21 ++++ src/components/study/ResourcePane.jsx | 13 ++- src/pages/StudyPage.jsx | 137 ++++++++++++++------------ src/test/App.home.test.jsx | 47 +++++---- src/test/App.study.test.jsx | 26 +++++ 6 files changed, 158 insertions(+), 88 deletions(-) create mode 100644 src/components/study/PopOut.jsx diff --git a/src/App.flow.test.jsx b/src/App.flow.test.jsx index aaa89fc..c3822e2 100644 --- a/src/App.flow.test.jsx +++ b/src/App.flow.test.jsx @@ -115,7 +115,7 @@ describe('Search palette (⌘K)', () => { await user.type(await paletteBox(), 'abounds'); const hit = await screen.findByRole('option', { name: /Titus 1:1/ }); await user.click(hit); - expect(await screen.findByText(/Your notes/i)).toBeInTheDocument(); + expect(await screen.findByRole('textbox', { name: 'Observe' })).toBeInTheDocument(); }); test('Escape closes it', async () => { diff --git a/src/components/study/PopOut.jsx b/src/components/study/PopOut.jsx new file mode 100644 index 0000000..77e4aad --- /dev/null +++ b/src/components/study/PopOut.jsx @@ -0,0 +1,21 @@ +// A section of the Study page lifted into a large window over the page, so there's room to read commentary or +// write notes without the column's cramped width. Esc, the backdrop or Close puts it back. +import { useEffect, useRef } from 'react'; + +export default function PopOut({ title, onClose, children }) { + const panel = useRef(null); + useEffect(() => { panel.current?.focus(); }, []); + return ( +
+ + ); +} diff --git a/src/components/study/ResourcePane.jsx b/src/components/study/ResourcePane.jsx index b79868e..744c317 100644 --- a/src/components/study/ResourcePane.jsx +++ b/src/components/study/ResourcePane.jsx @@ -19,9 +19,11 @@ export const RESOURCE_TABS = [ { id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key ]; -export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) { +// leadTab: { id, label, node } shown as the first tab, in place of the resources, when it is the selected one (the Study page +// puts the notes there, so notes and resources share one column). +export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId, leadTab }) { const iq = useIqAvailable(); - const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq); + const tabs = [...(leadTab ? [leadTab] : []), ...RESOURCE_TABS.filter((t) => !t.needsIq || iq)]; if (tab === 'aids' && !iq) tab = 'commentary'; // Commentary-type tabs wait for your own Observe notes (per chunk, until you choose to look anyway). const [unlocked, setUnlocked] = useState(() => new Set()); @@ -37,7 +39,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length }; return (
-
+
{tabs.map((t) => (
+ {leadTab && tab === leadTab.id && leadTab.node} {held && (

Observe first

@@ -68,8 +71,8 @@ export function useResourceTab() { const [tab, setTabState] = useState(() => { try { const saved = localStorage.getItem('studyResourceTab'); - return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary'; - } catch { return 'commentary'; } + return saved === 'notes' || RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'notes'; + } catch { return 'notes'; } }); const setTab = (id) => { setTabState(id); diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx index f47609f..cfb9317 100644 --- a/src/pages/StudyPage.jsx +++ b/src/pages/StudyPage.jsx @@ -1,6 +1,6 @@ -// The Study stage: the chunk outline on the left, the passage and your -// Observe / Interpret / Apply notes in the middle, and commentary, word study, -// cross-references and people & places on the right — all for the selected chunk. +// The Study stage: the passage in the middle, with the chunk list as a card that slides over it from the left edge, +// and one adjustable column on the right where Notes (Observe / Interpret / Apply) and the resources (commentary, word +// study, cross-references, people & places) are tabs — all for the selected chunk. import { useEffect, useRef, useState } from 'react'; import { useApp } from '../context/AppContext.js'; import { useCrossRefs } from '../hooks/useCrossRefs.js'; @@ -12,6 +12,7 @@ import StudioShell from '../components/study/StudioShell.jsx'; import OutlineRail from '../components/study/OutlineRail.jsx'; import ScripturePane from '../components/study/ScripturePane.jsx'; import NotesPane from '../components/study/NotesPane.jsx'; +import PopOut from '../components/study/PopOut.jsx'; import { commentAuthor } from '../utils/chunkComments.js'; import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx'; import CompareSheet from '../components/study/CompareSheet.jsx'; @@ -43,30 +44,16 @@ export default function StudyPage() { // The right column's width can be dragged (remembered on this device) const gridRef = useRef(null); const rightPanelRef = useRef(null); - const rightPanel = useResizableWidth({ storageKey: 'study-right-width', containerRef: gridRef }); + const rightPanel = useResizableWidth({ storageKey: 'study-right-width', containerRef: gridRef, maxAbsolute: 960, reserve: 480 }); const [tab, setTab] = useResourceTab(); const [openWordId, setOpenWordId] = useState(null); const [comparing, setComparing] = useState(false); - // Either half of the right column can be folded away so the other fills it (remembered on this device). - const [folded, setFolded] = useState(() => { - try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; } - }); + const [popped, setPopped] = useState(false); // the notes-and-resources column lifted into a large window const [focusRequest, setFocusRequest] = useState(null); - const showHalf = (which) => setFolded((cur) => { - if (!cur[which]) return cur; - const next = { ...cur, [which]: false }; - try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ } - return next; - }); - const toggleFold = (which) => setFolded((cur) => { - const other = which === 'notes' ? 'resources' : 'notes'; - // folding the last open half opens the other one instead, so the column is never empty - const next = { ...cur, [which]: !cur[which] }; - if (next[which] && next[other]) next[other] = false; - try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ } - return next; - }); + // The chunk list is a card that slides over the passage (remembered on this device). + const [chunksOpen, setChunksOpenState] = useState(() => { try { return localStorage.getItem('study-chunks-open') === 'on'; } catch { return false; } }); + const setChunksOpen = (open) => { setChunksOpenState(open); try { localStorage.setItem('study-chunks-open', open ? 'on' : 'off'); } catch { /* storage unavailable */ } }; useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]); const resources = useStudyResources({ @@ -77,9 +64,10 @@ export default function StudyPage() { const crossRefs = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify }); const wordStudy = useWordStudy({ project, allChunks, updateProject, notify }); - const select = (id) => updateProject((current) => ({ ...current, selectedChunkId: id })); - const goPrev = () => { if (globalIndex > 0) select(allChunks[globalIndex - 1].id); }; - const goNext = () => { if (globalIndex >= 0 && globalIndex < allChunks.length - 1) select(allChunks[globalIndex + 1].id); }; + const select = (id) => { updateProject((current) => ({ ...current, selectedChunkId: id })); setChunksOpen(false); }; + const step = (to) => updateProject((current) => ({ ...current, selectedChunkId: to })); // prev/next keep the card as it is + const goPrev = () => { if (globalIndex > 0) step(allChunks[globalIndex - 1].id); }; + const goNext = () => { if (globalIndex >= 0 && globalIndex < allChunks.length - 1) step(allChunks[globalIndex + 1].id); }; // Arrow keys move between chunks; Escape closes the word picker useEffect(() => { @@ -87,15 +75,17 @@ export default function StudyPage() { const tag = event.target?.tagName; const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable; if (event.key === 'Escape') { + if (popped && !wordStudy.suggestModal) { setPopped(false); return; } + if (chunksOpen && !wordStudy.suggestModal && !editable) { setChunksOpen(false); return; } if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); } else if (editable && !comparing) event.target.blur(); // leave a note box and go back to keyboard moves return; } - if (editable || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return; + if (editable || popped || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return; if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); } else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); } - else if (NOTE_KEYS[event.key]) { event.preventDefault(); showHalf('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); } - else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); showHalf('resources'); setTab(TAB_KEYS[event.key.toLowerCase()]); } + else if (NOTE_KEYS[event.key]) { event.preventDefault(); setTab('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); } + else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); setTab(TAB_KEYS[event.key.toLowerCase()]); } else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); } }; window.addEventListener('keydown', onKey); @@ -113,15 +103,40 @@ export default function StudyPage() { if (id) { setTab('words'); setOpenWordId(id); } }; + const notesPane = selectedChunk && chapter && ( + startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} /> + ); + // Notes and the resources share one column as tabs; Notes is the first. + const workPane = selectedChunk && chapter && ( + + ); + const chunkPosition = selectedChunk ? `${globalIndex + 1}/${allChunks.length}` : ''; + return (
- +
-
+
{ if (chunksOpen) setChunksOpen(false); }}> {selectedChunk && chapter ? (
@@ -154,48 +169,24 @@ export default function StudyPage() { )}
-
+ {popped && selectedChunk && chapter && ( + setPopped(false)}>{workPane} + )} {comparing && selectedChunk && setComparing(false)} />} {statusMessage && ( @@ -206,3 +197,21 @@ export default function StudyPage() { ); } + +function PopButton({ label, onClick }) { + return ( + + ); +} + +function PoppedNote({ onBack }) { + return ( +
+

Your notes and resources are open in a larger window.

+ +
+ ); +} diff --git a/src/test/App.home.test.jsx b/src/test/App.home.test.jsx index c5ba525..35298ec 100644 --- a/src/test/App.home.test.jsx +++ b/src/test/App.home.test.jsx @@ -419,33 +419,47 @@ describe('Study workspace', () => { 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(screen.getByRole('tab', { name: /commentary/i })); expect(screen.getByText('Observe first')).toBeInTheDocument(); // commentary waits for your own notes await user.click(screen.getByRole('button', { name: /show it anyway/i })); 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('tab', { name: /^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('commentary opens once you have written enough of your own Observe notes', async () => { - await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]); + 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(screen.getByRole('tab', { name: /commentary/i })); expect(screen.getByText('Observe first')).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: /^notes/i })); fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: Array.from({ length: 15 }, (_, i) => `word${i}`).join(' ') } }); + await user.click(screen.getByRole('tab', { name: /commentary/i })); expect(screen.queryByText('Observe first')).not.toBeInTheDocument(); expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument(); }); - test('either half of the right column can be folded, and the other always stays open', async () => { + test('notes and resources share one column as tabs, with Notes first and open', async () => { const user = await studyOneChunk(); - const notes = screen.getByRole('button', { name: /your notes/i }); - const resources = screen.getByRole('button', { name: /commentary & resources/i }); - expect(notes).toHaveAttribute('aria-expanded', 'true'); - await user.click(notes); - expect(notes).toHaveAttribute('aria-expanded', 'false'); - expect(resources).toHaveAttribute('aria-expanded', 'true'); - await user.click(resources); // folding the last open half opens the other one - expect(resources).toHaveAttribute('aria-expanded', 'false'); - expect(notes).toHaveAttribute('aria-expanded', 'true'); + const tabs = screen.getAllByRole('tab').map((t) => t.textContent.replace(/\s·\s\d+$/, '')); + expect(tabs.slice(0, 3)).toEqual(['Notes', 'Commentary', 'Word study']); + expect(screen.getByRole('tab', { name: /^notes/i })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('textbox', { name: 'Observe' })).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: /word study/i })); + expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: /^notes/i })); + expect(screen.getByRole('textbox', { name: 'Observe' })).toBeInTheDocument(); + }); + + test('the chunk list is a card on the left edge that opens and closes', async () => { + const user = await studyOneChunk(); + const toggle = screen.getByRole('button', { name: /^chunks · 1\/1$/i }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await user.keyboard('{Escape}'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); }); test('a long paste asks if it is from AI; "I wrote it" takes the tag back', async () => { @@ -578,18 +592,15 @@ describe('App bar', () => { }); describe('Study layout', () => { - test('notes sit in their own box above the resources, not under the passage', async () => { + test('notes are the first tab of the right column, 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(); + const column = screen.getByRole('region', { name: 'Notes and resources panel' }); + expect(within(column).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument(); + expect(within(column).getAllByRole('tab')[0]).toHaveTextContent('Notes'); // and the passage column no longer holds them expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument(); }); diff --git a/src/test/App.study.test.jsx b/src/test/App.study.test.jsx index edefa7d..ddc6d72 100644 --- a/src/test/App.study.test.jsx +++ b/src/test/App.study.test.jsx @@ -249,3 +249,29 @@ describe('Greek word lookup', () => { }); }); + +describe('Pop-out window on the Study page', () => { + test('notes and resources open in a larger window, show a placeholder in the column, and Esc brings them back', async () => { + await goToStudyWith3Chunks(); + expect(screen.getByRole('tab', { name: /word study/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /open in a larger window/i })); + const dialog = await screen.findByRole('dialog', { name: /notes and resources/i }); + expect(dialog).toContainElement(screen.getByRole('tab', { name: /word study/i })); // the tabs moved into the window + expect(screen.getAllByRole('tab', { name: /word study/i })).toHaveLength(1); + expect(screen.getByText(/are open in a larger window/i)).toBeInTheDocument(); + expect(dialog).toContainElement(screen.getByRole('textbox', { name: /observe/i })); + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(await findChunkCounter(1, 3)).toBeInTheDocument(); // chunk keys do nothing while it is open + fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog', { name: /notes and resources/i })).not.toBeInTheDocument()); + expect(screen.getByRole('tab', { name: /word study/i })).toBeInTheDocument(); + }); + + test('"Bring it back" closes the window', async () => { + await goToStudyWith3Chunks(); + fireEvent.click(screen.getByRole('button', { name: /open in a larger window/i })); + await screen.findByRole('dialog', { name: /notes and resources/i }); + fireEvent.click(screen.getByRole('button', { name: /bring it back/i })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: /notes and resources/i })).not.toBeInTheDocument()); + }); +});