diff --git a/frontend/src/components/OverlayEditor.css b/frontend/src/components/OverlayEditor.css
new file mode 100644
index 0000000..b8296ac
--- /dev/null
+++ b/frontend/src/components/OverlayEditor.css
@@ -0,0 +1,123 @@
+/* The editor is dark whatever the page theme is. It exists to show radiographs
+ and photographs of skin, and a white or cream frame around a grey film pulls
+ the eye and shifts how the greys read. The page's theme variables stop at
+ the door; the colours in here are literals on purpose. */
+
+.ovl {
+ position: fixed; inset: 0; z-index: 1100;
+ display: grid; grid-template-rows: auto minmax(0, 1fr);
+ background: #0f1216; color: #e6e9ee;
+ overscroll-behavior: contain;
+ font-size: 0.92rem;
+}
+.ovl:focus { outline: none; }
+
+.ovl button {
+ min-height: 40px; min-width: 40px; padding: 0 12px;
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+ font: inherit; color: inherit; cursor: pointer;
+ background: #1a1f26; border: 1px solid #343b47; border-radius: 8px;
+}
+.ovl button:hover { border-color: #4a5364; }
+.ovl button:disabled { opacity: 0.45; cursor: default; }
+.ovl button:focus-visible { outline: 2px solid #5eead4; outline-offset: 2px; }
+.ovl button[aria-pressed="true"] { border-color: #5eead4; background: #12302d; color: #ffffff; }
+
+.ovl-bar {
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ padding: 8px 12px; border-bottom: 1px solid #262c35;
+}
+.ovl-bar h2 { margin: 0; font-size: 1rem; font-weight: 600; }
+.ovl-actions { display: flex; gap: 8px; }
+.ovl-save { background: #5eead4; color: #062b26; border-color: transparent; font-weight: 600; }
+.ovl-save:hover { background: #7ff0dd; border-color: transparent; }
+
+.ovl-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; min-height: 0; }
+
+.ovl-stage {
+ position: relative; display: flex; align-items: center; justify-content: center;
+ padding: 12px; min-width: 0; min-height: 0; overflow: hidden;
+}
+/* Kept invisible until measured: for one frame before the image reports its
+ size the frame has no dimensions of its own, and a full-size image flashing
+ past before it snaps to fit is worse than a short blank. */
+.ovl-frame {
+ position: relative; max-width: 100%; max-height: 100%;
+ opacity: 0; transition: opacity 120ms ease-out;
+ user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
+}
+.ovl-frame.is-ready { opacity: 1; }
+.ovl-frame img { display: block; width: 100%; height: 100%; max-width: 100%; max-height: 100%; }
+
+/* touch-action: none is what lets a finger draw instead of scrolling; the
+ pointer handlers cannot stop a scroll once the browser has started one. */
+.ovl-surface {
+ position: absolute; inset: 0; width: 100%; height: 100%;
+ touch-action: none; cursor: crosshair; overflow: visible;
+}
+.ovl-failed { color: #9aa3b2; }
+
+.ovl-panel {
+ display: flex; flex-direction: column; gap: 12px; padding: 12px;
+ border-left: 1px solid #262c35; overflow-y: auto; min-height: 0;
+}
+
+.ovl-tools { display: flex; gap: 6px; flex-wrap: wrap; }
+.ovl-tool { flex: 1 1 auto; }
+
+.ovl-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
+.ovl-swatches, .ovl-widths { display: flex; gap: 6px; }
+.ovl-swatch { padding: 0; }
+.ovl-swatch i { display: block; width: 20px; height: 20px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); }
+.ovl-width { padding: 0; }
+.ovl-width i { display: block; width: 22px; border-radius: 3px; background: currentColor; }
+
+.ovl-list-toggle { display: none; }
+
+.ovl-empty { margin: 0; color: #9aa3b2; line-height: 1.45; }
+.ovl-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
+.ovl-list li {
+ display: flex; align-items: center; gap: 8px;
+ padding: 4px 4px 4px 10px; border: 1px solid #262c35; border-radius: 8px;
+}
+.ovl-list li.is-active { border-color: #5eead4; }
+.ovl-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; }
+.ovl-kind { flex: none; min-width: 5.5em; }
+.ovl-label {
+ flex: 1 1 60px; min-width: 0; min-height: 40px; padding: 0 8px;
+ font: inherit; color: inherit; background: #0f1216;
+ border: 1px solid #343b47; border-radius: 6px;
+}
+.ovl-label:focus { outline: 2px solid #5eead4; outline-offset: -1px; }
+.ovl-remove { flex: none; padding: 0; }
+.ovl-remove:hover { color: #fca5a5; border-color: #fca5a5; }
+
+.ovl-ask-backdrop {
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+ padding: 16px; background: rgba(0, 0, 0, 0.55);
+}
+.ovl-ask {
+ width: min(360px, 100%); padding: 16px;
+ background: #1a1f26; border: 1px solid #343b47; border-radius: 12px;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
+}
+.ovl-ask p { margin: 0 0 12px; font-weight: 600; }
+.ovl-ask-actions { display: flex; gap: 8px; justify-content: flex-end; }
+.ovl-discard { color: #fca5a5; }
+
+/* On a phone the panel moves under the image and gives up most of its height:
+ the tools stay in one scrollable row, and the list of shapes is behind a
+ toggle so that four rectangles do not push the film off the screen. */
+@media (max-width: 760px) {
+ .ovl-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
+ .ovl-panel { border-left: 0; border-top: 1px solid #262c35; max-height: 45dvh; padding: 8px; gap: 8px; }
+ .ovl-tools { flex-wrap: nowrap; overflow-x: auto; }
+ .ovl-tool { flex: none; }
+ .ovl-tool-name { display: none; }
+ .ovl-list-toggle { display: inline-flex; justify-content: space-between; }
+ .ovl-list-toggle::after { content: '▾'; }
+ .ovl-panel.is-open .ovl-list-toggle::after { content: '▴'; }
+ .ovl-list-wrap { display: none; }
+ .ovl-panel.is-open .ovl-list-wrap { display: block; }
+ .ovl-kind { min-width: 0; }
+}
diff --git a/frontend/src/components/OverlayEditor.jsx b/frontend/src/components/OverlayEditor.jsx
new file mode 100644
index 0000000..1765d8e
--- /dev/null
+++ b/frontend/src/components/OverlayEditor.jsx
@@ -0,0 +1,416 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { uploadUrl } from '../utils/uploads'
+import './OverlayEditor.css'
+
+// The marks are stored as vectors, not burnt into a second copy of the image,
+// because a learner has to be able to switch them off and look first. The
+// format is docs/image-overlays.md; this editor produces it and nothing else.
+
+// Teal first: it is the one hue that stays visible against every grey on a
+// radiograph. Amber is the contract's own second example. The rest are for
+// telling several marks apart on one image, not for meaning.
+export const COLOURS = [
+ { hex: '#5eead4', name: 'Teal' },
+ { hex: '#f0a53d', name: 'Amber' },
+ { hex: '#f87171', name: 'Red' },
+ { hex: '#60a5fa', name: 'Blue' },
+ { hex: '#ffffff', name: 'White' },
+]
+
+// Fractions of the image width, so a "medium" line carries the same relative
+// weight in a 256px thumbnail and on a projector.
+export const WIDTHS = [
+ { value: 0.003, name: 'Thin' },
+ { value: 0.006, name: 'Medium' },
+ { value: 0.012, name: 'Thick' },
+]
+
+const TOOLS = [
+ { kind: 'path', name: 'Freehand' },
+ { kind: 'rect', name: 'Rectangle' },
+ { kind: 'ellipse', name: 'Ellipse' },
+ { kind: 'arrow', name: 'Arrow' },
+]
+const KIND_NAME = Object.fromEntries(TOOLS.map((t) => [t.kind, t.name]))
+
+// Four decimal places is a fifth of a pixel on the largest image the server
+// keeps; anything finer only makes the JSON longer.
+const round = (n) => Math.round(n * 10000) / 10000
+const clamp01 = (n) => Math.min(1, Math.max(0, n))
+const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1])
+
+// Hand jitter at rest is well under this; a genuine stroke moves further
+// between events even on a slow phone.
+const MIN_STEP = 0.0025
+
+let seq = 0
+const nextId = () => ++seq
+
+// Where the pointer is as a fraction of the surface, measured fresh on every
+// event rather than cached, so a window resized mid-session or a phone turned
+// sideways cannot leave the stored numbers pointing at the wrong place.
+function normalise(svg, e) {
+ const r = svg.getBoundingClientRect()
+ if (!r.width || !r.height) return null
+ return [round(clamp01((e.clientX - r.left) / r.width)), round(clamp01((e.clientY - r.top) / r.height))]
+}
+
+function extend(d, p) {
+ if (d.kind === 'path') {
+ if (dist(p, d.points[d.points.length - 1]) < MIN_STEP) return d
+ return { ...d, points: [...d.points, p] }
+ }
+ return { ...d, last: p }
+}
+
+// A tap is not a shape. Each kind has its own idea of "too small to mean
+// anything", and dropping those here is what keeps an accidental touch on a
+// phone from leaving an invisible mark in the list.
+function finish(d) {
+ const base = { kind: d.kind, color: d.color, width: d.width }
+ const [sx, sy] = d.start
+ const [lx, ly] = d.last
+ switch (d.kind) {
+ case 'path':
+ return d.points.length >= 2 ? { ...base, points: d.points } : null
+ case 'arrow':
+ return dist(d.start, d.last) >= 0.01 ? { ...base, points: [d.start, d.last] } : null
+ case 'rect': {
+ const w = round(Math.abs(lx - sx))
+ const h = round(Math.abs(ly - sy))
+ return w >= 0.005 && h >= 0.005 ? { ...base, x: round(Math.min(sx, lx)), y: round(Math.min(sy, ly)), w, h } : null
+ }
+ case 'ellipse': {
+ const rx = round(Math.abs(lx - sx) / 2)
+ const ry = round(Math.abs(ly - sy) / 2)
+ return rx >= 0.0025 && ry >= 0.0025 ? { ...base, cx: round((sx + lx) / 2), cy: round((sy + ly) / 2), rx, ry } : null
+ }
+ default:
+ return null
+ }
+}
+
+// Only the fields the contract names leave this component. Anything else that
+// was on a stored shape is dropped on the next save rather than carried along.
+function serialise(s) {
+ const out = { kind: s.kind }
+ if (s.kind === 'rect') Object.assign(out, { x: s.x, y: s.y, w: s.w, h: s.h })
+ else if (s.kind === 'ellipse') Object.assign(out, { cx: s.cx, cy: s.cy, rx: s.rx, ry: s.ry })
+ else out.points = s.points
+ out.color = s.color
+ out.width = s.width
+ const label = (s.label || '').trim()
+ if (label) out.label = label
+ return out
+}
+
+function fromOverlay(overlay) {
+ return (overlay?.shapes || [])
+ .filter((s) => s && KIND_NAME[s.kind])
+ .map((s) => ({ ...s, id: nextId(), color: s.color || COLOURS[0].hex, width: s.width || WIDTHS[1].value, label: s.label || '' }))
+}
+
+// The head is worked out in pixels and mapped back, because the unit-square
+// viewBox is stretched to the image's aspect ratio and an angle chosen in
+// normalised space would come out skewed on anything that is not square.
+function arrowHead([a, b], box, strokePx) {
+ const W = box.w || 1
+ const H = box.h || 1
+ const ax = a[0] * W, ay = a[1] * H, bx = b[0] * W, by = b[1] * H
+ const angle = Math.atan2(by - ay, bx - ax)
+ const len = Math.max(10, strokePx * 4)
+ const wing = (spread) => [(bx - len * Math.cos(angle + spread)) / W, (by - len * Math.sin(angle + spread)) / H]
+ return [wing(Math.PI / 7), b, wing(-Math.PI / 7)]
+}
+
+const toPoints = (pts) => pts.map((p) => p.join(',')).join(' ')
+
+function Shape({ shape, box, halo }) {
+ // The stroke is drawn in screen pixels (non-scaling-stroke) rather than in
+ // viewBox units: a unit-square viewBox stretched over a 3:2 image would
+ // otherwise make horizontal strokes half again as heavy as vertical ones.
+ const px = box.w ? shape.width * box.w : 2
+ const passes = halo
+ ? [{ stroke: '#ffffff', strokeWidth: px + 6, opacity: 0.55 }, { stroke: shape.color, strokeWidth: px }]
+ : [{ stroke: shape.color, strokeWidth: px }]
+ return passes.map((pass, i) => {
+ const p = { fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', ...pass }
+ switch (shape.kind) {
+ case 'path':
+ return
+ // constrained by max-height inside a shrink-wrapped box does not give its
+ // wrapper the same size in every browser.
+ const fit = useCallback(() => {
+ const stage = stageRef.current
+ const img = imgRef.current
+ if (!stage || !img || !img.naturalWidth) return
+ const cs = getComputedStyle(stage)
+ const pad = (v) => parseFloat(v) || 0
+ const sw = stage.clientWidth - pad(cs.paddingLeft) - pad(cs.paddingRight)
+ const sh = stage.clientHeight - pad(cs.paddingTop) - pad(cs.paddingBottom)
+ if (sw <= 0 || sh <= 0) return
+ const s = Math.min(sw / img.naturalWidth, sh / img.naturalHeight)
+ setBox({ w: Math.floor(img.naturalWidth * s), h: Math.floor(img.naturalHeight * s) })
+ }, [])
+
+ useEffect(() => {
+ rootRef.current?.focus()
+ if (imgRef.current?.complete) fit()
+ const stage = stageRef.current
+ let ro
+ if (typeof ResizeObserver !== 'undefined' && stage) {
+ ro = new ResizeObserver(fit)
+ ro.observe(stage)
+ }
+ window.addEventListener('resize', fit)
+ return () => {
+ ro?.disconnect()
+ window.removeEventListener('resize', fit)
+ }
+ }, [fit])
+
+ // A finger that misses the surface would otherwise scroll the page under a
+ // full-screen editor.
+ useEffect(() => {
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => { document.body.style.overflow = prev }
+ }, [])
+
+ const requestClose = useCallback(() => {
+ if (asking) { setAsking(false); return }
+ if (dirty) setAsking(true)
+ else onClose()
+ }, [asking, dirty, onClose])
+
+ useEffect(() => {
+ const onKey = (e) => {
+ if (e.key !== 'Escape') return
+ e.preventDefault()
+ requestClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [requestClose])
+
+ const commit = (next) => {
+ setPast((p) => [...p, shapes])
+ setShapes(next)
+ }
+ const undo = () => {
+ if (!past.length) return
+ setShapes(past[past.length - 1])
+ setPast(past.slice(0, -1))
+ }
+ const remove = (id) => commit(shapes.filter((s) => s.id !== id))
+ // Labels stay out of the undo history: undoing a drawing one keystroke at a
+ // time is not what anyone reaching for Undo wants.
+ const relabel = (id, label) => setShapes(shapes.map((s) => (s.id === id ? { ...s, label } : s)))
+
+ // Pointer events rather than mouse or touch: one code path serves a mouse,
+ // a finger and a stylus, and capturing the pointer keeps a stroke alive when
+ // a fast hand runs off the edge of the image.
+ const onPointerDown = (e) => {
+ if (draft) return
+ if (e.pointerType === 'mouse' && e.button !== 0) return
+ const p = normalise(svgRef.current, e)
+ if (!p) return
+ e.preventDefault()
+ try { e.currentTarget.setPointerCapture(e.pointerId) } catch { /* not every environment implements capture */ }
+ setDraft({ kind: tool, color, width, start: p, last: p, points: [p], pointerId: e.pointerId })
+ }
+ const onPointerMove = (e) => {
+ if (!draft || e.pointerId !== draft.pointerId) return
+ const p = normalise(svgRef.current, e)
+ if (p) setDraft((d) => (d ? extend(d, p) : d))
+ }
+ const onPointerUp = (e) => {
+ if (!draft || e.pointerId !== draft.pointerId) return
+ const p = normalise(svgRef.current, e)
+ const done = finish(p ? extend(draft, p) : draft)
+ setDraft(null)
+ if (done) commit([...shapes, { ...done, id: nextId(), label: '' }])
+ }
+ const onPointerCancel = () => setDraft(null)
+
+ const save = () => onSave(shapes.length ? { shapes: shapes.map(serialise) } : null)
+
+ const live = draft ? finish(draft) : null
+
+ return (
+
The image could not be loaded.
+ ) : ( +Discard your changes?
+