feat: an opened explanation figure shows what the library knows
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 38s
Tests / e2e (push) Failing after 43s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 05:05:59 +02:00
parent 954b13e7b3
commit 094bab20cd
5 changed files with 142 additions and 2 deletions

View file

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

View file

@ -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.",

View file

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

View file

@ -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)}>
<div className="fs-overlay-inner">
<div className="fs-overlay-head">
<strong>{shown.label}</strong>
<strong>{shown.label || shelf?.title}</strong>
{hasShapes && (
<button type="button" className={`fs-overlay-mark${marked ? ' is-on' : ''}`}
aria-pressed={marked} onClick={() => setMarked(v => !v)}> Overlay</button>
)}
<button type="button" onClick={() => setOpen(null)} aria-label="Close figure"></button>
</div>
<img src={uploadUrl(shown.path, attemptId)} alt={shown.caption || shown.label} />
<span className="fs-overlay-frame">
<img src={uploadUrl(shown.path, attemptId)}
alt={shown.caption || shelf?.alt_text || shown.label} />
{marked && hasShapes && <ImageOverlay overlay={shelf.overlay} />}
</span>
{shown.caption && <p className="fs-overlay-cap">{shown.caption}</p>}
{/* 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) && (
<div className="fs-overlay-shelf">
{shelf.title && shelf.title !== shown.label && <strong>{shelf.title}</strong>}
{shelf.caption && shelf.caption !== shown.caption && <p>{shelf.caption}</p>}
{shelf.source && (
<p className="fs-overlay-source">
Source: {shelf.source_url
? <a href={shelf.source_url} target="_blank" rel="noopener noreferrer">{shelf.source}</a>
: shelf.source}
</p>
)}
</div>
)}
{figures.length > 1 && (
<div className="fs-overlay-nav">
<button type="button" disabled={open === 0}

View file

@ -56,3 +56,42 @@ describe('figures on a question', () => {
expect(screen.queryByRole('button', { name: 'Next ' })).not.toBeInTheDocument()
})
})
it('shows the library record when an explanation figure is opened, never on the thumbnail', async () => {
// Two voices. The thumbnail carries what the *question* says about the
// figure words an educator chose for this question and the opened
// viewer adds what the library holds: what it was filed as, the description
// written when it was catalogued, and where it came from. The library half
// rides only on explanation rows, which the server withholds until answers
// are revealed, so none of it can sit beside a stem.
render(<FigureStrip attemptId={7} figures={[{
id: 1, role: 'explanation', path: 'x.png',
label: 'Figure 1', caption: 'AP pelvis after fixation.',
library: {
title: 'Right SCFE, AP pelvis',
caption: 'An X-ray of a child\u2019s pelvis showing the right hip.',
source: 'Teaching file', source_url: 'https://example.org/x',
},
}]} />)
// Nothing of the library on the thumbnail.
expect(screen.queryByText(/Right SCFE/)).toBeNull()
expect(screen.queryByText(/Teaching file/)).toBeNull()
expect(screen.getByText('AP pelvis after fixation.')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /Open Figure 1/ }))
const viewer = screen.getByRole('dialog')
expect(within(viewer).getByText('Right SCFE, AP pelvis')).toBeInTheDocument()
expect(within(viewer).getByText(/pelvis showing the right hip/)).toBeInTheDocument()
expect(within(viewer).getByRole('link', { name: 'Teaching file' }))
.toHaveAttribute('href', 'https://example.org/x')
})
it('carries nothing from the library on a stem figure', () => {
// The server sends `library` only for explanation rows; if that ever
// changed, the stem must still show only the question's own words.
render(<FigureStrip attemptId={7} figures={[{
id: 2, role: 'stem', path: 'y.png', label: null, caption: null, library: null,
}]} />)
expect(screen.queryByRole('button', { name: /Overlay/ })).toBeNull()
})