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:
parent
202979f7c0
commit
e311a6b5ad
6 changed files with 79 additions and 55 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 — "
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue