From e9c8bfd20434957233c3f02bb5a6226c8fe13862 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 01:36:45 +0200 Subject: [PATCH] feat: session rail with excerpts, and articles that read as one page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quiz runner — session rail The desktop sidebar was `display: none` in the player theme, so the only way to move between questions was a dropdown that showed numbers and nothing else. It is now the left rail a Qbank session has: one row per question with its number, an excerpt of the stem, its difficulty, and a mark for bookmarked ones. The active row is marked with aria-current, answered rows turn green, and the rail sticks while the question scrolls. Below 1150px there is no room for both, so the topbar dropdown takes over as before. Article pages — read straight through Each section opened in a modal overlay, which meant a topic could not be read end to end: you opened a section, read it, closed it, opened the next. Sections now render inline as one page under a sticky contents rail, with the last-edited date, matching how the reference reads. Deep links still work — `?section=` now scrolls to and highlights the section instead of trapping the reader in a dialog, and the rest of the article stays visible around it. `scrollIntoView` is called defensively: it does not exist in every environment. Tests: 2 new runner tests (rail lists number, excerpt and difficulty; navigates and follows the active row; marks answered after a study response is submitted). Stem assertions are now scoped to the question card, since the rail repeats each stem as an excerpt. Article tests assert sections are inline and no dialog opens. Full suites green: 96 backend, 135 frontend, build clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m --- frontend/src/pages/ArticlesPage.css | 29 +++++++++++++ frontend/src/pages/ArticlesPage.jsx | 42 +++++++++++------- frontend/src/pages/ArticlesPage.test.jsx | 20 +++++---- frontend/src/pages/QuizPage.jsx | 35 ++++++++++++--- frontend/src/pages/QuizPage.test.jsx | 54 ++++++++++++++++++++---- frontend/src/pages/QuizPlayer.css | 34 ++++++++++++++- 6 files changed, 174 insertions(+), 40 deletions(-) diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index c6b0107..0131a1d 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -27,6 +27,10 @@ .article-content img { max-width: 100%; border-radius: 8px; } .article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; } .article-content td, .article-content th { border: 1px solid var(--border); padding: 6px 10px; } +.article-updated { font-size: .76rem; color: var(--text-subtle); margin: 0 0 10px; } +.article-section { scroll-margin-top: 84px; padding-top: 6px; margin-top: 26px; border-top: 1px solid var(--border); } +.article-section > h2 { margin: 16px 0 8px; font-size: 1.15rem; } +.article-section.is-target > h2 { box-shadow: -10px 0 0 var(--primary); } .article-summary { font-size: .95rem; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 10px; margin: 0 0 16px; } .article-drawer-toggle { display: none; margin-bottom: 10px; } .article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; } @@ -61,6 +65,26 @@ .comment-content p { margin: 0 0 4px; } .comment-actions { display: flex; gap: 6px; margin-top: 8px; } .comment-load-more { margin-top: 10px; } +.comment-section { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; } +.comment-heading { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; } +.comment-heading h3 { margin: 0; font-size: 1rem; } +.comment-subtitle { font-size: .74rem; color: var(--text-muted); } +.comment-compose { background: var(--input-bg); border: 1px solid var(--border); border-radius: 10px; padding: 10px; margin-bottom: 14px; } +.comment-input { width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: .88rem; resize: vertical; } +.comment-compose-footer { display: flex; align-items: center; gap: 10px; margin-top: 8px; } +.comment-count { margin-left: auto; font-size: .72rem; color: var(--text-muted); } +.comment-empty { color: var(--text-muted); font-size: .84rem; margin: 6px 0 0; } +.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.comment { display: flex; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); } +.comment-avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 700; display: flex; align-items: center; justify-content: center; font-size: .9rem; flex-shrink: 0; } +.comment-body { flex: 1; min-width: 0; } +.comment-meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; font-size: .84rem; } +.comment-meta span { color: var(--text-muted); font-size: .74rem; } +.comment-badge { font-style: normal; font-size: .66rem; font-weight: 700; text-transform: uppercase; color: #92400e; background: #fef3c7; padding: 1px 8px; border-radius: 10px; } +.comment-content { font-size: .88rem; } +.comment-content p { margin: 0 0 4px; } +.comment-actions { display: flex; gap: 6px; margin-top: 8px; } +.comment-load-more { margin-top: 10px; } .article-section-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35); z-index: 1050; display: flex; justify-content: flex-end; } .article-section-panel { background: var(--card-bg); width: min(560px, 92vw); height: 100%; display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,0.18); animation: article-slide-in .18s ease; } @keyframes article-slide-in { from { transform: translateX(24px); opacity: 0; } to { transform: none; opacity: 1; } } @@ -108,3 +132,8 @@ .article-practise-controls label { flex: 1; } .article-practise-controls .btn { width: 100%; } } + +@media (max-width: 640px) { + .article-section { margin-top: 20px; } + .article-section > h2 { font-size: 1.06rem; } +} diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index dc8d119..711dee9 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -193,11 +193,22 @@ export function ArticlePage() { useEffect(() => { load() }, [load]) - const section = article?.sections?.find(s => s.id === activeSection) + // Deep link: scroll to the section once it has rendered. + useEffect(() => { + if (!activeSection || !article) return + const target = document.getElementById(`section-${activeSection}`) + target?.scrollIntoView?.({ block: 'start' }) + }, [activeSection, article]) + /** Jump to a section in the page and record it in the URL for deep links. */ const openSection = (secId) => { setActiveSection(secId) setSearchParams(secId ? { section: secId } : {}) + setDrawerOpen(false) + if (secId) { + const target = document.getElementById(`section-${secId}`) + target?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }) + } } const save = async (publish = null) => { @@ -363,11 +374,26 @@ export function ArticlePage() {
+ {article.updated_at && ( +

Last edited {new Date(article.updated_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}

+ )} {article.summary &&

{article.summary}

} {article.content && {article.content}} {!article.sections?.length && (!article.summary && !article.content) && (
Content is being prepared by educators.
)} + + {/* Sections read straight through, as one page. Opening each in a + modal made a topic impossible to read end to end. */} + {(article.sections || []).map(sec => ( +
+

{sec.title}

+ {sec.content} +
+ ))} + {cards.length > 0 && (
@@ -382,20 +408,6 @@ export function ArticlePage() { )}
- {section && ( -
-
-
-

{section.title}

- -
-
{section.content}
-
- openSection('')}>← {article.title} -
-
-
- )} )} diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx index f88e114..de07314 100644 --- a/frontend/src/pages/ArticlesPage.test.jsx +++ b/frontend/src/pages/ArticlesPage.test.jsx @@ -44,13 +44,15 @@ describe('topic reading', () => { expect(await screen.findByRole('heading', { name: 'Febrile seizures' })).toBeInTheDocument() expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology') expect(screen.getByText('Introduction markdown')).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Initial workup' })) - // The section opens in a closable overlay while the main article stays visible. - const overlay = await screen.findByRole('dialog', { name: 'Section: Initial workup' }) - expect(within(overlay).getByText('Section markdown')).toBeInTheDocument() + // Sections read inline as one page, so the body is present without opening anything. + expect(screen.getByRole('heading', { name: 'Initial workup' })).toBeInTheDocument() + expect(screen.getByText('Section markdown')).toBeInTheDocument() expect(screen.getByText('Introduction markdown')).toBeInTheDocument() - await userEvent.click(within(overlay).getByRole('button', { name: 'Close section' })) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + // The contents rail jumps to a section and marks it active. + await userEvent.click(screen.getByRole('button', { name: 'Initial workup' })) + expect(screen.getByRole('button', { name: 'Initial workup' })).toHaveClass('active') // Reading a topic offers a test; it never prints the stem, answer or explanation. expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument() expect(screen.queryByText('Linked question text')).not.toBeInTheDocument() @@ -90,9 +92,11 @@ describe('topic reading', () => { return Promise.resolve({ data: [] }) }) render(} />) - const overlay = await screen.findByRole('dialog', { name: 'Section: Second section' }) - expect(within(overlay).getByText('Second body')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Second section' })).toHaveClass('active') + // A deep link marks the section without hiding the rest of the article. + expect(await screen.findByRole('button', { name: 'Second section' })).toHaveClass('active') + expect(screen.getByText('Second body')).toBeInTheDocument() + expect(screen.getByText('First body')).toBeInTheDocument() expect(screen.getByText('Introduction markdown')).toBeInTheDocument() + expect(document.querySelector(`#section-${'b'.repeat(32)}`)).toHaveClass('is-target') }) }) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 106ef61..e32011c 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -939,6 +939,29 @@ const timerStarted = timeLeft !== null } } + const QuestionRailItem = ({ q, i }) => { + const isActive = i === currentIdx + const isDone = !!answers[q.id] + const marked = favorites.includes(q.id) + const excerpt = questionStem(q).replace(/\s+/g, ' ').trim() + return ( + + ) + } + const QuestionDot = ({ q, i }) => { const isActive = i === currentIdx const isDone = !!answers[q.id] @@ -1326,13 +1349,11 @@ const timerStarted = timeLeft !== null )} - {/* Desktop sidebar */} -
-
- Questions -
-
- {questions.map((q, i) => )} + {/* Desktop rail — numbers with an excerpt, as in a Qbank session */} +
+
Session questions
+
+ {questions.map((q, i) => )}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index ef780bc..9948255 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -50,13 +50,51 @@ beforeEach(() => { function mount(entry = '/quizzes/10') { render(} />Submitted results
} />) } +/** The session rail repeats each stem as an excerpt, so stem lookups are + * scoped to the question card to stay unambiguous. */ +const inCard = () => within(document.querySelector('.question-card')) +const findStem = async (text) => { + await waitFor(() => expect(document.querySelector('.question-card')).toBeInTheDocument()) + return waitFor(() => inCard().getByText(text)) +} + async function begin(study = true) { quizModeVar = study ? 'learning' : 'timed' mount() - await screen.findByText('Full first clinical question.') + await findStem('Full first clinical question.') } describe('quiz player', () => { + it('lists every question in the rail with a number, excerpt and difficulty', async () => { + await begin() + const rail = document.querySelector('.quiz-rail-list') + expect(rail).toBeInTheDocument() + + const items = rail.querySelectorAll('.quiz-rail-item') + expect(items).toHaveLength(2) + expect(within(items[0]).getByText('Question 1')).toBeInTheDocument() + expect(within(items[0]).getByText('Full first clinical question.')).toBeInTheDocument() + expect(within(items[0]).getByText('hard')).toBeInTheDocument() + expect(items[0]).toHaveAttribute('aria-current', 'true') + expect(items[1]).not.toHaveAttribute('aria-current') + + // The rail navigates, and the active row follows. + await userEvent.click(items[1]) + await findStem('Full second clinical question.') + const after = document.querySelectorAll('.quiz-rail-item') + expect(after[1]).toHaveAttribute('aria-current', 'true') + }) + + it('marks answered questions in the rail', async () => { + await begin() + const before = document.querySelectorAll('.quiz-rail-item') + expect(before[0].className).not.toMatch(/is-done/) + await userEvent.click(inCard().getByText('First answer').closest('.option')) + // Study mode holds a draft until it is submitted, so the rail marks it then. + await userEvent.click(screen.getByRole('button', { name: 'Submit response' })) + await waitFor(() => expect(document.querySelectorAll('.quiz-rail-item')[0].className).toMatch(/is-done/)) + }) + it('keeps question metadata on one compact strip with a single action bar', async () => { await begin() // The category trail used to be a tall wrapping block above the stem. @@ -106,7 +144,7 @@ describe('quiz player', () => { expect(api.post).not.toHaveBeenCalled() await userEvent.click(screen.getByRole('button', { name: 'Retry resume' })) // Retry loads the quiz and starts it straight away in its own mode — no re-ask. - expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument() + expect(await findStem('Full first clinical question.')).toBeInTheDocument() expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') }) @@ -132,7 +170,7 @@ describe('quiz player', () => { }) mount('/quizzes/10?return_to=%2Fcourses%2F1') await userEvent.click(await screen.findByRole('button', { name: 'Begin Quiz' })) - await screen.findByText('Full first clinical question.') + await findStem('Full first clinical question.') expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() }) @@ -147,7 +185,7 @@ describe('quiz player', () => { await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' })) await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' })) expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open') - expect(screen.getByText('Full first clinical question.')).toBeInTheDocument() + expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument() failSaving = false await userEvent.click(screen.getByRole('button', { name: 'Retry saving' })) await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) @@ -181,7 +219,7 @@ describe('quiz player', () => { mount() expect(await screen.findByText(/Time expired while you were away/)).toBeInTheDocument() expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false) - expect(screen.getByText('Full first clinical question.')).toBeInTheDocument() + expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument() }) it('shows per-option explanations in study feedback behind a toggle', async () => { @@ -281,7 +319,7 @@ describe('quiz player', () => { const review = screen.getByRole('dialog', { name: 'Review & Complete' }) expect(within(review).getByText(/1 of 2 questions answered/)).toBeInTheDocument() await userEvent.click(within(review).getByRole('button', { name: '2 · Unanswered' })) - await screen.findByText('Full second clinical question.') + await findStem('Full second clinical question.') expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false) await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Complete test' })) @@ -291,7 +329,7 @@ describe('quiz player', () => { it('starts timed quizzes in exam mode without a mode prompt', async () => { mount() - expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument() + expect(await findStem('Full first clinical question.')).toBeInTheDocument() expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') expect(screen.queryByRole('button', { name: /Study Mode|Exam Mode/ })).not.toBeInTheDocument() }) @@ -323,7 +361,7 @@ describe('quiz player', () => { return originalPost(url, ...args) }) mount() - await screen.findByText('Full first clinical question.') + await findStem('Full first clinical question.') await screen.findByRole('button', { name: 'Make shareable' }) await userEvent.click(screen.getByRole('button', { name: 'Make shareable' })) await screen.findByRole('button', { name: 'Copy share link' }) diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index 008431e..3f6f7a4 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -4,8 +4,38 @@ .quiz-player .quiz-header-title { font-size: .9rem; font-weight: 500; color: #737982; } .quiz-player .quiz-code-badge { font-size: .75rem; } .quiz-player .quiz-code-badge code { background: transparent; border: 0; } -.quiz-player .quiz-layout { display: block; } -.quiz-player .quiz-sidebar { display: none; } +/* Session rail beside the question, like a Qbank session's left column. + Below 1150px there is not room for both, so the topbar dropdown takes over. */ +.quiz-player .quiz-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 28px; align-items: start; } +.quiz-player .quiz-sidebar { display: block; order: -1; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow-y: auto; } +.quiz-rail-head { font-size: .68rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: #8b929c; padding: 0 0 8px; } +.quiz-rail-list { display: flex; flex-direction: column; } +.quiz-rail-item { + display: flex; gap: 10px; align-items: flex-start; width: 100%; + padding: 9px 8px; background: none; border: 0; border-left: 3px solid transparent; + font: inherit; text-align: left; cursor: pointer; color: #4a5058; +} +.quiz-rail-item:hover { background: #f4f6fb; } +.quiz-rail-item.is-active { background: #eaf0fa; border-left-color: #496fa5; color: #253038; } +.quiz-rail-num { + flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; position: relative; + font-size: .76rem; font-weight: 650; background: #edeef2; color: #6f767f; +} +.quiz-rail-item.is-done .quiz-rail-num { background: #dff0e8; color: #327b64; } +.quiz-rail-item.is-active .quiz-rail-num { background: #496fa5; color: #fff; } +.quiz-rail-mark { position: absolute; top: -4px; right: -4px; font-size: .6rem; color: #d99a2b; } +.quiz-rail-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.quiz-rail-label { font-size: .68rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #98a0aa; } +.quiz-rail-text { font-size: .78rem; line-height: 1.4; overflow-wrap: anywhere; } +.quiz-rail-diff { align-self: flex-start; font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; border-radius: 10px; padding: 1px 7px; margin-top: 2px; } +.quiz-rail-diff.is-easy { background: #eef7f3; color: #327b64; } +.quiz-rail-diff.is-medium { background: #fdf6e8; color: #8a6417; } +.quiz-rail-diff.is-hard { background: #fbecf0; color: #a13c51; } +@media (max-width: 1150px) { + .quiz-player .quiz-layout { display: block; } + .quiz-player .quiz-sidebar { display: none; } +} .quiz-player .quiz-nav-toggle { display: inline-flex; } .quiz-player .progress-bar { height: 3px; } .quiz-topbar { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 10px 0 20px; margin-bottom: 0; border-bottom: 1px solid var(--border); }