feat: session rail with excerpts, and articles that read as one page

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
This commit is contained in:
Daniel 2026-09-10 01:36:45 +02:00
parent 878e61c69b
commit e9c8bfd204
6 changed files with 174 additions and 40 deletions

View file

@ -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; }
}

View file

@ -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() {
</ul>
</aside>
<main className="article-content">
{article.updated_at && (
<p className="article-updated">Last edited {new Date(article.updated_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}</p>
)}
{article.summary && <p className="article-summary">{article.summary}</p>}
{article.content && <Markdown>{article.content}</Markdown>}
{!article.sections?.length && (!article.summary && !article.content) && (
<div className="empty-state">Content is being prepared by educators.</div>
)}
{/* 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 => (
<section key={sec.id} id={`section-${sec.id}`}
className={`article-section${activeSection === sec.id ? ' is-target' : ''}`}
aria-labelledby={`heading-${sec.id}`}>
<h2 id={`heading-${sec.id}`}>{sec.title}</h2>
<Markdown>{sec.content}</Markdown>
</section>
))}
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
{cards.length > 0 && (
<div className="article-linked">
@ -382,20 +408,6 @@ export function ArticlePage() {
)}
<CommentSection articleId={article.id} />
</main>
{section && (
<div className="article-section-overlay" role="dialog" aria-label={`Section: ${section.title}`}>
<div className="article-section-panel">
<header className="article-section-panel-header">
<h2>{section.title}</h2>
<button type="button" aria-label="Close section" onClick={() => openSection('')}></button>
</header>
<div className="article-section-body"><Markdown>{section.content}</Markdown></div>
<footer className="article-section-panel-footer">
<Link to={`/articles/${article.id}`} onClick={() => openSection('')}> {article.title}</Link>
</footer>
</div>
</div>
)}
</div>
)}
</div>

View file

@ -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(<MemoryRouter initialEntries={[`/articles/1?section=${'b'.repeat(32)}`]}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
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')
})
})

View file

@ -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 (
<button type="button"
className={`quiz-rail-item${isActive ? ' is-active' : ''}${isDone ? ' is-done' : ''}`}
aria-current={isActive ? 'true' : undefined}
onClick={() => { safeNavigate(i); setNavOpen(false) }}>
<span className="quiz-rail-num">
{i + 1}
{marked && <span className="quiz-rail-mark" aria-label="Marked"></span>}
</span>
<span className="quiz-rail-body">
<span className="quiz-rail-label">Question {i + 1}</span>
<span className="quiz-rail-text">{excerpt.slice(0, 64)}{excerpt.length > 64 ? '…' : ''}</span>
{q.difficulty && <span className={`quiz-rail-diff is-${q.difficulty}`}>{q.difficulty}</span>}
</span>
</button>
)
}
const QuestionDot = ({ q, i }) => {
const isActive = i === currentIdx
const isDone = !!answers[q.id]
@ -1326,13 +1349,11 @@ const timerStarted = timeLeft !== null
)}
</div>
{/* Desktop sidebar */}
<div className="quiz-sidebar">
<div style={{ fontWeight: 700, fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: 12 }}>
Questions
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
{/* Desktop rail — numbers with an excerpt, as in a Qbank session */}
<div className="quiz-sidebar quiz-rail">
<div className="quiz-rail-head">Session questions</div>
<div className="quiz-rail-list">
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
</div>
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>

View file

@ -50,13 +50,51 @@ beforeEach(() => {
function mount(entry = '/quizzes/10') {
render(<MemoryRouter initialEntries={[entry]}><Routes><Route path="/quizzes/:id" element={<QuizPage />} /><Route path="/results/:id" element={<div>Submitted results</div>} /></Routes></MemoryRouter>)
}
/** 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' })

View file

@ -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); }