fix: exam chrome you can read, a lab panel you can use, and Next that ends the block

The exam bar had a navy background and dark navy text on it, so the two
numbers it exists for — which item, which block — were invisible, and the
rail's own hide handle was a white disc with a white chevron inside it. The
round arrows were drawn by the chrome while the labels still carried chevrons
of their own, giving every control two. All of it is flush now: the player's
page gutters were still there, framing a full-window exam in white.

The block's tools are the block's. Labs moved to the bar across the top, so the
copy of it under the stem is gone, and reading a paper aloud is not something
an exam offers — Listen, Listen through and the highlight clear are study's.

Next carries on being Next at the last item. There is nowhere further to go, so
where it goes is out: it ends the block. Disabling it there left the hand that
had pressed it four times with nothing under it. End Block stays where it is —
two controls, one outcome, because what you reach for at the end of a block is
not always what you have been pressing all the way through it.

The lab panel was two components sharing one class name, so the list's layout
leaked onto the column holding it and its controls — written for a dialog —
came out as bare browser buttons. Now it is its own thing, wide enough that
the reference range no longer runs off the right-hand edge, and stacked so
every value is legible at any width. The disclaimer about ringing your own
laboratory is gone; nobody sitting a board question is going to.

And nobody edits the reference table from inside a session. An educator
answering a question was one keystroke from changing a range every learner
reads. It lives in Settings → Tools now.

Also: the full-window session shell is claimed by the player rather than
guessed from the URL — /study/42 is also the screen that asks whether to start
one, and that screen is an ordinary card that was being left marooned in an
empty window with its own navigation stripped. And "Multiple choice" above five
lettered options labelled the obvious.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 08:01:01 +02:00
parent b2a353cf58
commit e2e558c294
12 changed files with 275 additions and 72 deletions

View file

@ -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() {
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<SessionDrawerProvider>
<SessionDrawerProvider><SessionChromeProvider>
<AppRoutes />
</SessionDrawerProvider>
</SessionChromeProvider></SessionDrawerProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>

View file

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

View file

@ -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 <>
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.</p>
{/* "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 <QuizDialog title={title} onClose={onClose}>
{tool === 'calculator' ? <Calculator /> : tool === 'labs' ? <LabValues /> : <>
{tool === 'calculator' ? <Calculator /> : tool === 'labs' ? <LabValues editable={false} /> : <>
<p>Shortcuts pause while typing or using a dialog.</p>
<table><thead><tr><th>Action</th><th>Key</th></tr></thead><tbody>{shortcuts.map(([action, key]) => <tr key={action}><td>{action}</td><td><kbd>{key}</kbd></td></tr>)}</tbody></table>
</>}

View file

@ -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(<MemoryRouter><QuizTools tool="labs" onClose={() => {}} /></MemoryRouter>)
}
// 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(<MemoryRouter><LabValues editable /></MemoryRouter>)
}
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')

View file

@ -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 <Context.Provider value={value}>{children}</Context.Provider>
}
/** 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])
}

View file

@ -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') => (
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
<button className="btn btn-secondary"
@ -1377,27 +1393,33 @@ const timerStarted = timeLeft !== null
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
// unanswered before anything is handed in the warning worth giving.
// A study session with everything answered has nothing left to warn
// about, so it finishes rather than asking twice.
<button className="btn btn-primary" disabled={submitting}
onClick={() => (isStudy && answeredCount >= totalCount
? handleSubmit(false)
: setShowReview(true))}>
{/* 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.
On the last question Next carries on being Next. There is nowhere
further to go, so where it goes is out: it ends the block, which is
what "next" means at the end of a paper. Disabling it there left the
hand that had pressed it four times with nothing under it. */}
<button className="btn btn-primary" disabled={submitting}
onClick={() => (isLast && !reviewing
? endBlock()
: safeNavigate(Math.min(totalCount - 1, currentIdx + 1)))}
title={isLast && !reviewing
? (isStudy ? 'Finish this session' : 'End this block') : undefined}>
{reviewing || answers[current?.id] || isLast ? 'Next →' : 'Skip →'}
</button>
{/* 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 && (
<button className="btn btn-secondary" disabled={submitting} onClick={endBlock}>
{isStudy ? 'Finish session' : 'End block'}
</button>
) : (
/* 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. */
<button className="btn btn-primary" disabled={isLast}
onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>
{reviewing || answers[current?.id] ? 'Next →' : 'Skip →'}
</button>
)}
</div>
)
@ -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. */}
<div className="quiz-item-nav">
{/* 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. */}
<button type="button" disabled={currentIdx === 0}
onClick={() => safeNavigate(currentIdx - 1)}> Previous</button>
onClick={() => safeNavigate(currentIdx - 1)}>Previous</button>
<span className="quiz-item-count">{currentIdx + 1} / {totalCount}</span>
<button type="button" disabled={isLast}
onClick={() => safeNavigate(currentIdx + 1)}>Next </button>
<button type="button" disabled={submitting}
onClick={() => (isLast ? endBlock() : safeNavigate(currentIdx + 1))}>Next</button>
</div>
</>
) : hasRail ? (
@ -1782,9 +1807,15 @@ const timerStarted = timeLeft !== null
{answerRevealed && current.difficulty && (
<span className={`quiz-meta-pill is-${current.difficulty}`}>{current.difficulty}</span>
)}
<span className="quiz-meta-pill">
{current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
</span>
{/* 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' && (
<span className="quiz-meta-pill">
{current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
</span>
)}
</div>
{/* 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. */}
<button type="button" className={labsOpen ? 'is-on' : ''}
aria-pressed={labsOpen} onClick={() => setLabsOpen(v => !v)}>
<span>Labs</span>
</button>
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 && (
<button type="button" className={labsOpen ? 'is-on' : ''}
aria-pressed={labsOpen} onClick={() => setLabsOpen(v => !v)}>
<span>Labs</span>
</button>
)}
{current.attending_tip && (
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
aria-pressed={panel === 'tip'}
@ -1845,7 +1881,7 @@ const timerStarted = timeLeft !== null
? 'Remove from favourites' : 'Add to favourites — sit them as their own session later'}>
{favorites.includes(current.id) ? '★' : '☆'} <span>Favourite</span>
</button>
{voices.length > 0 && (
{voices.length > 0 && !examChrome && (
<TTSButton
key={`${current.id}_${selectedVoice || 'default'}`}
text={buildQuestionSpeechText(current, currentIdx)}
@ -1867,7 +1903,7 @@ const timerStarted = timeLeft !== null
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
/>
)}
{voices.length > 0 && (
{voices.length > 0 && !examChrome && (
<button type="button" className={readThrough ? 'is-on' : ''}
onClick={() => setReadThrough(v => !v)}
title="Read each question aloud and advance automatically">
@ -2163,13 +2199,13 @@ const timerStarted = timeLeft !== null
</div>
{labsOpen && (
<aside className="quiz-labs" aria-label="Lab values">
<div className="quiz-labs-head">
<aside className="quiz-labpanel" aria-label="Lab values">
<div className="quiz-labpanel-head">
<strong>Lab values</strong>
<button type="button" aria-label="Close lab values"
onClick={() => setLabsOpen(false)}></button>
</div>
<div className="quiz-labs-body"><LabValues /></div>
<div className="quiz-labpanel-body"><LabValues /></div>
</aside>
)}

View file

@ -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()

View file

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

View file

@ -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() {
</div>
{labsOpen && (
<aside className="quiz-labs" aria-label="Lab values">
<div className="quiz-labs-head">
<aside className="quiz-labpanel" aria-label="Lab values">
<div className="quiz-labpanel-head">
<strong>Lab values</strong>
<button type="button" aria-label="Close lab values" onClick={() => setLabsOpen(false)}></button>
</div>
<div className="quiz-labs-body"><LabValues /></div>
<div className="quiz-labpanel-body"><LabValues /></div>
</aside>
)}

View file

@ -52,3 +52,9 @@
.tools-counts .is-pending { background: var(--option-sel-bg); color: var(--primary); }
.tools-counts .is-accepted { background: var(--correct-bg); color: var(--correct-fg); }
.tools-counts .is-rejected { background: var(--bg); color: var(--text-subtle); }
/* The reference table inside Tools. It is the same component the session
shows, so it brings its own layout; this only gives it room to breathe and
somewhere to scroll when the list is long. */
.tools-labs { margin-top: 14px; max-height: 60vh; overflow-y: auto; }

View file

@ -6,6 +6,9 @@ import './ToolsPage.css'
import BackLink from '../components/BackLink'
const DraftBatch = lazy(() => import('../components/DraftBatch'))
// Only loaded when the section is opened: the whole reference table and its
// editing form are a lot to ship to a page that is mostly about PDFs.
const LabValues = lazy(() => import('../components/QuizTools').then(m => ({ default: m.LabValues })))
const when = value => (value
? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
@ -27,6 +30,7 @@ export default function ToolsPage() {
const [error, setError] = useState('')
const [open, setOpen] = useState(null)
const [showDone, setShowDone] = useState(false)
const [labsOpen, setLabsOpen] = useState(false)
const load = useCallback(() => {
Promise.all([
@ -152,6 +156,31 @@ export default function ToolsPage() {
)}
</section>
{['admin', 'moderator'].includes(user?.role) && (
<section className="tools-section">
<div className="tools-section-head">
<h2>Lab reference values</h2>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setLabsOpen(v => !v)}>
{labsOpen ? 'Close' : 'Open the table'}
</button>
</div>
<p className="tools-note">
{/* It used to be editable from inside a session, where an educator
answering a question was one keystroke from changing a range
every learner reads. Looking one up during a block is a lookup;
maintaining the table is this. */}
The ranges learners see in the lab panel during a session. Editing
here changes what everybody sees.
</p>
{labsOpen && (
<Suspense fallback={<div className="loading"><div className="spinner" /></div>}>
<div className="tools-labs"><LabValues editable /></div>
</Suspense>
)}
</section>
)}
{user?.role === 'admin' && (
<section className="tools-section">
<div className="tools-section-head">

View file

@ -1,11 +0,0 @@
/**
* Which routes are "inside a session".
*
* Sitting a block and reading one back are the same screen with different
* things on it, and both want the whole window: no section bar, no centred
* column, and on a phone a burger that opens the question list rather than the
* site menu. Two files decided this separately and disagreed the moment review
* became a player, so they now ask here.
*/
export const isSessionPath = (pathname = '') =>
pathname.startsWith('/study/') || pathname.startsWith('/results/')