diff --git a/backend/alembic/versions/l2c3d4e5f6a7_media_source.py b/backend/alembic/versions/l2c3d4e5f6a7_media_source.py
new file mode 100644
index 0000000..48c978b
--- /dev/null
+++ b/backend/alembic/versions/l2c3d4e5f6a7_media_source.py
@@ -0,0 +1,40 @@
+"""Where an image came from, and what has been marked on it
+
+`source` is the citation as it should be read — figure, authors, publication,
+licence — and `source_url` is where to check it. `overlay` holds the regions an
+educator has marked on the image, as vector shapes in normalised coordinates so
+they land in the right place at any size. Both on the asset rather than
+typed into each caption: the same figure used in three articles is cited the
+same way in all three, and a licence that turns out to be wrong is one row to
+fix rather than three paragraphs to find.
+
+Revision ID: l2c3d4e5f6a7
+Revises: k1b2c3d4e5f6
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "l2c3d4e5f6a7"
+down_revision = "k1b2c3d4e5f6"
+branch_labels = None
+depends_on = None
+
+COLUMNS = {
+ "source": sa.Column("source", sa.Text(), nullable=True),
+ "source_url": sa.Column("source_url", sa.String(length=600), nullable=True),
+ # Vector shapes in normalised coordinates, not a second raster: an overlay
+ # has to be switchable, correctable and correct at any size.
+ "overlay": sa.Column("overlay", sa.JSON(), nullable=True),
+}
+
+
+def upgrade() -> None:
+ existing = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("media_assets")}
+ for name, column in COLUMNS.items():
+ if name not in existing:
+ op.add_column("media_assets", column)
+
+
+def downgrade() -> None:
+ for name in COLUMNS:
+ op.drop_column("media_assets", name)
diff --git a/backend/app/models/media.py b/backend/app/models/media.py
index 082bf69..901289d 100644
--- a/backend/app/models/media.py
+++ b/backend/app/models/media.py
@@ -1,6 +1,7 @@
from datetime import datetime
-from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import (Column, DateTime, ForeignKey, Integer, JSON, String, Text,
+ UniqueConstraint)
from app.database import Base
from app.models.embeddable import Embeddable
@@ -21,6 +22,21 @@ class MediaAsset(Base, Embeddable):
title = Column(String(300), nullable=True)
caption = Column(Text, nullable=True)
alt_text = Column(Text, nullable=True)
+ # Where the image came from, and where to check that. Kept on the asset
+ # rather than typed into a caption because a citation belongs to the file:
+ # the same figure used in three articles is cited the same way in all
+ # three, and a licence that turns out to be wrong is one row to fix.
+ source = Column(Text, nullable=True)
+ source_url = Column(String(600), nullable=True)
+ # Regions an educator has marked on the image, as vector shapes in
+ # normalised coordinates — see `docs/image-overlays.md`. Vectors rather
+ # than a second burnt-in picture: a raster overlay cannot be turned off,
+ # cannot be corrected without redrawing it, doubles the storage, and looks
+ # wrong at any size but the one it was drawn at.
+ #
+ # {"shapes": [{"kind": "path", "points": [[0.31, 0.52], …],
+ # "color": "#5eead4", "width": 0.006, "label": "lead lines"}]}
+ overlay = Column(JSON, nullable=True)
kind = Column(String(20), default="image")
library_id = Column(Integer, ForeignKey("media_libraries.id", ondelete="SET NULL"), nullable=True, index=True)
# Which backend holds the bytes; reads fall back to the volume either way.
diff --git a/backend/app/routers/media.py b/backend/app/routers/media.py
index 7fe3aef..27c31d6 100644
--- a/backend/app/routers/media.py
+++ b/backend/app/routers/media.py
@@ -70,6 +70,9 @@ def _asset_json(asset: MediaAsset, tags: list[str]) -> dict:
"title": asset.title,
"caption": asset.caption,
"alt_text": asset.alt_text,
+ "source": asset.source,
+ "source_url": asset.source_url,
+ "overlay": asset.overlay,
"kind": asset.kind,
"library_id": asset.library_id,
"category_id": asset.category_id,
@@ -191,6 +194,31 @@ def list_media(
{**_asset_json(a, tags.get(a.id, [])), "used_by": used.get(a.id, 0)} for a in assets]}
+@router.get("/by-path")
+def media_by_path(path: str = Query(...), db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ """What is known about the image at this path: label, description, source.
+
+ The reader's viewer asks for this when a figure is opened, not when the page
+ is drawn — a page of prose with six figures in it should cost six requests
+ only if somebody opens all six.
+
+ Answers 404 rather than 403 for an image in a library this person cannot
+ use: whether a private library holds a given filename is not a question
+ this endpoint should answer.
+ """
+ cleaned = (path or "").strip().lstrip("/")
+ if cleaned.startswith("uploads/"):
+ cleaned = cleaned[len("uploads/"):]
+ asset = db.query(MediaAsset).filter(MediaAsset.path == cleaned).first()
+ if not asset:
+ raise HTTPException(404, "Image not found")
+ scope = readable_libraries(db, current_user)
+ if scope is not None and asset.library_id is not None and asset.library_id not in scope:
+ raise HTTPException(404, "Image not found")
+ return _asset_json(asset, _tags_for(db, [asset.id]).get(asset.id, []))
+
+
@router.post("/upload", status_code=201)
def upload_media(
file: UploadFile = File(...),
@@ -198,6 +226,8 @@ def upload_media(
title: str | None = Form(None),
caption: str | None = Form(None),
alt_text: str | None = Form(None),
+ source: str | None = Form(None),
+ source_url: str | None = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -246,6 +276,10 @@ class MediaUpdate(BaseModel):
title: str | None = None
caption: str | None = None
alt_text: str | None = None
+ source: str | None = None
+ source_url: str | None = None
+ #: Vector shapes in normalised coordinates; see docs/image-overlays.md.
+ overlay: dict | None = None
library_id: int | None = None
category_id: int | None = None
tags: list[str] | None = None
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 96e6205..3d60393 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -4,7 +4,7 @@ import logging
import time
import os
-from sqlalchemy import text as sa_text
+from sqlalchemy import or_, text as sa_text
from app.tasks import celery_app
from app.database import SessionLocal
@@ -351,6 +351,131 @@ def extract_quiz(
db.close()
+#: What each label means, in the prompt and in the docs, so the three words
+#: mean the same thing to the model, to the learner reading a filter, and to
+#: whoever disagrees with a label later.
+DIFFICULTY_RUBRIC = """easy — one step. The stem names a classic presentation and the answer is
+ recall: a diagnosis with a pathognomonic finding, a first-line drug, a
+ standard schedule. A prepared candidate answers without working anything out.
+medium — two steps, or one step under noise. The finding has to be interpreted
+ before it can be used, a distractor is genuinely plausible, or the question
+ asks for the *next* action rather than the diagnosis.
+hard — several steps, or a judgement. An atypical presentation, a decision
+ where two answers are defensible and one is better, an exception to the rule
+ the candidate learned, or arithmetic on top of interpretation."""
+
+DIFFICULTY_PROMPT = """You are labelling pediatric board-exam questions by how hard they are to
+answer correctly, for a candidate who has studied the material.
+
+Judge the *question*, not the topic. A rare disease with a give-away finding is
+easy; a common one where two managements are defensible is not.
+
+""" + DIFFICULTY_RUBRIC + """
+
+Return ONLY a JSON array, one object per question, no prose:
+[{"id": 12, "difficulty": "easy"}, ...]
+Every id you were given must appear exactly once.
+
+QUESTIONS
+"""
+
+#: Small enough that one bad reply costs little and the JSON stays inside the
+#: reply limit; large enough that 3,000 questions is a hundred calls and not
+#: three thousand.
+DIFFICULTY_BATCH = 25
+
+
+@celery_app.task(name="classify_question_difficulty", bind=True)
+def classify_question_difficulty(self, job_id: str = "", limit: int | None = None,
+ relabel: bool = False) -> dict:
+ """Give every question a difficulty, in batches, resumably.
+
+ The column existed and was NULL on all 2,924 rows, which made the filter in
+ the test builder a control that could only ever empty the bank, and made
+ "move the session along the difficulty range" impossible to build. Nothing
+ was ever going to write it by hand.
+
+ Resumable by construction: only rows with no difficulty are asked about
+ unless `relabel` is set, so a run that dies halfway is continued by running
+ it again. Each batch is committed on its own — a failure late in a long run
+ keeps everything the earlier batches decided.
+ """
+ from app.models.question import Question
+ from app.services.ai_service import get_model_for_task, chat
+
+ r = _redis()
+ if job_id:
+ r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
+ db = SessionLocal()
+ labelled, failed_batches = 0, 0
+ try:
+ model_id, api_key = get_model_for_task(db, "keyword")
+ query = db.query(Question).order_by(Question.id)
+ if not relabel:
+ query = query.filter(or_(Question.difficulty.is_(None), Question.difficulty == ""))
+ rows = query.limit(limit).all() if limit else query.all()
+ total = len(rows)
+ if job_id:
+ _push_step(r, job_id, "start", f"{total} questions to label")
+
+ for start in range(0, total, DIFFICULTY_BATCH):
+ batch = rows[start:start + DIFFICULTY_BATCH]
+ payload = []
+ for question in batch:
+ options = question.options if isinstance(question.options, list) else []
+ payload.append({
+ "id": question.id,
+ # Trimmed: the shape of the reasoning is in the first
+ # paragraph and the options, and a full vignette times
+ # twenty-five is a reply nobody needs to pay for.
+ "stem": (question.question_text or "")[:700],
+ "options": [str(option)[:120] for option in options[:6]],
+ "answer": (question.correct_answer or "")[:120],
+ })
+ prompt = DIFFICULTY_PROMPT + json.dumps(payload, ensure_ascii=False)
+ try:
+ raw = (chat(model=model_id, messages=[{"role": "user", "content": prompt}],
+ max_tokens=1500, temperature=0, api_key=api_key) or "").strip()
+ if raw.startswith("```"):
+ raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
+ raw = raw[:-3] if raw.endswith("```") else raw
+ verdicts = json.loads(raw.strip())
+ except Exception as exc:
+ failed_batches += 1
+ logger.warning("Difficulty batch at %s failed: %s", start, exc)
+ if job_id:
+ _push_step(r, job_id, "warn", f"A batch of {len(batch)} could not be read; skipped")
+ continue
+
+ by_id = {question.id: question for question in batch}
+ for verdict in verdicts if isinstance(verdicts, list) else []:
+ question = by_id.get(verdict.get("id"))
+ level = str(verdict.get("difficulty", "")).strip().lower()
+ # Anything that is not one of the three words is not a label.
+ # A model that answers "moderate" has not answered.
+ if question is None or level not in ("easy", "medium", "hard"):
+ continue
+ question.difficulty = level
+ labelled += 1
+ db.commit()
+ if job_id:
+ _push_step(r, job_id, "batch",
+ f"{min(start + DIFFICULTY_BATCH, total)} of {total} · {labelled} labelled")
+
+ if job_id:
+ r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "done", f"{labelled} questions labelled")
+ return {"labelled": labelled, "considered": total, "failed_batches": failed_batches}
+ except Exception as exc:
+ logger.warning("Difficulty run failed: %s", exc)
+ if job_id:
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
+ raise
+ finally:
+ db.close()
+
+
@celery_app.task(name="apply_topic_claims")
def apply_topic_claims() -> dict:
"""Re-apply every article's topic claim. The safety net, not the mechanism.
diff --git a/docs/TODO.md b/docs/TODO.md
index 5d4c33b..1e6e5f9 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -88,7 +88,15 @@ Captured so nothing is lost while the article writing runs.
that system exists to hold them, which is a classification gap rather
than a filing error. `Radial Head Subluxation` merged into
`Nursemaid's Elbow (Radial Head Subluxation)` — one idea under two names.
-- [x] **Adaptive session** — closed 2026-09-12, both remaining halves settled.
+- [ ] **Adaptive session: the difficulty ladder** — reopened 2026-09-12, hours
+ after being closed as unbuildable, because the reason it was unbuildable
+ is gone: every one of the 2,924 questions now carries a difficulty (622
+ easy, 1,634 medium, 668 hard, no failures), labelled in batches of 25
+ against a written rubric by `classify_question_difficulty`. The ordering
+ can now move a session along the range as a learner does well or badly,
+ which is the thing the design has always been missing. The rest of the
+ item, below, stays closed.
+- [x] **Adaptive session (the other halves)** — closed 2026-09-12.
*Shrunk readiness*: already done and now pinned by a test —
`CandidateRanking.accuracy()` pulls a topic towards `NEUTRAL_RECALL` by
`PRIOR_ANSWERS`, and the recommendations page sorts on `readiness` and
@@ -100,8 +108,8 @@ Captured so nothing is lost while the article writing runs.
would be scoring noise while looking as though it worked. What was built
instead is honesty in the control: the Difficulty facet now counts each
level under the other filters and disables one that would empty the
- bank, so nobody picks Hard and watches the count fall to zero. Reopen
- when something actually writes that column.
+ bank, so nobody picks Hard and watches the count fall to zero. **That
+ column is now written** — see the reopened item above.
### Settings and access
- [x] **Settings revamp** — done 2026-09-11. A section list beside one panel,
@@ -308,7 +316,22 @@ Captured so nothing is lost while the article writing runs.
Pillow therefore means re-pinning litellm first, which is a deliberate
upgrade of the AI layer and not a side effect to slip into a thumbnail
change.
-- [ ] ~~Image thumbnails and caching~~ — you mentioned a tool from the ped-ai
+- [x] **Image thumbnails** — done, and the note was stale: `services/thumbnails.py`
+ makes 256px and 640px WebP derivatives with Pillow (not sharp — sharp is
+ Node and this backend is Python), stores them beside the original under
+ `thumbs/{width}/{key}` in the same bucket, and `/uploads/...?w=256`
+ serves them. Only those two widths exist, nothing is ever enlarged, and a
+ derivative that cannot be made falls back to the original rather than
+ failing the request. Every figure in prose now asks for the 256 and opens
+ the original on a click.
+ *Still open, deliberately:* HTTP caching. Uploads answer
+ `Cache-Control: private, no-store`, so nothing is cached anywhere. A
+ shared Caddy cache in front of access-controlled images would be a leak;
+ what is safe is `private, max-age=…` on the derivatives, which is the
+ browser's own cache and no one else's. And "make originals hard to pull"
+ honestly means a signed short-lived URL per view — watermarking and
+ right-click blocking are not that.
+- [ ] ~~Image caching through Caddy~~ — you mentioned a tool from the ped-ai
work that generates thumbnails and caches through Caddy so images load
fast, click to open full size, same bucket, and no straightforward
download of the original. Not started: I need the name of that tool or a
diff --git a/docs/image-overlays.md b/docs/image-overlays.md
new file mode 100644
index 0000000..822a865
--- /dev/null
+++ b/docs/image-overlays.md
@@ -0,0 +1,60 @@
+# Image overlays
+
+An educator can mark regions on an image — the lead lines on a knee film, the
+level of a narrowing, the border of a lesion — and a learner sees them only when
+they ask. This is the contract for how those marks are stored and drawn.
+
+## Why vectors and not a second picture
+
+The obvious implementation is a second PNG with the marks burnt in, shown
+instead of the original. It fails four ways: it cannot be turned off (which is
+the whole point — the learner should look first and check second), it cannot be
+corrected without redrawing, it doubles the storage for every marked image, and
+it is correct at exactly one size. Vectors have none of those problems and draw
+as crisply on a phone as on a monitor.
+
+## The shape of the data
+
+`media_assets.overlay`, JSON, null when nothing is marked:
+
+```json
+{
+ "shapes": [
+ {
+ "kind": "path",
+ "points": [[0.31, 0.52], [0.34, 0.51], [0.38, 0.53]],
+ "color": "#5eead4",
+ "width": 0.006,
+ "label": "Dense metaphyseal bands"
+ },
+ { "kind": "rect", "x": 0.12, "y": 0.4, "w": 0.2, "h": 0.15, "color": "#5eead4" },
+ { "kind": "ellipse", "cx": 0.5, "cy": 0.5, "rx": 0.1, "ry": 0.08, "color": "#f0a53d" },
+ { "kind": "arrow", "points": [[0.2, 0.2], [0.4, 0.35]], "color": "#5eead4" }
+ ]
+}
+```
+
+**Every coordinate is normalised to 0–1** against the image's own width and
+height — never pixels. That is what makes one drawing correct in a 256px
+thumbnail, in the viewer, and on a projector. `width` is normalised too, against
+the image width, so a stroke stays the same relative weight.
+
+Four kinds, and no more without a reason: `path` (freehand or polyline, the one
+that traces an anatomical edge), `rect`, `ellipse`, `arrow` (a `path` of exactly
+two points, drawn with a head). `label` is optional on any shape and is what a
+screen reader is given.
+
+## Drawing it
+
+An SVG with `viewBox="0 0 1 1"` and `preserveAspectRatio="none"`, absolutely
+positioned over the image at the same size. Because the viewBox is the unit
+square, the stored numbers are the SVG's own coordinates and no conversion is
+needed at any size.
+
+## Rules
+
+- **Off by default.** The learner sees the image, and turns the overlay on.
+ Marks shown before the learner has looked answer the question for them.
+- **The original is never modified.** Nothing here writes to the image file.
+- **An overlay is not a caption.** What the marks *mean* belongs in the
+ description; the overlay says where.
diff --git a/frontend/src/components/ImageFigure.css b/frontend/src/components/ImageFigure.css
new file mode 100644
index 0000000..b65a102
--- /dev/null
+++ b/frontend/src/components/ImageFigure.css
@@ -0,0 +1,75 @@
+/* Small until asked for. The caption is the author's own alt text, so what a
+ reader sees under the figure and what a screen reader is told are the same
+ sentence — and there is no second field to keep in step with the first. */
+.imgfig { display: inline-flex; flex-direction: column; gap: 5px; margin: 10px 12px 10px 0; vertical-align: top; max-width: 100%; }
+.imgfig-thumb {
+ display: block; padding: 0; cursor: zoom-in; line-height: 0;
+ border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
+ background: var(--card-bg);
+}
+.imgfig-thumb:hover { border-color: var(--primary); }
+.imgfig-thumb img { display: block; max-width: 260px; max-height: 200px; width: auto; height: auto; }
+.imgfig-label { max-width: 260px; font-size: 0.78rem; line-height: 1.4; color: var(--text-muted); }
+
+/* ── The viewer ───────────────────────────────────────────────────────
+ The image gets the screen, and what is known about it sits beside it. Dark,
+ because a radiograph read against a white page is read through glare. */
+.imgfig-viewer {
+ position: fixed; inset: 0; z-index: 1200;
+ display: flex; flex-direction: column;
+ background: #0b1220; color: #e2e8f0;
+}
+.imgfig-bar {
+ display: flex; align-items: center; gap: 10px;
+ padding: 10px 14px; border-bottom: 1px solid rgba(255, 255, 255, .1);
+}
+.imgfig-tool {
+ display: inline-flex; align-items: center; gap: 7px;
+ padding: 6px 13px; cursor: pointer; font: inherit; font-size: 0.83rem; font-weight: 600;
+ border: 1px solid rgba(255, 255, 255, .22); border-radius: 8px;
+ background: transparent; color: #e2e8f0;
+}
+.imgfig-tool:hover { border-color: #5eead4; color: #5eead4; }
+.imgfig-tool.is-on { border-color: #5eead4; color: #0b1220; background: #5eead4; }
+.imgfig-close {
+ margin-left: auto; width: 34px; height: 34px; cursor: pointer;
+ border: 0; border-radius: 8px; background: transparent; color: #e2e8f0; font-size: 1rem;
+}
+.imgfig-close:hover { background: rgba(255, 255, 255, .12); }
+
+.imgfig-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px minmax(0, 1fr); }
+.imgfig-desc {
+ display: flex; flex-direction: column; gap: 14px;
+ padding: 26px 24px; overflow-y: auto;
+ border-right: 1px solid rgba(255, 255, 255, .1);
+}
+.imgfig-desc strong { font-size: 1.05rem; font-weight: 700; }
+.imgfig-desc-text { font-size: 0.92rem; line-height: 1.6; color: #cbd5e1; }
+.imgfig-source { font-size: 0.78rem; line-height: 1.55; color: #94a3b8; }
+.imgfig-source a { color: #94a3b8; }
+
+.imgfig-stage {
+ display: flex; align-items: center; justify-content: center;
+ padding: 20px; overflow: auto; cursor: zoom-out;
+}
+/* The frame is what the overlay is positioned against, so it must be exactly
+ the size of the drawn image and no larger — hence a block that shrinks to
+ its content rather than a flex child that stretches. */
+.imgfig-frame { position: relative; display: block; line-height: 0; cursor: default; }
+.imgfig-frame img { display: block; max-width: 100%; max-height: calc(100dvh - 110px); width: auto; height: auto; }
+.imgov { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
+
+@media (max-width: 820px) {
+ /* Stacked, description first: it can be read before scrolling to the
+ picture rather than after it, and on a phone the picture wants the width
+ more than the text does. */
+ .imgfig-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); overflow-y: auto; }
+ .imgfig-desc { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1); padding: 16px 16px 14px; gap: 10px; }
+ .imgfig-stage { padding: 12px; }
+ .imgfig-frame img { max-height: none; }
+}
+
+@media (max-width: 560px) {
+ .imgfig { margin-right: 0; }
+ .imgfig-thumb img, .imgfig-label { max-width: 100%; }
+}
diff --git a/frontend/src/components/ImageFigure.jsx b/frontend/src/components/ImageFigure.jsx
new file mode 100644
index 0000000..ba86d13
--- /dev/null
+++ b/frontend/src/components/ImageFigure.jsx
@@ -0,0 +1,116 @@
+import { useEffect, useState } from 'react'
+import api from '../api/client'
+import { uploadUrl } from '../utils/uploads'
+import ImageOverlay from './ImageOverlay'
+import './ImageFigure.css'
+
+/**
+ * A figure in prose: small until you ask for it, and then the whole screen.
+ *
+ * Every image written into markdown — an article's figure, the illustration on
+ * an option's explanation, a diagram in a lesson — used to render at whatever
+ * width it happened to be, which on a 2,000px radiograph meant a wall of
+ * greyscale in the middle of a sentence and four megabytes to draw it. A
+ * reader wants to know a figure is there, and then to look at it properly.
+ *
+ * So: a 256px derivative with the author's label under it, and on a click a
+ * viewer that gives the image the screen and puts what is known about it
+ * beside it — what it shows, and where it came from. What the bank knows is
+ * asked for when the figure is opened rather than when the page is drawn: six
+ * figures in an article should cost six requests only if somebody opens all
+ * six.
+ *
+ * Anything an educator has marked on the image is off until the learner turns
+ * it on. Marks shown before they have looked answer the question for them.
+ */
+export default function ImageFigure({ src, alt = '', attemptId, className = '' }) {
+ const [open, setOpen] = useState(false)
+ const [asset, setAsset] = useState(null)
+ const [marked, setMarked] = useState(false)
+
+ useEffect(() => {
+ if (!open) return undefined
+ const onKey = event => { if (event.key === 'Escape') setOpen(false) }
+ document.addEventListener('keydown', onKey)
+ // The page behind must not scroll while the viewer has the screen.
+ const previous = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.body.style.overflow = previous
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open || asset !== null || !src) return undefined
+ let live = true
+ api.get('/media/by-path', { params: { path: src } })
+ // `false` rather than null: asked and there is nothing, so do not ask again.
+ .then(res => { if (live) setAsset(res.data || false) })
+ .catch(() => { if (live) setAsset(false) })
+ return () => { live = false }
+ }, [open, asset, src])
+
+ if (!src) return null
+ const label = (alt || '').trim()
+ const title = (asset && asset.title) || label
+ const description = (asset && (asset.caption || asset.alt_text)) || (title === label ? '' : label)
+ const overlay = asset && asset.overlay
+ const hasShapes = !!(overlay && Array.isArray(overlay.shapes) && overlay.shapes.length)
+
+ return (
+ <>
+
+
+ {label && {label}}
+
+
+ {open && (
+
+
+ {hasShapes && (
+
+ )}
+
+
+
+
+ {/* The description beside the image on a wide screen, above it on a
+ narrow one — where it can be read before scrolling to the
+ picture, rather than after it. */}
+ {(title || description || (asset && asset.source)) && (
+
+ {title && {title}}
+ {description && {description}}
+ {asset && asset.source && (
+
+ Source: {asset.source_url
+ ? {asset.source}
+ : asset.source}
+
+ )}
+
+ )}
+ {
+ if (event.target === event.currentTarget) setOpen(false)
+ }}>
+
+
+ {marked && hasShapes && }
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/frontend/src/components/ImageOverlay.jsx b/frontend/src/components/ImageOverlay.jsx
new file mode 100644
index 0000000..c873c80
--- /dev/null
+++ b/frontend/src/components/ImageOverlay.jsx
@@ -0,0 +1,53 @@
+/**
+ * The regions an educator has marked on an image.
+ *
+ * An SVG on the unit square laid over the picture: because the viewBox is
+ * `0 0 1 1` and the stored coordinates are normalised, the numbers in the
+ * database *are* the SVG's coordinates, and one drawing is correct in a
+ * thumbnail, in the viewer and on a projector with no conversion anywhere.
+ *
+ * See `docs/image-overlays.md` for the shape of the data.
+ */
+const DEFAULT_COLOR = '#5eead4'
+const DEFAULT_WIDTH = 0.006
+
+const points = (shape) => (Array.isArray(shape.points) ? shape.points : [])
+ .filter(point => Array.isArray(point) && point.length === 2)
+
+export default function ImageOverlay({ overlay }) {
+ const shapes = (overlay && Array.isArray(overlay.shapes)) ? overlay.shapes : []
+ if (!shapes.length) return null
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx
index 986582b..6b10dba 100644
--- a/frontend/src/components/RichText.jsx
+++ b/frontend/src/components/RichText.jsx
@@ -9,7 +9,7 @@ import rehypeHighlightOffsets from '../utils/highlightOffsets'
import remarkTipTerms from '../utils/tipTerms'
import remarkKeyPoints from '../utils/keyPoints'
import TipTerm from './TipTerm'
-import { markdownImageUrl } from '../utils/uploads'
+import ImageFigure from './ImageFigure'
import './RichText.css'
/**
@@ -81,7 +81,12 @@ export default function RichText({
// type every render, and React unmounts and remounts everything it drew — so
// an open tip closed itself each time the exam clock ticked.
const components = useMemo(() => ({
- img: ({ node, src, ...props }) => ,
+ // Every figure in prose is a thumbnail with the author's label under it,
+ // full size on a click. A 2,000px radiograph rendered inline was a wall of
+ // greyscale in the middle of a sentence, and four megabytes to draw it.
+ img: ({ node, src, alt, ...props }) => (
+
+ ),
// `{{phrase|tip}}` — a teaching point that opens where the phrase is.
span: ({ node, children, ...props }) => (
props.className === 'tip-term'
diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx
index e8f42f2..886a1c1 100644
--- a/frontend/src/components/RichText.test.jsx
+++ b/frontend/src/components/RichText.test.jsx
@@ -3,9 +3,14 @@ import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import RichText from './RichText'
+import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: {} })) } }))
-vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => `/uploads/${src}` }))
+vi.mock('../utils/uploads', () => ({
+ markdownImageUrl: (src) => `/uploads/${src}`,
+ uploadUrl: (src, attemptId, width) => `/uploads/${src}${width ? `?w=${width}` : ''}`,
+ THUMB_WIDTHS: [256, 640],
+}))
const mount = (props) => render()
@@ -161,3 +166,60 @@ describe('==key points==', () => {
expect(container.querySelector('mark')).toBeNull()
})
})
+
+describe('figures in prose', () => {
+ it('renders a thumbnail with the label under it, and the full image on a click', async () => {
+ const { container } = mount({ value: '' })
+ const thumb = container.querySelector('.imgfig-thumb img')
+ // A derivative, not the original: a 2MB radiograph to draw a postage stamp
+ // is what this exists to stop.
+ expect(thumb).toHaveAttribute('src', expect.stringContaining('w=256'))
+ expect(thumb).toHaveAttribute('alt', 'Lateral neck film showing the thumb sign')
+ // The author's own alt text is the caption, so the sighted reader and the
+ // screen reader are told the same thing.
+ expect(screen.getByText('Lateral neck film showing the thumb sign')).toBeInTheDocument()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /Open the figure/ }))
+ const viewer = screen.getByRole('dialog')
+ // Full size in the viewer — no width asked for.
+ expect(within(viewer).getByRole('img')).toHaveAttribute('src', expect.not.stringContaining('w='))
+ await userEvent.click(within(viewer).getByRole('button', { name: 'Close' }))
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('leaves an unlabelled figure without a caption', () => {
+ const { container } = mount({ value: '' })
+ expect(container.querySelector('.imgfig-thumb')).toBeInTheDocument()
+ expect(container.querySelector('.imgfig-label')).toBeNull()
+ })
+})
+
+describe('the figure viewer', () => {
+ it('shows what the bank knows: the label, the description and the source', async () => {
+ api.get.mockResolvedValue({ data: {
+ title: 'Lead poisoning', caption: 'X-ray of both knees showing dense metaphyseal bands.',
+ source: 'Ying et al., BMC Pediatrics, CC BY 4.0', source_url: 'https://example.test/paper',
+ overlay: { shapes: [{ kind: 'path', points: [[0.3, 0.5], [0.6, 0.5]] }] },
+ } })
+ mount({ value: '' })
+ await userEvent.click(screen.getByRole('button', { name: /Open the figure/ }))
+ const viewer = await screen.findByRole('dialog', { name: 'Lead poisoning' })
+ expect(within(viewer).getByText('X-ray of both knees showing dense metaphyseal bands.')).toBeInTheDocument()
+ expect(within(viewer).getByRole('link', { name: /Ying et al/ })).toHaveAttribute('href', 'https://example.test/paper')
+
+ // What an educator marked is off until it is asked for: marks shown before
+ // the learner has looked answer the question for them.
+ expect(document.querySelector('.imgov')).toBeNull()
+ await userEvent.click(within(viewer).getByRole('button', { name: /Overlay/ }))
+ expect(document.querySelector('.imgov path')).toBeInTheDocument()
+ })
+
+ it('offers no overlay control when nothing has been marked', async () => {
+ api.get.mockResolvedValue({ data: { title: 'A plain figure' } })
+ mount({ value: '' })
+ await userEvent.click(screen.getByRole('button', { name: /Open the figure/ }))
+ const viewer = await screen.findByRole('dialog')
+ expect(within(viewer).queryByRole('button', { name: /Overlay/ })).toBeNull()
+ })
+})