From f8a81ef937ffba8dd64024d0e8ed45c1618fb6ac Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 07:11:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20the=20question=20gets=20the=20room=20?= =?UTF-8?q?=E2=80=94=20a=20rail=20that=20closes,=20labs=20beside=20it,=20t?= =?UTF-8?q?hree=20controls=20at=20the=20foot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail is a sidebar now rather than furniture: a long stem wants the width, and the list is one press away on a handle that sits where the rail was. Remembered, because somebody who put it away meant it. Labs moved off the top toolbar onto the question's own bar, beside the attending tip, and open as a column next to the case instead of a dialog over it — a reference range is read while re-reading the stem, and a dialog covers the thing it is for. Below 1150px there is no room for a third column and it goes back to being a dialog. The calculator is the exam's alone. A study session has no clock to beat and no arithmetic to do under one; both it and the old Lab values button were in that toolbar because the exam player and the study player were one screen. And the bar at the foot is three things: leave, back, on. The question count and the list behind it are in the rail, and on a narrow screen behind the site's own menu button — repeating them crowded the one row that has to stay legible under every question. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/QuizTools.jsx | 3 +- frontend/src/pages/QuizPage.jsx | 76 +++++++++++++++++++++------ frontend/src/pages/QuizPage.test.jsx | 31 ++++++++++- frontend/src/pages/QuizPlayer.css | 37 +++++++++++++ 4 files changed, 130 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx index ea30376..675cf36 100644 --- a/frontend/src/components/QuizTools.jsx +++ b/frontend/src/components/QuizTools.jsx @@ -52,7 +52,8 @@ const labFields = [ ['article_id', 'Article ID (optional deep link)', 12], ['article_section_id', 'Article section ID (optional)', 64], ] -function LabValues() { +/** Exported so the player can dock it beside a question instead of over it. */ +export function LabValues() { const { user } = useAuth() const isEducator = ['admin', 'moderator'].includes(user?.role) const [rows, setRows] = useState([]) diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 4380f71..2f192b7 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -15,7 +15,7 @@ import FeedbackForm from '../components/FeedbackForm' import ShareSession from '../components/ShareSession' import MoreMenu from '../components/MoreMenu' import '../components/Feedback.css' -import QuizTools, { QuizDialog } from '../components/QuizTools' +import QuizTools, { QuizDialog, LabValues } from '../components/QuizTools' import './QuizPlayer.css' //: Seconds left when the block says so. Long enough to finish the question in @@ -476,6 +476,20 @@ export default function QuizPage() { const [activeReadSegment, setActiveReadSegment] = useState(null) const [manualHighlights, setManualHighlights] = useState({}) const [tool, setTool] = useState(null) + // Labs sit beside the question rather than over it: a reference range is + // read while re-reading the case, and a dialog covers the thing it is for. + const [labsOpen, setLabsOpen] = useState(false) + // The rail is a sidebar, not furniture: on a long stem the question wants + // the width, and the list is still one press away. Remembered, because + // somebody who put it away meant it. + const [railOpen, setRailOpen] = useState(() => { + try { return localStorage.getItem('pedshub.quizRail') !== 'closed' } catch { return true } + }) + + useEffect(() => { + try { localStorage.setItem('pedshub.quizRail', railOpen ? 'open' : 'closed') } + catch { /* private browsing */ } + }, [railOpen]) // Which of the per-question panels is open. One at a time: they sit in the // same place under the stem, and two at once would push the options off screen. const [panel, setPanel] = useState(null) @@ -1359,15 +1373,10 @@ const timerStarted = timeLeft !== null onClick={() => safeNavigate(Math.max(0, currentIdx - 1))} disabled={currentIdx === 0}>← Prev - {/* Only where the rail is not on screen. Beside a permanent list of - every question, a button that unfolds the same list is noise. */} - {!hasRail && ( - - )} + {/* The bar is three things: leave, back, on. The question count and the + list behind it live in the rail and, on a narrow screen, behind the + site's own menu button — repeating them here crowded the one row + that has to stay legible at the foot of every question. */} {isLast && !reviewing ? ( // The end of the block. In an exam the dialog names how many are still @@ -1691,8 +1700,16 @@ const timerStarted = timeLeft !== null )} - {/* Two-column layout: content + desktop sidebar */} -
+ {/* The question, with the rail on one side and the labs on the other — + both optional, and the question taking whatever they leave. */} +
+ {/* Rendered whenever the rail is away, and hidden by the same + breakpoint that hides the rail — the sidebar is a CSS decision, and + its handle has to be made the same way or the two disagree. */} + {!railOpen && ( + + )} {/* Main content */}
@@ -1731,8 +1748,14 @@ const timerStarted = timeLeft !== null )}
- - + {/* Both are the exam's. A study session has no clock to beat, and + the labs are on the question's own bar where the case is. */} + {!isStudy && ( + <> + + + + )} {/* An exam moves between items from the middle of this bar, and ends the block from the bar at the foot of it. Repeating either here is the same action under a second name. */} @@ -1775,6 +1798,14 @@ const timerStarted = timeLeft !== null />
+ {/* Beside the tip rather than up in the toolbar: reference + ranges are read against the case in front of you, and a + control at the top of the screen is a different place from + where the numbers are. */} + {current.attending_tip && ( +
+
+ + )} + {/* Desktop rail — numbers with an excerpt, as in a Qbank session */}
-
Session questions
+
+ Session questions + +
{questions.map((q, i) => )}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 491f476..6da6e1f 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -504,7 +504,9 @@ describe('quiz player', () => { }) it('offers a functional calculator without hijacking input shortcuts', async () => { - await begin() + // The exam's. A study session has no clock to beat and no arithmetic to + // do under one, so the toolbar it sat in is the exam's too. + await begin(false) await userEvent.click(screen.getByRole('button', { name: /Calculator/ })) const dialog = screen.getByRole('dialog', { name: 'Calculator' }) const input = within(dialog).getByLabelText('Calculation') @@ -859,3 +861,30 @@ describe('quiz player', () => { expect(within(dialog).queryByRole('button', { name: 'Create a share link' })).toBeNull() }) }) + +it('keeps the exam\'s tools out of a study session', async () => { + await begin() + for (const gone of [/Calculator/, /Lab values/]) { + expect(screen.queryByRole('button', { name: gone })).not.toBeInTheDocument() + } + // Labs are on the question's own bar instead, where the case is. + const bar = screen.getByRole('toolbar', { name: 'Question actions' }) + expect(within(bar).getByRole('button', { name: /Labs/ })).toBeInTheDocument() +}) + +it('puts the rail away and brings it back', async () => { + await begin() + expect(document.querySelector('.quiz-sidebar')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Hide the session questions' })) + // A long stem wants the width, and the list is still one press away. + expect(document.querySelector('.quiz-layout.is-rail-closed')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Show the session questions' })) + expect(document.querySelector('.quiz-layout.is-rail-closed')).toBeNull() +}) + +it('leaves three things at the foot: out, back, on', async () => { + await begin() + const bar = document.querySelector('.quiz-footbar') + const names = [...bar.querySelectorAll('button')].map(b => b.textContent.trim()) + expect(names).toEqual(['Exit session', '← Prev', 'Skip →']) +}) diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index 9233f33..084ffad 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -524,3 +524,40 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; } border: 1px solid var(--correct-bd); border-radius: 9px; } .quiz-folder-new:disabled { opacity: 0.6; cursor: default; } + +/* ── The rail as a sidebar, and the labs as its opposite number ─────── + Both are optional columns; the question takes whatever they leave. */ +.quiz-player .quiz-layout.is-rail-closed { grid-template-columns: minmax(0, 1fr); } +.quiz-player .quiz-layout.is-rail-closed .quiz-sidebar { display: none; } +.quiz-player .quiz-layout.has-labs { grid-template-columns: 260px minmax(0, 1fr) minmax(300px, 360px); } +.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); } + +.quiz-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.quiz-rail-hide, .quiz-rail-reopen { + border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); + border-radius: 50%; width: 24px; height: 24px; cursor: pointer; line-height: 1; + font-size: 0.9rem; flex-shrink: 0; +} +.quiz-rail-hide:hover, .quiz-rail-reopen:hover { color: var(--primary); border-color: var(--primary); } +/* Sits where the rail was, so putting it back is where you last saw it. */ +.quiz-rail-reopen { align-self: start; margin-top: 4px; } + +.quiz-labs { + display: flex; flex-direction: column; min-height: 0; + border-left: 1px solid var(--border); background: var(--card-bg); +} +.quiz-labs-head { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 10px 12px; border-bottom: 1px solid var(--border); flex: none; +} +.quiz-labs-head button { border: 0; background: none; cursor: pointer; color: var(--text-muted); font-size: 0.95rem; } +.quiz-labs-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 12px; } + +@media (max-width: 1150px) { + /* The rail is a drawer down here, so its handle has nothing to reopen. */ + .quiz-rail-reopen { display: none; } + /* No room for a third column; the labs go back to being a dialog. */ + .quiz-player .quiz-layout.has-labs, + .quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr); } + .quiz-labs { display: none; } +}