diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a948361..aedde67 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useState } from 'react' import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import { SessionDrawerProvider } from './context/SessionDrawer' +import { SessionChromeProvider, useInSession } from './context/SessionChrome' import { ThemeProvider } from './context/ThemeContext' import Navbar from './components/Navbar' import SiteFooter from './components/SiteFooter' @@ -9,7 +10,6 @@ import ChooseObjective from './components/ChooseObjective' import SearchOverlay from './components/SearchOverlay' import ErrorBoundary from './components/ErrorBoundary' import lazyPage from './utils/lazyPage' -import { isSessionPath } from './utils/session' const LoginPage = lazyPage(() => import('./pages/LoginPage')) const RegisterPage = lazyPage(() => import('./pages/RegisterPage')) @@ -64,7 +64,7 @@ function AppLayout() { // Keyed by path so navigating away from a broken page clears the error. const location = useLocation() const [searching, setSearching] = useState(false) - const inSession = isSessionPath(location.pathname) + const inSession = useInSession() useEffect(() => { const onKey = (event) => { @@ -293,9 +293,9 @@ export default function App() { - + - + diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index a979467..52cb7d5 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom' import ScrollStrip from './ScrollStrip' import { useAuth } from '../context/AuthContext' import { useSessionDrawer } from '../context/SessionDrawer' -import { isSessionPath } from '../utils/session' +import { useInSession } from '../context/SessionChrome' import api from '../api/client' import ExamSwitcher from './ExamSwitcher' import GlobalSearch from './GlobalSearch' @@ -191,7 +191,7 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { const sessionDrawer = useSessionDrawer() const [jobs, setJobs] = useState([]) const location = useLocation() - const inSession = isSessionPath(location.pathname) + const inSession = useInSession() const isModerator = user?.role === 'admin' || user?.role === 'moderator' // Educators granted a category also manage questions, without a moderator role. const [canManageQuestions, setCanManageQuestions] = useState(false) diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx index 675cf36..f422773 100644 --- a/frontend/src/components/QuizTools.jsx +++ b/frontend/src/components/QuizTools.jsx @@ -53,9 +53,18 @@ const labFields = [ ] /** Exported so the player can dock it beside a question instead of over it. */ -export function LabValues() { +/** + * Reference ranges, as a lookup. + * + * `editable` is off everywhere it is opened from a session. An educator + * sitting a block was being offered an "Edit values" switch beside the + * question they were answering — a control that changes what every learner on + * the site sees, one keystroke from a stem they were reading. Maintaining the + * table is a settings job and lives in Settings → Tools. + */ +export function LabValues({ editable = false }) { const { user } = useAuth() - const isEducator = ['admin', 'moderator'].includes(user?.role) + const isEducator = editable && ['admin', 'moderator'].includes(user?.role) const [rows, setRows] = useState([]) const [query, setQuery] = useState('') const [group, setGroup] = useState('All') @@ -117,7 +126,6 @@ export function LabValues() { const groups = ['All', ...new Set(rows.map(row => row.group))] const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name)) return <> -

Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.

{/* "Manage references and drafts" said nothing useful: "references" here means lab values, not citations, and "drafts" means unpublished ones. This is an editing switch inside a lookup, so it says so. */} @@ -210,7 +218,7 @@ export default function QuizTools({ tool, onClose }) { const title = { calculator: 'Calculator', labs: 'Lab values', shortcuts: 'Keyboard shortcuts' }[tool] if (!title) return null return - {tool === 'calculator' ? : tool === 'labs' ? : <> + {tool === 'calculator' ? : tool === 'labs' ? : <>

Shortcuts pause while typing or using a dialog.

{shortcuts.map(([action, key]) => )}
ActionKey
{action}{key}
} diff --git a/frontend/src/components/QuizTools.test.jsx b/frontend/src/components/QuizTools.test.jsx index c19ef0f..0dbc0bd 100644 --- a/frontend/src/components/QuizTools.test.jsx +++ b/frontend/src/components/QuizTools.test.jsx @@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' -import QuizTools from './QuizTools' +import QuizTools, { LabValues } from './QuizTools' import api from '../api/client' vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } })) @@ -37,6 +37,13 @@ function renderLabs() { return render( {}} />) } +// Editing is not offered from a session — an educator answering a question was +// one keystroke from changing a range every learner reads. It lives in +// Settings → Tools, which renders the same component with `editable`. +function renderLabEditor() { + return render() +} + describe('lab values panel', () => { it('groups values by panel and keeps card links, without a sources footer', async () => { renderLabs() @@ -51,9 +58,15 @@ describe('lab values panel', () => { expect(screen.queryByRole('link', { name: 'PediRounds' })).not.toBeInTheDocument() }) - it('lets an educator link and unlink a card per reference', async () => { + it('offers no way to edit the table from inside a session', async () => { renderLabs() await screen.findByRole('heading', { name: 'CSF' }) + expect(screen.queryByRole('button', { name: 'Edit values' })).toBeNull() + }) + + it('lets an educator link and unlink a card per reference, where it is editable', async () => { + renderLabEditor() + await screen.findByRole('heading', { name: 'CSF' }) await userEvent.click(screen.getByRole('button', { name: 'Edit values' })) const csfSection = screen.getByRole('heading', { name: 'CSF' }).closest('section') const input = await within(csfSection).findByLabelText('Card ID to link to CSF white cell count') diff --git a/frontend/src/context/SessionChrome.jsx b/frontend/src/context/SessionChrome.jsx new file mode 100644 index 0000000..aca7ef6 --- /dev/null +++ b/frontend/src/context/SessionChrome.jsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react' + +/** + * Whether a session player is actually on screen. + * + * This used to be read off the URL — `/study/…` meant "in a session" — which + * is wrong at both ends of one. `/study/42` is also the screen that asks + * whether you want to start, and that screen is an ordinary card that wants + * the page's gutters and its section bar; stripping them left it marooned in + * the middle of an empty window. The player is the thing that needs the whole + * window, so the player is what says so. + * + * Counted rather than flagged, so a player unmounting as another mounts — a + * route change from one attempt straight into another — cannot leave the shell + * stuck in the wrong state. + */ +const Context = createContext({ inSession: false, enter: () => () => {} }) + +export function SessionChromeProvider({ children }) { + const [depth, setDepth] = useState(0) + const value = useMemo(() => ({ + inSession: depth > 0, + enter: () => { + setDepth(n => n + 1) + return () => setDepth(n => Math.max(0, n - 1)) + }, + }), [depth]) + return {children} +} + +/** Read by the shell and the navbar. */ +export const useInSession = () => useContext(Context).inSession + +/** Called by a player while it is mounted. */ +export function useClaimSessionChrome(active = true) { + const { enter } = useContext(Context) + useEffect(() => { + if (!active) return undefined + return enter() + // `enter` is stable in intent but changes identity with the depth it sets; + // depending on it here would release and re-take on every change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]) +} diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 099c282..184f811 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -8,6 +8,7 @@ import { mergeTextRanges } from '../utils/highlightOffsets' import { useAuth } from '../context/AuthContext' import api from '../api/client' import useMediaQuery from '../hooks/useMediaQuery' +import { useClaimSessionChrome } from '../context/SessionChrome' import useAwayDetector from '../hooks/useAwayDetector' import { useSessionDrawer } from '../context/SessionDrawer' import FigureStrip from '../components/FigureStrip' @@ -428,6 +429,9 @@ export default function QuizPage() { // 1150px breakpoint in QuizPlayer.css that hides the rail. const sessionDrawer = useSessionDrawer() const hasRail = useMediaQuery('(min-width: 1151px)') + // Only once a session is actually being sat. The screen that asks + // whether to start one is an ordinary card and wants the ordinary page. + useClaimSessionChrome(!!quizMode) // The site's burger opens this session's questions while there is no rail. // Only while there is no rail: on a desktop the list is already beside the @@ -1366,6 +1370,18 @@ const timerStarted = timeLeft !== null // Block — belongs to a block being sat. Study mode never had any of it, and // a closed block has nothing left to pause or hand in. const examChrome = !isStudy && !reviewing + /** + * Ending the block. + * + * In an exam the dialog names how many items are still unanswered before + * anything is handed in — the one warning worth giving. A study session with + * everything answered has nothing left to warn about, so it finishes rather + * than asking a question whose answer is already known. + */ + const endBlock = () => (isStudy && answeredCount >= totalCount + ? handleSubmit(false) + : setShowReview(true)) + const quizNavigation = (position = 'bottom') => (
+ + {/* And the end is still its own button, named for what it does. Two + controls, one outcome, because the one you reach for at the end of a + block is not always the one you have been pressing all the way + through it. */} + {isLast && !reviewing && ( + - ) : ( - /* Skip and Next are not the same decision. Moving on from a question - you have not answered is a choice, and the button says which one it - is rather than calling both of them Next. In review everything is - already marked, so there is nothing to skip — it is only a way - through. */ - )}
) @@ -1721,11 +1743,14 @@ const timerStarted = timeLeft !== null sits in the middle with the count between the two arrows rather than tucked in beside the tools. */}
+ {/* No chevron in the text: the round arrow above each label + is the chevron, and printing a second one beside it read + as two controls stuck together. */} + onClick={() => safeNavigate(currentIdx - 1)}>Previous {currentIdx + 1} / {totalCount} - +
) : hasRail ? ( @@ -1782,9 +1807,15 @@ const timerStarted = timeLeft !== null {answerRevealed && current.difficulty && ( {current.difficulty} )} - - {current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'} - + {/* Only when it is not the ordinary kind. A pill reading + "Multiple choice" above five lettered options is a label + for something already obvious; True/False and a blank to + type in are worth saying because they change what you do. */} + {current.question_type && current.question_type !== 'mcq' && ( + + {current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'} + + )} {/* A stem carrying a lab table cannot live inside a heading — the table would be invalid markup there — so the heading is the @@ -1803,11 +1834,16 @@ 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. */} - + where the numbers are. + + Except in an exam, where the chrome across the top is that + control and repeating it here is the same button twice. */} + {!examChrome && ( + + )} {current.attending_tip && ( - {voices.length > 0 && ( + {voices.length > 0 && !examChrome && ( setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })} /> )} - {voices.length > 0 && ( + {voices.length > 0 && !examChrome && ( -
+
)} diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 4247c2e..82eec7b 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -155,7 +155,9 @@ describe('quiz player', () => { // "hard" narrows the answer before the stem has been read, so it waits // until the answer is in. expect(within(meta).queryByText('hard')).not.toBeInTheDocument() - expect(within(meta).getByText('Multiple choice')).toBeInTheDocument() + // And "Multiple choice" is not said at all: every question is, and a pill + // repeating it above five lettered options labels the obvious. + expect(within(meta).queryByText('Multiple choice')).not.toBeInTheDocument() fireEvent.keyDown(window, { key: '1' }) expect(await within(meta).findByText('hard')).toBeInTheDocument() @@ -693,7 +695,11 @@ describe('quiz player', () => { await userEvent.click(within(nav).getByRole('button', { name: /Next/ })) await findStem('Full second clinical question.') expect(document.querySelector('.quiz-block-meta').textContent).toContain('Item: 2 of 2') - expect(within(document.querySelector('.quiz-item-nav')).getByRole('button', { name: /Next/ })).toBeDisabled() + // Next stays live at the last item and ends the block, which is what + // "next" means at the end of a paper. End Block is still its own control. + await userEvent.click(within(document.querySelector('.quiz-item-nav')).getByRole('button', { name: /Next/ })) + expect(screen.getByRole('dialog', { name: /This block is incomplete/ })).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Remain in Block' })) // The tools kept their end of the bar. expect(within(document.querySelector('.quiz-top-actions')).getByRole('button', { name: 'Lab values' })).toBeInTheDocument() diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index a01e781..f1ea8a5 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -552,8 +552,11 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; } 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); } +/* Wide enough for the whole row. At 360px the reference range — the one + column anybody opens this for — ran off the right-hand edge and there was + nothing to scroll sideways to reach it. */ +.quiz-player .quiz-layout.has-labs { grid-template-columns: 260px minmax(0, 1fr) minmax(360px, 440px); } +.quiz-player .quiz-layout.has-labs.is-rail-closed { grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); } .quiz-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .quiz-rail-hide, .quiz-rail-reopen { @@ -565,16 +568,61 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; } /* 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 { +.quiz-labpanel { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--border); background: var(--card-bg); } -.quiz-labs-head { +.quiz-labpanel-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; } +.quiz-labpanel-head button { border: 0; background: none; cursor: pointer; color: var(--text-muted); font-size: 0.95rem; } +.quiz-labpanel-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 12px; } + +/* The reference list was written for a dialog, where `.quiz-tool-body` gives + its controls a size and a shape. Docked beside the question it had neither, + so the group filters came out as bare browser buttons crammed edge to edge + and the search box was a default input with a Refresh beside it running off + the panel. Same controls, same look, in the column. */ +.quiz-labpanel-body .quiz-reference-controls { align-items: stretch; gap: 8px; } +.quiz-labpanel-body .quiz-reference-controls label { + display: flex; flex-direction: column; gap: 4px; + font-size: .72rem; font-weight: 600; letter-spacing: .04em; + text-transform: uppercase; color: var(--text-muted); +} +.quiz-labpanel-body input:not([type=checkbox]) { + width: 100%; padding: 8px 10px; font: inherit; font-size: .85rem; + color: var(--text); background: var(--input-bg); + border: 1px solid var(--border); border-radius: 7px; +} +.quiz-labpanel-body button { + padding: 7px 11px; font: inherit; font-size: .8rem; cursor: pointer; + color: var(--text); background: var(--bg); + border: 1px solid var(--border); border-radius: 7px; +} +.quiz-labpanel-body button:hover { border-color: var(--primary); color: var(--primary); } +.quiz-labpanel-body button:disabled { opacity: .5; cursor: not-allowed; } + +/* The group filters are a row of pills, and they wrap rather than squeeze. */ +.quiz-labpanel-body .quiz-tool-tabs { gap: 5px; margin: 12px 0; } +.quiz-labpanel-body .quiz-tool-tabs button { padding: 5px 10px; font-size: .75rem; border-radius: 999px; } +.quiz-labpanel-body .quiz-tool-tabs button[aria-pressed=true] { + background: var(--primary); border-color: var(--primary); color: #fff; +} +.quiz-labpanel-body .quiz-reference-note { margin: 0 0 12px; font-size: .78rem; line-height: 1.5; } +.quiz-labpanel-body .quiz-reference-scroll { max-height: none; overflow-x: visible; } + +/* One column, not two. The dialog is wide enough to put the test and its range + side by side; this column is not, and the half that got squeezed was the + range. Stacked, every value is legible at any panel width, and the list + scrolls rather than the row clipping. */ +.quiz-labpanel-body .quiz-lab-row { + grid-template-columns: minmax(0, 1fr); + padding: 10px 12px; row-gap: 4px; +} +.quiz-labpanel-body .quiz-lab-age-line { grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; } +.quiz-labpanel-body .quiz-lab-range { white-space: normal; overflow-wrap: anywhere; text-align: right; } +.quiz-labpanel-body .quiz-lab-values { width: 100%; } @media (max-width: 1150px) { /* The rail is a drawer down here, so its handle has nothing to reopen. */ @@ -582,7 +630,7 @@ body:has(.quiz-player.is-boxed) .site-footer { 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; } + .quiz-labpanel { display: none; } } @@ -596,8 +644,14 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; } .quiz-player.is-exam-chrome { position: fixed; inset: 0; z-index: 400; height: 100dvh; max-height: 100dvh; + /* Flush. The player's own 24/32/40 gutters are for a page inside a site; + this is the whole window, and a white margin around a navy bar reads as a + dialog somebody forgot to close. */ + padding: 0; background: var(--card-bg); } +.quiz-player.is-exam-chrome .quiz-layout { gap: 0; } +.quiz-player.is-exam-chrome .quiz-main > *:not(.quiz-topbar) { padding: 0 24px 24px; } body:has(.quiz-player.is-exam-chrome) .navbar, body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; } /* The title, the mode pill and the progress bar are the session list's way of @@ -620,7 +674,8 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; } font-size: .82rem; line-height: 1.35; color: #fff; background: rgba(255, 255, 255, .07); border: 1px solid rgba(255, 255, 255, .35); } -.quiz-player.is-exam-chrome .quiz-block-meta strong { font-weight: 700; } +.quiz-player.is-exam-chrome .quiz-block-meta strong { color: #fff; font-weight: 700; } +.quiz-player.is-exam-chrome .quiz-item-count { color: #fff; } .quiz-player.is-exam-chrome .quiz-item-nav { flex: 1; display: flex; align-items: center; justify-content: center; gap: 14px; @@ -655,7 +710,22 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; } /* The rail is the exam's question-status list: numbers and how each stands, without the excerpt a study session shows. */ -.quiz-player.is-exam-chrome .quiz-rail { background: var(--exam-bar); border: 0; border-radius: 0; } +.quiz-player.is-exam-chrome .quiz-rail { + padding: 14px 12px; background: var(--exam-bar); border: 0; border-radius: 0; +} +.quiz-player.is-exam-chrome .quiz-rail-hide { + background: transparent; border-color: rgba(255, 255, 255, .55); color: #fff; +} +.quiz-player.is-exam-chrome .quiz-rail-hide:hover { + background: rgba(255, 255, 255, .18); border-color: #fff; color: #fff; +} +/* And the handle that brings it back, which sits on the navy where the rail + was rather than on the page. */ +.quiz-player.is-exam-chrome .quiz-rail-reopen { + margin: 10px 0 0 10px; + background: var(--exam-bar); border-color: var(--exam-bar); color: #fff; +} +.quiz-player.is-exam-chrome .quiz-rail-reopen:hover { background: #14304f; border-color: #14304f; } .quiz-player.is-exam-chrome .quiz-rail, .quiz-player.is-exam-chrome .quiz-rail-head span, .quiz-player.is-exam-chrome .quiz-rail-item { color: #eef2f8; } diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx index e7d2700..d04c00e 100644 --- a/frontend/src/pages/ResultsPage.jsx +++ b/frontend/src/pages/ResultsPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom' import api from '../api/client' import { useSessionDrawer } from '../context/SessionDrawer' +import { useClaimSessionChrome } from '../context/SessionChrome' import useMediaQuery from '../hooks/useMediaQuery' import QuizTools, { LabValues } from '../components/QuizTools' import FigureStrip from '../components/FigureStrip' @@ -45,6 +46,7 @@ export default function ResultsPage() { const [responseStats, setResponseStats] = useState(null) const [navOpen, setNavOpen] = useState(false) const hasRail = useMediaQuery('(min-width: 1151px)') + useClaimSessionChrome(true) // On a phone the site's burger opens this attempt's questions, exactly as it // does while one is being sat — the rail has nowhere to live at that width, // and a second hamburger for the same list is not an answer. @@ -273,12 +275,12 @@ export default function ResultsPage() { {labsOpen && ( -