fix: choosing is answering; figures say only what an educator wrote

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 00:19:35 +02:00
parent 202979f7c0
commit e311a6b5ad
6 changed files with 79 additions and 55 deletions

View file

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

View file

@ -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 — "

View file

@ -34,12 +34,18 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label }
{figures.map((figure, index) => (
<li key={figure.id}>
<button type="button" className="fs-item"
aria-label={`Open ${figure.label}${figure.caption ? `: ${figure.caption}` : ''}`}
// Whatever an educator wrote, both parts if both exist a
// screen reader gets the caption too, not only the label. With
// neither, the position is the only honest name there is.
aria-label={`Open ${[figure.label, figure.caption].filter(Boolean).join(': ')
|| `figure ${index + 1}`}`}
onClick={() => setOpen(index)}>
<img src={uploadUrl(figure.path, attemptId)} alt={figure.caption || figure.label}
<img src={uploadUrl(figure.path, attemptId)} alt={figure.caption || figure.label || ''}
loading="lazy" onError={e => { e.currentTarget.style.visibility = 'hidden' }} />
<span className="fs-cap">
<strong>{figure.label}</strong>
{/* Only what somebody wrote. An unlabelled figure says
nothing rather than "Figure 1". */}
{figure.label && <strong>{figure.label}</strong>}
{figure.caption && <span>{figure.caption}</span>}
</span>
</button>

View file

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

View file

@ -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 <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null
@ -1362,25 +1373,10 @@ const timerStarted = timeLeft !== null
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<button className="btn btn-secondary btn-sm" onClick={() => leaveNow()}
title="Save your answers and pick this up later — the clock stops too">
Suspend
</button>
{restartConfirm ? (
<span className="quiz-restart-confirm" role="alert">Restart from the beginning?
<button className="btn btn-primary btn-sm" onClick={async () => {
setRestartConfirm(false)
setAnswers({}); setCurrentIdx(0); setDraftAnswer(''); setStartedAt(null)
setTimeLeft(null); setTotalTime(null); setResponseStats(null); setStatsError('')
hasStarted.current = false
await startAttempt(quizMode || (quiz?.mode === 'timed' ? 'exam' : 'study'), selectedVoice || null, quiz?.time_limit_minutes || null, true)
}}>Yes, restart</button>
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(false)}>Cancel</button>
</span>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning"> Restart</button>
)}
{isModerator && <Link to={`/study/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
{/* 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. */}
</div>
</div>
{voices.length > 1 && (
@ -1605,7 +1601,7 @@ const timerStarted = timeLeft !== null
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 8 }}>
{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
})}
</div>
) : (
<input type="text" placeholder="Type your answer..."
value={answers[current.id] || draftAnswer}
<input type="text" placeholder="Type your answer, then press Enter"
value={answers[current.id] ?? typed}
readOnly={isStudy && Boolean(answers[current.id])}
onChange={e => 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] && <button type="button" className="btn btn-primary quiz-submit-response" disabled={!draftAnswer.trim()} onClick={submitStudyResponse}>Submit response</button>}
{isStudy && answers[current.id] && (
<>
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>

View file

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