From 094bab20cd299d946259d635d9bf35e42d4015e6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 05:05:59 +0200 Subject: [PATCH] feat: an opened explanation figure shows what the library knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two voices, and they belong in different places. The thumbnail in an explanation carries what the *question* says about the figure — words an educator chose for this question. Opening it now adds the catalogue entry underneath: what the image was filed as, the description written when it was filed, where it came from, and any marks an educator drew on it, behind a switch. It rides on explanation rows only. Those are already withheld by the server until answers are revealed, so the record reaches somebody who has finished the question and gets nowhere near somebody who has not — which is the whole reason the caption came off the figure in the first place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/services/question_figures.py | 22 ++++++++++ backend/tests/test_question_figures.py | 18 +++++++++ frontend/src/components/FigureStrip.css | 23 +++++++++++ frontend/src/components/FigureStrip.jsx | 42 +++++++++++++++++++- frontend/src/components/FigureStrip.test.jsx | 39 ++++++++++++++++++ 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/backend/app/services/question_figures.py b/backend/app/services/question_figures.py index d54950d..18a2e18 100644 --- a/backend/app/services/question_figures.py +++ b/backend/app/services/question_figures.py @@ -42,6 +42,28 @@ def figure_json(link: QuestionMedia, asset: MediaAsset) -> dict: "caption": link.caption or None, "path": getattr(asset, "path", None), "position": link.position, + # What the library knows, for an explanation figure only. + # + # Once the answer is in, the catalogue entry is worth having: the + # title, the description written when the image was filed, where it + # came from, and any marks an educator drew on it. Before the answer + # it is a giveaway, which is why the caption fell out of the figure + # itself — so it rides only on the rows that are already withheld + # until answers are revealed. Nothing here is sent with a stem. + "library": _library_json(asset) if link.role == "explanation" else None, + } + + +def _library_json(asset: MediaAsset) -> dict | None: + if asset is None: + return None + return { + "title": asset.title or None, + "caption": asset.caption or None, + "alt_text": asset.alt_text or None, + "source": asset.source or None, + "source_url": asset.source_url or None, + "overlay": asset.overlay or None, } diff --git a/backend/tests/test_question_figures.py b/backend/tests/test_question_figures.py index a57ef87..2f3bc61 100644 --- a/backend/tests/test_question_figures.py +++ b/backend/tests/test_question_figures.py @@ -39,6 +39,24 @@ class FigureCaptionTests(unittest.TestCase): self.assertNotIn("title", figure) self.assertEqual(figure["path"], "uploads/hip.png") + def test_the_library_record_rides_only_on_an_explanation_figure(self): + """Once the answer is in, the catalogue entry is worth having. + + The server already withholds explanation figures until answers are + revealed, so hanging the library record on those rows — and only those + — puts it in front of a reader who has finished the question and + nowhere near one who has not. + """ + self.asset.source = "Teaching file" + self.asset.source_url = "https://example.org/x" + stem = QuestionMedia(id=3, question_id=9, media_id=5, role="stem", position=0) + back = QuestionMedia(id=4, question_id=9, media_id=5, role="explanation", position=0) + self.assertIsNone(figure_json(stem, self.asset)["library"]) + shelf = figure_json(back, self.asset)["library"] + self.assertEqual(shelf["title"], "Right SCFE, AP pelvis") + self.assertIn("right hip joint", shelf["caption"]) + self.assertEqual(shelf["source"], "Teaching file") + def test_what_the_question_itself_says_is_shown(self): link = QuestionMedia(id=2, question_id=9, media_id=5, role="stem", label="Figure 1", caption="AP pelvis at presentation.", diff --git a/frontend/src/components/FigureStrip.css b/frontend/src/components/FigureStrip.css index e162f08..8cb560d 100644 --- a/frontend/src/components/FigureStrip.css +++ b/frontend/src/components/FigureStrip.css @@ -48,6 +48,29 @@ } .fs-overlay img { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: 8px; } .fs-overlay-cap { margin: 10px 0 0; font-size: 0.85rem; line-height: 1.6; color: var(--text-muted); } + +/* The image and anything drawn on it, in one box: the overlay is positioned + against this, so it has to be the size of the picture rather than the + panel around it. */ +.fs-overlay-frame { position: relative; display: block; width: fit-content; margin: 0 auto; } +.fs-overlay-mark { + margin-left: auto; padding: 4px 10px; font-size: .78rem; font-weight: 600; + border: 1px solid var(--border); border-radius: 999px; + background: none; color: var(--text-muted); cursor: pointer; +} +.fs-overlay-mark.is-on { border-color: var(--primary); color: var(--primary); } + +/* What the library holds, under what the question said. Set apart, because + they are two different voices: one was written for this question, the other + catalogues the image wherever it turns up. */ +.fs-overlay-shelf { + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); + font-size: .82rem; line-height: 1.6; color: var(--text-muted); +} +.fs-overlay-shelf strong { display: block; color: var(--text); font-size: .88rem; } +.fs-overlay-shelf p { margin: 4px 0 0; } +.fs-overlay-source { font-size: .78rem; } +.fs-overlay-source a { color: var(--primary); } .fs-overlay-nav { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 12px; font-size: 0.82rem; color: var(--text-muted); } .fs-overlay-nav button { min-height: 38px; padding: 8px 14px; font: inherit; font-size: 0.82rem; diff --git a/frontend/src/components/FigureStrip.jsx b/frontend/src/components/FigureStrip.jsx index 8724004..ea38536 100644 --- a/frontend/src/components/FigureStrip.jsx +++ b/frontend/src/components/FigureStrip.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { uploadUrl } from '../utils/uploads' +import ImageOverlay from './ImageOverlay' import './FigureStrip.css' /** @@ -16,6 +17,12 @@ import './FigureStrip.css' */ export default function FigureStrip({ figures, attemptId, size = 'full', label }) { const [open, setOpen] = useState(null) + //: Whether the educator's marks are drawn. Off until asked for, like every + //: other overlay: marks shown before somebody has looked answer the question + //: for them. + const [marked, setMarked] = useState(false) + + useEffect(() => { setMarked(false) }, [open]) useEffect(() => { if (open === null) return undefined @@ -26,6 +33,13 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label } if (!figures?.length) return null const shown = figures[open] + // What the library knows about this image. Sent on explanation figures only, + // so it appears after the answer and never beside a stem. The thumbnail is + // unaffected either way: in the explanation it shows what the *question* + // says about the figure, which is what an educator wrote for this question. + const shelf = shown?.library || null + const shapes = shelf?.overlay?.shapes + const hasShapes = Array.isArray(shapes) && shapes.length > 0 return ( <> @@ -59,11 +73,35 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label } onClick={e => e.target === e.currentTarget && setOpen(null)}>
- {shown.label} + {shown.label || shelf?.title} + {hasShapes && ( + + )}
- {shown.caption + + {shown.caption + {marked && hasShapes && } + {shown.caption &&

{shown.caption}

} + {/* Everything the library holds, once the answer is in: what it was + filed as, the description written for it, and where it came + from. The thumbnail keeps the question's own words. */} + {shelf && (shelf.title || shelf.caption || shelf.source) && ( +
+ {shelf.title && shelf.title !== shown.label && {shelf.title}} + {shelf.caption && shelf.caption !== shown.caption &&

{shelf.caption}

} + {shelf.source && ( +

+ Source: {shelf.source_url + ? {shelf.source} + : shelf.source} +

+ )} +
+ )} {figures.length > 1 && (