From e311a6b5ada89d333461bcc7db5e23ef3e5e2171 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 00:19:35 +0200 Subject: [PATCH] fix: choosing is answering; figures say only what an educator wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Study mode held a choice as a draft and waited for "Submit response" — a second press to confirm something already decided, on every question. Clicking an option marks it now, green or red, with the explanation. Free text is the exception and keeps Enter, because typing is not choosing. Figures carried a generated caption: "Figure from question #3360 (from images/doc_23/page_704_img_0.jpeg)". That describes the database, not the picture, and showed a learner an internal file path. 341 of them are cleared, the indexer no longer writes them, and an unlabelled figure now says nothing rather than "Figure 1". A screen reader still gets the label and caption when there are any, and the position when there are not. Suspend, Restart and Edit are gone from above the question. Three buttons over a question nobody was looking away from to press them; Exit is in the bar at the bottom with the session's own controls, and restarting and editing belong to the session list and the editor. And iOS Safari's zoom-on-focus is fixed once rather than per field. Safari zooms the whole page in when a control smaller than 16px takes focus and never zooms back out, leaving the layout scaled and broken. It was being remembered at each individual field, which meant it was forgotten at most of them — a dozen were still under 16px. One rule for every control on a coarse pointer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/services/question_figures.py | 5 +- backend/scripts/index_question_images.py | 6 ++- frontend/src/components/FigureStrip.jsx | 12 +++-- frontend/src/index.css | 20 +++++++ frontend/src/pages/QuizPage.jsx | 66 +++++++++++------------- frontend/src/pages/QuizPage.test.jsx | 25 ++++----- 6 files changed, 79 insertions(+), 55 deletions(-) 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 () => {