From 3980ecb7f848820bdd7a99dc2e91d04fa27d50e9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 18:12:37 +0200 Subject: [PATCH] feat: one Markdown renderer for the whole site, with LaTeX and highlights intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every piece of educator prose the platform stores is Markdown, and until now only articles rendered it. A lab panel written as a table reached the quiz player as a row of literal pipes, which is why the table conversion had to be held back. `RichText` is now the single renderer: GFM tables, `$…$` maths through KaTeX, images resolved through the uploads helper, external links opened safely, and raw HTML escaped rather than executed — a stem can never inject markup into the page around it. The question bank's `dangerouslySetInnerHTML` is gone with it. Highlights were the hard part Manual highlights and the read-aloud cursor are stored as character offsets into the raw stem, and rendering Markdown destroys the one-to-one map a plain string gave us. A rehype plugin puts it back: each text node in the output carries the source offsets it was parsed from, so a highlight saved before this change still lands exactly where it was drawn, and the selection arithmetic that reads `data-start` needs no change at all. Inside an inline-formatted run the rendered text is shorter than its source by the marker characters, so an offset picked mid-run can be out by a few. Splitting per text node bounds that to one node and keeps every node boundary exact — stated in the code, because it is a real limit rather than an oversight. With that in place the lab tables are applied: 79 stems, 82 panels. Question 3333 now reads as two tables with `3.5 × 10⁹/L` instead of `3.5 x 109/L`, and the `inEq/L` and `mrnol/L` scanning damage repaired. Each change was snapshotted first, so it is reversible from the question editor. Six schematic illustrations Drawn from scratch as SVG in `scripts/seed_illustrations.py` — bilirubin risk zones, airway narrowing by level, dehydration bands, the fluid pathway, the target sign, growth velocity. Each is captioned, tagged and searchable in the image bank, and each says on its face that it is schematic and not a clinical reference. They exist so the media library, picker and article figures can be exercised against real files, and because an article with no figure looks unfinished even when its prose is not. 234 frontend tests green, 11 of them new on the renderer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/scripts/seed_illustrations.py | 222 ++++++++++++++++++++++ frontend/src/components/RichText.css | 32 ++++ frontend/src/components/RichText.jsx | 84 ++++++++ frontend/src/components/RichText.test.jsx | 118 ++++++++++++ frontend/src/pages/QuestionBankPage.jsx | 7 +- frontend/src/pages/QuizPage.jsx | 87 ++------- frontend/src/utils/highlightOffsets.js | 91 +++++++++ 7 files changed, 573 insertions(+), 68 deletions(-) create mode 100644 backend/scripts/seed_illustrations.py create mode 100644 frontend/src/components/RichText.css create mode 100644 frontend/src/components/RichText.jsx create mode 100644 frontend/src/components/RichText.test.jsx create mode 100644 frontend/src/utils/highlightOffsets.js diff --git a/backend/scripts/seed_illustrations.py b/backend/scripts/seed_illustrations.py new file mode 100644 index 0000000..448f3cd --- /dev/null +++ b/backend/scripts/seed_illustrations.py @@ -0,0 +1,222 @@ +"""Draw a set of schematic teaching illustrations and put them in the image bank. + +These are *diagrams*, not clinical references: drawn from scratch as SVG, labelled +as schematic, and deliberately carrying no numbers that anyone should dose or +diagnose from. They exist so that the media library, the image picker, article +figures and question attachment can be exercised against real files rather than +against placeholders — and because an article with no figure looks unfinished +even when the prose is complete. + +Every drawing is generated here in code, so nothing is copied from anywhere. + + docker compose exec backend python -m scripts.seed_illustrations + docker compose exec backend python -m scripts.seed_illustrations --apply +""" +import sys +import textwrap + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal +from app.models.media import MediaAsset, MediaLibrary, MediaTagLink +from app.services import embedding_service, storage_service + +W, H = 640, 400 +INK = "#0f172a" +MUTED = "#64748b" +LINE = "#cbd5e1" +ACCENT = "#2563eb" +WARN = "#dc2626" +GOOD = "#059669" + + +def _frame(title: str, body: str) -> str: + """Every figure shares one frame, so a set of them looks like a set.""" + return f""" + + {title} + {body} + Schematic — not to scale. Teaching diagram, not a clinical reference. +""" + + +def _label(x, y, s, size=12, fill=MUTED, weight="400", anchor="start"): + return (f'{s}') + + +def _axes(x0, y0, x1, y1, xlabel, ylabel): + return f""" + + + {_label((x0 + x1) / 2, y1 + 30, xlabel, 12, MUTED, "600", "middle")} + {ylabel}""" + + +def bilirubin_zones() -> str: + body = _axes(70, 60, 590, 320, "Age (hours)", "Serum bilirubin") + for index, (offset, colour, name) in enumerate( + [(0, WARN, "High risk"), (44, "#f59e0b", "High-intermediate"), + (88, "#eab308", "Low-intermediate"), (132, GOOD, "Low risk")]): + y_start = 120 + offset + body += (f'') + body += _label(596, y_start + 4, name, 11, colour, "600") + for hour, x in [(24, 200), (48, 330), (72, 460), (96, 585)]: + body += f'' + \ + _label(x, 340, str(hour), 11, MUTED, "400", "middle") + return _frame("Bilirubin risk zones by age", body) + + +def airway_obstruction() -> str: + body = "" + for index, (x, label, narrow, colour) in enumerate( + [(120, "Normal", 0, GOOD), (320, "Subglottic narrowing", 20, ACCENT), + (520, "Supraglottic swelling", 26, WARN)]): + body += f'' + body += (f'') + body += _label(x, 330, label, 12, colour, "600", "middle") + body += _label(x, 78, "airway lumen", 10, MUTED, "400", "middle") + body += _label(24, 62, "Where the narrowing sits changes the sound and the urgency.", 12, MUTED) + return _frame("Upper airway narrowing: level and lumen", body) + + +def dehydration_scale() -> str: + body = _label(24, 62, "Signs accumulate as deficit grows; each band adds to the one before it.", 12, MUTED) + bands = [("Minimal", GOOD, ["alert", "moist mucosa", "normal pulse"]), + ("Mild to moderate", "#f59e0b", ["restless", "dry mucosa", "reduced urine"]), + ("Severe", WARN, ["lethargic", "sunken eyes", "weak pulse", "prolonged refill"])] + for index, (name, colour, signs) in enumerate(bands): + y = 90 + index * 80 + body += f'' + body += _label(86, y + 24, name, 13, colour, "700") + body += _label(86, y + 43, " · ".join(signs), 11, MUTED) + return _frame("Dehydration: severity bands", body) + + +def fluid_pathway() -> str: + steps = [("Assess perfusion", ACCENT), ("Shock?", WARN), ("Bolus, reassess", WARN), + ("Maintenance + deficit", GOOD), ("Reassess hourly", ACCENT)] + body = "" + for index, (label, colour) in enumerate(steps): + y = 80 + index * 56 + body += f'' + body += _label(320, y + 25, label, 13, colour, "600", "middle") + if index < len(steps) - 1: + body += f'' + body = ('' + f'') + body + return _frame("Fluid resuscitation: order of decisions", body) + + +def target_sign() -> str: + body = _label(24, 62, "Bowel within bowel: concentric rings on the transverse view.", 12, MUTED) + for radius, colour, opacity in [(110, ACCENT, 0.10), (78, ACCENT, 0.16), (46, ACCENT, 0.24), (18, WARN, 0.30)]: + body += (f'') + for index, (label, radius) in enumerate([("outer wall", 110), ("intussuscipiens", 78), + ("intussusceptum", 46), ("mesenteric fat", 18)]): + y = 130 + index * 46 + body += f'' + body += _label(428, y + 4, label, 11, MUTED, "600") + return _frame("Target sign: concentric bowel layers", body) + + +def growth_velocity() -> str: + body = _axes(70, 60, 590, 320, "Age (years)", "Growth velocity") + body += ('') + for x, label in [(110, "infancy"), (300, "childhood"), (490, "puberty")]: + body += _label(x, 344, label, 11, MUTED, "600", "middle") + body += f'' + body += _label(24, 62, "Three phases, each driven by something different.", 12, MUTED) + return _frame("Growth velocity across childhood", body) + + +FIGURES = [ + ("bilirubin-risk-zones", "Bilirubin risk zones by age", bilirubin_zones, + "Schematic of serum bilirubin risk bands plotted against age in hours.", + ["neonatal jaundice", "hyperbilirubinemia", "newborn"]), + ("airway-narrowing-levels", "Upper airway narrowing by level", airway_obstruction, + "Schematic comparing a normal airway lumen with subglottic and supraglottic narrowing.", + ["stridor", "croup", "epiglottitis", "airway"]), + ("dehydration-severity-bands", "Dehydration severity bands", dehydration_scale, + "Schematic of clinical signs grouped by dehydration severity.", + ["dehydration", "gastroenteritis", "fluid"]), + ("fluid-resuscitation-pathway", "Fluid resuscitation pathway", fluid_pathway, + "Schematic order of decisions in paediatric fluid resuscitation.", + ["shock", "fluid", "resuscitation"]), + ("target-sign-intussusception", "Target sign: concentric bowel layers", target_sign, + "Schematic cross-section showing bowel within bowel as concentric rings.", + ["intussusception", "ultrasound", "abdominal pain"]), + ("growth-velocity-phases", "Growth velocity across childhood", growth_velocity, + "Schematic growth velocity curve showing infancy, childhood and pubertal phases.", + ["growth", "puberty", "development"]), +] + + +def main(): + apply_changes = "--apply" in sys.argv + db = SessionLocal() + try: + library = db.query(MediaLibrary).order_by(MediaLibrary.id).first() + if library is None: + print(" No image library exists; create one first.") + return 1 + + made = skipped = 0 + for slug, title, draw, caption, tags in FIGURES: + key = f"media/illustrations/{slug}.svg" + if db.query(MediaAsset.id).filter(MediaAsset.path == key).first(): + print(f" exists {slug}") + skipped += 1 + continue + svg = draw().encode() + print(f" draw {slug} ({len(svg)} bytes)") + made += 1 + if not apply_changes: + continue + + storage_service.save(key, svg, "image/svg+xml") + asset = MediaAsset( + path=key, title=title, caption=caption, + alt_text=caption, kind="image", library_id=library.id, + storage="s3" if storage_service.using_s3() else "local", + byte_size=len(svg), + ) + db.add(asset) + db.flush() + for name in tags: + row = db.execute(sa_text( + "SELECT id FROM question_tags WHERE lower(name) = lower(:n) ORDER BY id LIMIT 1" + ), {"n": name}).first() + tag_id = row[0] if row else db.execute(sa_text( + "INSERT INTO question_tags (name, type) VALUES (:n, 'keyword') RETURNING id" + ), {"n": name}).scalar() + db.add(MediaTagLink(media_id=asset.id, tag_id=tag_id)) + db.commit() + try: + if embedding_service.embed_record(asset, "media"): + db.commit() + except Exception: + db.rollback() + + print(f"\n drawn: {made} already there: {skipped}") + if not apply_changes: + print(" Re-run with --apply to store them in the image bank.") + else: + print(textwrap.dedent(""" + In the bank now, searchable by what they show. Attach one to an + article with a Markdown image, or to a question from the picker. + """).strip()) + finally: + db.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/src/components/RichText.css b/frontend/src/components/RichText.css new file mode 100644 index 0000000..5954e75 --- /dev/null +++ b/frontend/src/components/RichText.css @@ -0,0 +1,32 @@ +/* Rendered educator prose, wherever it appears. */ + +.rich-text { line-height: 1.6; overflow-wrap: anywhere; } +.rich-text > :first-child { margin-top: 0; } +.rich-text > :last-child { margin-bottom: 0; } +.rich-text p { margin: 0 0 0.7em; } +.rich-text ul, .rich-text ol { margin: 0 0 0.7em; padding-left: 1.4em; } +.rich-text li { margin-bottom: 0.25em; } +.rich-text code { + font-size: 0.9em; padding: 1px 5px; border-radius: 4px; + background: var(--bg); border: 1px solid var(--border); +} +.rich-text pre { overflow-x: auto; padding: 12px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); } +.rich-text pre code { border: 0; padding: 0; background: none; } +.rich-text blockquote { + margin: 0 0 0.7em; padding-left: 12px; + border-left: 3px solid var(--border); color: var(--text-muted); +} +.rich-text img { max-width: 100%; border-radius: 8px; } + +/* A lab panel is the common case: many short rows, numbers that must line up. */ +.rich-table-wrap { overflow-x: auto; margin: 0 0 0.8em; } +.rich-text table { border-collapse: collapse; width: 100%; font-size: 0.92em; } +.rich-text th, .rich-text td { + border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; +} +.rich-text th { background: var(--bg); font-weight: 650; } +.rich-text td:nth-child(n+2) { font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* KaTeX paints its own colours; these keep it on the page's. */ +.rich-text .katex { font-size: 1.02em; color: inherit; } +.rich-text .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; } diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx new file mode 100644 index 0000000..cae2c25 --- /dev/null +++ b/frontend/src/components/RichText.jsx @@ -0,0 +1,84 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import rehypeKatex from 'rehype-katex' +import 'katex/dist/katex.min.css' +import ArticleLink from './ArticleLink' +import rehypeHighlightOffsets from '../utils/highlightOffsets' +import { markdownImageUrl } from '../utils/uploads' +import './RichText.css' + +/** + * The one place educator-written text becomes rendered content. + * + * Everything the platform stores as prose — question stems and options, + * explanations, articles, lesson text — is Markdown, and until now only + * articles rendered it. A lab panel written as a table showed up in the quiz + * player as a row of literal pipes, which is how this component came about. + * + * Markdown only: raw HTML is escaped rather than executed, so a stem can never + * inject markup into the page around it. Maths is written as `$…$` or `$$…$$` + * and rendered by KaTeX, because a serum osmolality formula set as plain text + * is a different sentence from the one the author wrote. + */ + +// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an +// educator writes a cross-reference without needing an article's numeric id. +const WIKI_LINK = /\[\[(?:(\d+)\|([^\]]+)|([^\]|]+?)(?:\|([a-z0-9-]+))?)\]\]/g + +const expandWikiLinks = (text) => (text || '').replace( + WIKI_LINK, + (_m, id, idLabel, label, slug) => (id + ? `[${idLabel.trim()}](/articles/${id})` + : `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`), +) + +const internalArticle = (href) => { + const match = /^\/articles\/(?:s\/)?([a-z0-9-]+|\d+)\/?$/.exec(href || '') + return match ? match[1] : null +} + +export default function RichText({ + value, + attemptId, + className = '', + linkArticles = false, + // Passing a textId turns on the highlight layer: text keeps the source + // offsets that highlights and the read-aloud cursor are stored against. + textId = null, + highlights = [], + speechRange = null, + onRemoveHighlight = null, +}) { + const rehypePlugins = [rehypeKatex] + if (textId) rehypePlugins.unshift([rehypeHighlightOffsets, { textId, highlights, speechRange }]) + + return ( +
{ + const span = event.target?.closest?.('[data-highlighted="true"]') + if (!span) return + event.preventDefault() + onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start)) + } : undefined}> + , + a: ({ node, href, children, ...props }) => { + const target = linkArticles && internalArticle(href) + if (target) return {children} + return {children} + }, + // A wide table is the reason this exists; it scrolls inside its own + // box rather than pushing the page sideways. + table: ({ node, ...props }) => ( +
+ ), + }}> + {linkArticles ? expandWikiLinks(value) : (value || '')} + + + ) +} diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx new file mode 100644 index 0000000..9f6bbfe --- /dev/null +++ b/frontend/src/components/RichText.test.jsx @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +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' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: {} })) } })) +vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => `/uploads/${src}` })) + +const mount = (props) => render() + +const LAB_TABLE = `Initial studies: + +| Analyte | Value | SI | +| --- | --- | --- | +| Sodium | 139 mEq/L | 139 mmol/L | +| Potassium | 2.8 mEq/L | 2.8 mmol/L |` + +describe('rendered prose', () => { + it('renders a lab panel as a table, which is the reason this exists', () => { + mount({ value: LAB_TABLE }) + const table = screen.getByRole('table') + expect(within(table).getByRole('columnheader', { name: 'Analyte' })).toBeInTheDocument() + expect(within(table).getByRole('cell', { name: '2.8 mEq/L' })).toBeInTheDocument() + // A wide table scrolls in its own box rather than pushing the page sideways. + expect(table.closest('.rich-table-wrap')).toBeTruthy() + expect(screen.queryByText(/\|/)).not.toBeInTheDocument() + }) + + it('renders maths rather than printing the source', () => { + const { container } = mount({ value: 'Osmolality is $2 \\times Na$ roughly.' }) + expect(container.querySelector('.katex')).toBeTruthy() + // What a reader sees is the typeset half; the TeX survives only in the + // MathML annotation, which is there for screen readers and is not visible. + expect(container.querySelector('.katex-html').textContent).toContain('×') + expect(container.querySelector('.katex-html').textContent).not.toContain('times') + }) + + it('escapes raw HTML instead of executing it', () => { + const { container } = mount({ value: 'A stem' }) + expect(container.querySelector('img')).toBeNull() + expect(screen.getByText(/A stem/)).toBeInTheDocument() + }) + + it('sends an external link away in a new tab, safely', () => { + mount({ value: '[AAP](https://aap.org)' }) + const link = screen.getByRole('link', { name: 'AAP' }) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')) + }) + + it('leaves article cross-references alone unless asked to link them', () => { + mount({ value: 'See [[febrile-seizures]] for more.' }) + expect(screen.getByText(/\[\[febrile-seizures\]\]/)).toBeInTheDocument() + }) + + it('turns a cross-reference into a preview link when asked', () => { + mount({ value: 'See [[7|Febrile seizures]] for more.', linkArticles: true }) + expect(screen.getByRole('link', { name: 'Febrile seizures' })) + .toHaveAttribute('href', '/articles/s/7') + }) +}) + +describe('highlights across rendered markdown', () => { + const stem = 'A 9-year-old boy is brought to the office.' + + it('keeps the source offsets a highlight is stored against', () => { + const { container } = mount({ + value: stem, textId: 'q1::question', + highlights: [{ start: 2, end: 12 }], + }) + const active = container.querySelector('.manual-highlight-active') + expect(active).toHaveTextContent('9-year-old') + expect(active.dataset.start).toBe('2') + expect(active.dataset.end).toBe('12') + expect(active.dataset.manualHighlightId).toBe('q1::question') + }) + + it('marks the read-aloud cursor separately from a highlight', () => { + const { container } = mount({ + value: stem, textId: 'q1::question', + highlights: [{ start: 0, end: 1 }], + speechRange: { start: 2, end: 12 }, + }) + expect(container.querySelector('.speech-highlight-active')).toHaveTextContent('9-year-old') + expect(container.querySelector('.manual-highlight-active')).toHaveTextContent('A') + }) + + it('offsets survive a stem that also contains a table', () => { + const { container } = mount({ + value: LAB_TABLE, textId: 'q2::question', + highlights: [{ start: 0, end: 8 }], + }) + expect(screen.getByRole('table')).toBeInTheDocument() + const active = container.querySelector('.manual-highlight-active') + expect(active).toHaveTextContent('Initial') + expect(active.dataset.start).toBe('0') + }) + + it('removes a highlight on right-click, and ignores plain text', async () => { + const onRemoveHighlight = vi.fn() + const { container } = mount({ + value: stem, textId: 'q1::question', + highlights: [{ start: 2, end: 12 }], onRemoveHighlight, + }) + await userEvent.pointer({ keys: '[MouseRight]', target: container.querySelector('.manual-highlight-active') }) + expect(onRemoveHighlight).toHaveBeenCalledWith('q1::question', 2) + + onRemoveHighlight.mockClear() + await userEvent.pointer({ keys: '[MouseRight]', target: container.querySelector('.manual-highlight-segment:not(.manual-highlight-active)') }) + expect(onRemoveHighlight).not.toHaveBeenCalled() + }) + + it('adds no highlight machinery when nothing is being highlighted', () => { + const { container } = mount({ value: stem }) + expect(container.querySelector('[data-manual-highlight-id]')).toBeNull() + }) +}) diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index 9734095..8b6d41e 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, lazy, Suspense } from 'react' import { useLocation, useNavigate, Link } from 'react-router-dom' +import RichText from '../components/RichText' import { useAuth } from '../context/AuthContext' import api from '../api/client' import Dialog from '../components/Dialog' @@ -58,7 +59,11 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite, -
+ {/* Markdown, not injected HTML: a stem is educator prose, and the + same renderer the quiz player uses keeps a lab table a table. */} +
+ +
{question.image_path && (
Question illustration Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start) - .sort((a, b) => a.start - b.start || a.end - b.end) - const merged = [] - sorted.forEach(range => { - const last = merged[merged.length - 1] - if (!last || range.start > last.end) { - merged.push({ start: range.start, end: range.end }) - } else { - last.end = Math.max(last.end, range.end) - } - }) - return merged -} function removeTextRange(ranges, removeRange) { const next = [] @@ -83,44 +70,6 @@ function getManualHighlightSelection(selection = window.getSelection?.()) { return { id: start.id, ...ordered } } -function ManualHighlightText({ text, textId, highlights = [], speechRange = null, onRemoveHighlight = null }) { - const ranges = mergeTextRanges(highlights) - const boundaries = new Set([0, (text || '').length]) - ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) }) - if (speechRange) { boundaries.add(speechRange.start); boundaries.add(speechRange.end) } - const points = [...boundaries] - .filter(point => point >= 0 && point <= (text || '').length) - .sort((a, b) => a - b) - - return points.slice(0, -1).map((start, i) => { - const end = points[i + 1] - if (end <= start) return null - const manuallyHighlighted = ranges.some(range => start >= range.start && end <= range.end) - const speechHighlighted = speechRange && start >= speechRange.start && end <= speechRange.end - const className = [ - 'manual-highlight-segment', - manuallyHighlighted ? 'manual-highlight-active' : '', - speechHighlighted ? 'speech-highlight-active' : '', - ].filter(Boolean).join(' ') - return ( - { - event.preventDefault() - onRemoveHighlight?.(textId, start) - } : undefined} - title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined} - > - {text.slice(start, end)} - - ) - }) -} - function splitSpeechChunks(text, maxWords) { const words = (text || '').trim().split(/\s+/).filter(Boolean) if (!words.length) return [] @@ -1157,16 +1106,17 @@ const timerStarted = timeLeft !== null {current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
-
-

- -

+ {/* A stem carrying a lab table cannot live inside a heading — + the table would be invalid markup there — so the heading is the + labelled region and the prose sits inside it. */} +
+
) @@ -1313,7 +1266,7 @@ const timerStarted = timeLeft !== null {statsError &&

{statsError}

} {(current.explanation || current.explanation_image_path) && (
- {current.explanation && <>Explanation: {current.explanation}} + {current.explanation && <>Explanation: } {current.explanation_image_path && (