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
{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. */}