diff --git a/backend/app/services/question_figures.py b/backend/app/services/question_figures.py index 9173b79..53deb69 100644 --- a/backend/app/services/question_figures.py +++ b/backend/app/services/question_figures.py @@ -24,7 +24,10 @@ def figure_json(link: QuestionMedia, asset: MediaAsset) -> dict: "role": link.role, # The label the prose refers to. Falls back to a number so a figure is # never nameless, which is what makes "see the figure" ambiguous. - "label": link.label or f"Figure {link.position + 1}", + # An educator's label, or nothing. "Figure 1" and "Figure from question + # #3360" told a learner only that an image was an image, and the second + # one told them the internal path it came from as well. + "label": link.label or None, "caption": link.caption or getattr(asset, "caption", None), "title": getattr(asset, "title", None), "path": getattr(asset, "path", None), diff --git a/backend/scripts/index_question_images.py b/backend/scripts/index_question_images.py index 71cf87c..b5d3c02 100644 --- a/backend/scripts/index_question_images.py +++ b/backend/scripts/index_question_images.py @@ -112,7 +112,11 @@ def main(): was = historical.get(key) source = question or was if question: - caption = f"Figure from question #{question[0]}" + # No caption unless an educator writes one. A generated one + # ("Figure from question #3360") describes the database, not + # the picture, and was shown to learners as though it were a + # caption — file path and all. + caption = None state = "in use" elif was: caption = (f"Detached from question #{was[0]} during the stem/answer review — " diff --git a/frontend/src/components/FigureStrip.jsx b/frontend/src/components/FigureStrip.jsx index b7587eb..76c88eb 100644 --- a/frontend/src/components/FigureStrip.jsx +++ b/frontend/src/components/FigureStrip.jsx @@ -34,12 +34,18 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label } {figures.map((figure, index) => (
  • diff --git a/frontend/src/index.css b/frontend/src/index.css index 8a8c06c..7875fea 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -127,6 +127,26 @@ html, body { overflow-x: hidden; max-width: 100%; } that pins itself below the header measures from here rather than guessing. */ --app-header: 98px; } +/* ── iOS Safari's zoom-on-focus ─────────────────────────────────────── + Safari on iOS zooms the whole page in when a form control whose font is + smaller than 16px takes focus, and it never zooms back out. The page is + left scaled, the layout looks broken, and the only way back is a manual + pinch. It is not a bug we can catch — it is the platform's behaviour, and + the only lever is the font size. + + Set once, for every control, on touch pointers. It was being remembered at + each individual field, which meant it was being forgotten at most of them — + `!important` because those per-field rules are class-scoped and would + otherwise win. Above 16px nothing here applies, so a deliberately larger + field keeps its size. */ +@media (pointer: coarse) { + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), + select, + textarea { + font-size: max(16px, 1rem) !important; + } +} + .app-shell { display: flex; flex-direction: column; min-height: 100dvh; } .app-main { flex: 1 0 auto; width: 100%; } .app-shell > .site-footer { flex: none; } diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 5b4be9c..92f8ab8 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -448,7 +448,6 @@ export default function QuizPage() { const [favorites, setFavorites] = useState([]) const [activeReadSegment, setActiveReadSegment] = useState(null) const [manualHighlights, setManualHighlights] = useState({}) - const [draftAnswer, setDraftAnswer] = useState('') const [tool, setTool] = useState(null) // 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. @@ -478,7 +477,6 @@ export default function QuizPage() { const [resumeError, setResumeError] = useState('') const [resumeRetry, setResumeRetry] = useState(0) const [progressError, setProgressError] = useState('') - const [restartConfirm, setRestartConfirm] = useState(false) const timerRef = useRef(null) const toastRef = useRef(null) const hasStarted = useRef(false) @@ -632,7 +630,7 @@ export default function QuizPage() { useEffect(() => { setActiveReadSegment(null) setTtsActive(false) - setDraftAnswer('') + setTyped('') setOpenExplanations(new Set()) savedHighlightSelectionRef.current = null clearTimeout(autoHighlightTimerRef.current) @@ -950,7 +948,7 @@ const timerStarted = timeLeft !== null delete next[questionId] return next }) - setDraftAnswer('') + setTyped('') } const saveNote = async (questionId, content) => { @@ -968,13 +966,27 @@ const timerStarted = timeLeft !== null ? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f))) } catch { /* the row stays unticked, which is the honest signal */ } } + /** + * Choosing is answering. + * + * Study mode used to hold the choice as a draft and wait for "Submit + * response" — a second press to confirm something you had already decided, + * on every question. Clicking an option marks it: green if it was right, red + * if it was not, with the explanation. Exam mode records it and moves on + * when you do. + */ const chooseAnswer = value => { if (!current || (isStudy && answers[current.id])) return - if (isStudy) setDraftAnswer(value) - else setAnswer(current.id, value) + setAnswer(current.id, value) } - const submitStudyResponse = () => { - if (isStudy && current && !answers[current.id] && draftAnswer.trim()) setAnswer(current.id, draftAnswer) + + // Free text is the exception: clicking an option is a decision, typing is + // not, so a typed answer is held until Enter or leaving the field. + const [typed, setTyped] = useState('') + const commitTyped = () => { + if (!current || !typed.trim()) return + if (isStudy && answers[current.id]) return + setAnswer(current.id, typed.trim()) } useEffect(() => { @@ -1114,7 +1126,6 @@ const timerStarted = timeLeft !== null if (event.target.closest?.('button, a') && ['Enter', ' '].includes(event.key)) return if (hasActiveTextSelection()) return if (/^[1-9]$/.test(event.key) && current.options?.[Number(event.key) - 1] !== undefined) chooseAnswer(current.options[Number(event.key) - 1]) - else if (event.key === 'Enter' && isStudy) submitStudyResponse() else if (event.key === 'ArrowLeft') safeNavigate(Math.max(0, currentIdx - 1)) else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') safeNavigate(Math.min(questions.length - 1, currentIdx + 1)) else if (event.key.toLowerCase() === 'b') toggleFavorite(current.id) @@ -1124,7 +1135,7 @@ const timerStarted = timeLeft !== null } window.addEventListener('keydown', keydown) return () => window.removeEventListener('keydown', keydown) - }, [quizMode, current, currentIdx, answers, draftAnswer, favorites, expandedImagePath]) + }, [quizMode, current, currentIdx, answers, favorites, expandedImagePath]) if (loading) return
    Loading quiz...
    if (!quiz) return null @@ -1362,25 +1373,10 @@ const timerStarted = timeLeft !== null
    {timeLeft !== null && } - - {restartConfirm ? ( - Restart from the beginning? - - - - ) : ( - - )} - {isModerator && ✏️ Edit} + {/* Suspend, Restart and Edit were three buttons above a question + nobody was looking away from to press them. Exit is in the bar + at the bottom, where the session's own controls are; restarting + and editing belong to the session list and the editor. */}
    {voices.length > 1 && ( @@ -1605,7 +1601,7 @@ const timerStarted = timeLeft !== null {(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
    {current.options.map((opt, i) => { - const isSelected = (answers[current.id] || draftAnswer) === opt + const isSelected = answers[current.id] === opt const hasAnswered = isStudy && !!answers[current.id] const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase() const showCorrect = hasAnswered && isCorrectOpt @@ -1660,14 +1656,14 @@ const timerStarted = timeLeft !== null })}
    ) : ( - chooseAnswer(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && isStudy) { e.preventDefault(); submitStudyResponse() } }} + onChange={e => setTyped(e.target.value)} + onBlur={commitTyped} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commitTyped() } }} style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} /> )} - {isStudy && !answers[current.id] && } {isStudy && answers[current.id] && ( <>
    Preferred response{current.page_reference && Source page {current.page_reference}}
    diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 0b24c23..1f93b13 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -125,9 +125,8 @@ describe('quiz player', () => { await begin() const before = document.querySelectorAll('.quiz-rail-item') expect(before[0].className).not.toMatch(/is-done/) + // Choosing is answering: the rail marks it on the click. await userEvent.click(inCard().getByText('First answer').closest('.option')) - // Study mode holds a draft until it is submitted, so the rail marks it then. - await userEvent.click(screen.getByRole('button', { name: 'Submit response' })) await waitFor(() => expect(document.querySelectorAll('.quiz-rail-item')[0].className).toMatch(/is-done/)) }) @@ -143,7 +142,6 @@ describe('quiz player', () => { expect(within(meta).getByText('Multiple choice')).toBeInTheDocument() fireEvent.keyDown(window, { key: '1' }) - fireEvent.keyDown(window, { key: 'Enter' }) expect(await within(meta).findByText('hard')).toBeInTheDocument() // The category trail is gone: it named the answer's own topic, and led out // of a session you are part-way through. @@ -233,7 +231,7 @@ describe('quiz player', () => { // Study mode holds a selection until it is confirmed, so confirm it — the // point of this test is what is saved, not what is provisionally picked. fireEvent.keyDown(window, { key: 'Enter' }) - await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' })) + await userEvent.click(screen.getByRole('button', { name: 'Exit session' })) expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open') expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument() failSaving = false @@ -247,17 +245,18 @@ describe('quiz player', () => { fireEvent.keyDown(window, { key: '1' }) // One press. Nothing is lost by leaving — the answers are saved and the // clock pauses — so there is nothing to confirm. - await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' })) + await userEvent.click(screen.getByRole('button', { name: 'Exit session' })) // Leaving a session part-way through should land where what you answered // is scored and Resume sits — not on a list of every session you own. expect(await screen.findByText('Submitted results')).toBeInTheDocument() }) - it('keeps a study selection provisional until confirmation and shows genuine response data', async () => { + it('marks a study answer the moment it is chosen, and shows genuine response data', async () => { await begin() + // Choosing is answering. It used to hold the choice and wait for "Submit + // response" — a second press to confirm something already decided, on + // every question. fireEvent.keyDown(window, { key: '1' }) - expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() - fireEvent.keyDown(window, { key: 'Enter' }) expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument() expect(await screen.findByText('8/10')).toBeInTheDocument() expect(screen.getByText('80%')).toBeInTheDocument() @@ -296,7 +295,6 @@ describe('quiz player', () => { await begin() await findStem('Full first clinical question.') fireEvent.keyDown(window, { key: '1' }) - fireEvent.keyDown(window, { key: 'Enter' }) const option = await waitFor(() => inCard().getByText('First answer').closest('button')) expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument() @@ -350,7 +348,6 @@ describe('quiz player', () => { }) await begin() await userEvent.click(screen.getByRole('button', { name: /1\s*First answer/ })) - await userEvent.click(screen.getByRole('button', { name: 'Submit response' })) expect(screen.queryByText('Preferred because of this')).not.toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Show all explanations' })) expect(await screen.findByText('Preferred because of this')).toBeInTheDocument() @@ -364,7 +361,6 @@ describe('quiz player', () => { it('lets the learner hide response statistics', async () => { await begin() fireEvent.keyDown(window, { key: '1' }) - fireEvent.keyDown(window, { key: 'Enter' }) expect(await screen.findByText('8/10')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Hide stats' })) expect(screen.queryByText('8/10')).not.toBeInTheDocument() @@ -377,7 +373,6 @@ describe('quiz player', () => { mode = 'exam' await begin(false) fireEvent.keyDown(window, { key: '1' }) - fireEvent.keyDown(window, { key: 'Enter' }) expect(screen.queryByText(/recorded answers/)).not.toBeInTheDocument() expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/'))).toBe(false) }) @@ -390,7 +385,6 @@ describe('quiz player', () => { }) await begin() fireEvent.keyDown(window, { key: '1' }) - fireEvent.keyDown(window, { key: 'Enter' }) expect(await screen.findByText('No response statistics available yet')).toBeInTheDocument() expect(screen.queryByText('0%')).not.toBeInTheDocument() }) @@ -409,7 +403,6 @@ describe('quiz player', () => { expect(screen.getByAltText('Expanded question illustration')).toHaveAttribute('src', '/uploads/questions/stem.png?attempt_id=50') await userEvent.click(screen.getByRole('button', { name: 'Close expanded image' })) fireEvent.keyDown(window, { key: '1' }) - fireEvent.keyDown(window, { key: 'Enter' }) expect(await screen.findByAltText('Explanation illustration')).toHaveAttribute('src', '/uploads/questions/answer.png?attempt_id=50') }) @@ -424,7 +417,9 @@ describe('quiz player', () => { expect(api.post.mock.calls.some(([url]) => url === '/favorites')).toBe(false) await userEvent.click(within(dialog).getByRole('button', { name: 'Close Calculator' })) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Submit response' })).toBeDisabled() + // The keystrokes typed into the calculator did not answer the question + // behind it — which is the point of the test. + expect(document.querySelector('.option.correct')).toBeNull() }) it('keeps exam answers hidden and reviews unanswered questions before completing', async () => {