From e2e558c294373ee73f2ff9ca419e233ff4acabed Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 08:01:01 +0200 Subject: [PATCH] fix: exam chrome you can read, a lab panel you can use, and Next that ends the block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/App.jsx | 8 +- frontend/src/components/Navbar.jsx | 4 +- frontend/src/components/QuizTools.jsx | 16 +++- frontend/src/components/QuizTools.test.jsx | 17 +++- frontend/src/context/SessionChrome.jsx | 44 +++++++++ frontend/src/pages/QuizPage.jsx | 106 ++++++++++++++------- frontend/src/pages/QuizPage.test.jsx | 10 +- frontend/src/pages/QuizPlayer.css | 88 +++++++++++++++-- frontend/src/pages/ResultsPage.jsx | 8 +- frontend/src/pages/ToolsPage.css | 6 ++ frontend/src/pages/ToolsPage.jsx | 29 ++++++ frontend/src/utils/session.js | 11 --- 12 files changed, 275 insertions(+), 72 deletions(-) create mode 100644 frontend/src/context/SessionChrome.jsx delete mode 100644 frontend/src/utils/session.js 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 && ( -