diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx
index cae2c25..1ba9203 100644
--- a/frontend/src/components/RichText.jsx
+++ b/frontend/src/components/RichText.jsx
@@ -5,6 +5,8 @@ import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import ArticleLink from './ArticleLink'
import rehypeHighlightOffsets from '../utils/highlightOffsets'
+import remarkTipTerms from '../utils/tipTerms'
+import TipTerm from './TipTerm'
import { markdownImageUrl } from '../utils/uploads'
import './RichText.css'
@@ -62,10 +64,16 @@ export default function RichText({
onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start))
} : undefined}>
,
+ // `{{phrase|tip}}` — a teaching point that opens where the phrase is.
+ span: ({ node, children, ...props }) => (
+ props.className === 'tip-term'
+ ? {children}
+ : {children}
+ ),
a: ({ node, href, children, ...props }) => {
const target = linkArticles && internalArticle(href)
if (target) return {children}
diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx
index c30b25e..995fded 100644
--- a/frontend/src/components/RichText.test.jsx
+++ b/frontend/src/components/RichText.test.jsx
@@ -100,6 +100,21 @@ describe('highlights across rendered markdown', () => {
expect(active.dataset.start).toBe('0')
})
+ it('a tip in the stem does not move the offsets after it', () => {
+ // The marker is 33 characters wide; text past it must still be found where
+ // it was stored, or every highlight drawn before the tip was written would
+ // land in the wrong place.
+ const withTip = 'A 9-year-old {{boy|Sex matters here}} is brought to the office.'
+ const start = withTip.indexOf('office')
+ const { container } = mount({
+ value: withTip, textId: 'q1::question',
+ highlights: [{ start, end: start + 6 }],
+ })
+ const active = container.querySelector('.manual-highlight-active')
+ expect(active).toHaveTextContent('office')
+ expect(active.dataset.start).toBe(String(start))
+ })
+
it('removes a highlight on right-click, and ignores plain text', async () => {
const onRemoveHighlight = vi.fn()
const { container } = mount({
diff --git a/frontend/src/components/TipTerm.css b/frontend/src/components/TipTerm.css
new file mode 100644
index 0000000..fe50e0b
--- /dev/null
+++ b/frontend/src/components/TipTerm.css
@@ -0,0 +1,29 @@
+/* The phrase reads as prose until you look for it: a dotted rule under the
+ words, not a coloured link. Following it is a detour, not the way on. */
+.tip-anchor { position: relative; display: inline; }
+
+.tip-term {
+ display: inline; padding: 0; margin: 0; border: 0; background: none;
+ font: inherit; color: inherit; cursor: pointer; text-align: inherit;
+ border-bottom: 1.5px dotted var(--primary);
+}
+.tip-term:hover, .tip-term.is-open { background: var(--tip-wash, rgba(253, 224, 71, 0.28)); }
+.tip-term:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 2px; }
+
+.tip-card {
+ position: absolute; z-index: 60; top: calc(100% + 7px); left: 0;
+ display: flex; gap: 9px; width: max-content; max-width: min(360px, 80vw);
+ padding: 11px 13px; text-align: left;
+ background: var(--card-bg); color: var(--text);
+ border: 1px solid var(--border); border-left: 3px solid var(--primary);
+ border-radius: 10px; box-shadow: 0 10px 26px rgba(15, 23, 42, 0.16);
+ font-size: 0.86rem; font-weight: 400; line-height: 1.55; white-space: normal;
+}
+.tip-card.is-above { top: auto; bottom: calc(100% + 7px); }
+.tip-card-mark { color: var(--primary); flex-shrink: 0; }
+
+@media (max-width: 560px) {
+ /* On a narrow screen a card pinned to the word runs off the edge; it takes
+ the width it has instead. */
+ .tip-card { left: 0; right: 0; width: auto; max-width: none; }
+}
diff --git a/frontend/src/components/TipTerm.jsx b/frontend/src/components/TipTerm.jsx
new file mode 100644
index 0000000..3856187
--- /dev/null
+++ b/frontend/src/components/TipTerm.jsx
@@ -0,0 +1,53 @@
+import { useEffect, useRef, useState } from 'react'
+import './TipTerm.css'
+
+/**
+ * An underlined phrase that opens its teaching point where it stands.
+ *
+ * Click rather than hover: a tip is a sentence worth reading, and a card that
+ * appears because the pointer crossed a word is a card that vanishes while you
+ * read it. It is also the only behaviour a touch screen has.
+ *
+ * Opening one closes the last, so a stem does not fill with panels.
+ */
+export default function TipTerm({ tip, children }) {
+ const [open, setOpen] = useState(false)
+ const [above, setAbove] = useState(false)
+ const anchor = useRef(null)
+
+ useEffect(() => {
+ if (!open) return undefined
+ const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
+ const onDown = (event) => {
+ if (!anchor.current?.contains(event.target)) setOpen(false)
+ }
+ document.addEventListener('keydown', onKey)
+ document.addEventListener('mousedown', onDown)
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.removeEventListener('mousedown', onDown)
+ }
+ }, [open])
+
+ const toggle = () => {
+ // Flip the card above the phrase when there is no room beneath it.
+ const box = anchor.current?.getBoundingClientRect?.()
+ if (box) setAbove(window.innerHeight - box.bottom < 160)
+ setOpen(value => !value)
+ }
+
+ return (
+
+
+ {open && (
+
+ ⚕
+ {tip}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/TipTerm.test.jsx b/frontend/src/components/TipTerm.test.jsx
new file mode 100644
index 0000000..ffdef4d
--- /dev/null
+++ b/frontend/src/components/TipTerm.test.jsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { expect, it } from 'vitest'
+import RichText from './RichText'
+
+const stem = 'A 2-year-old has {{stridor|Inspiratory stridor is extrathoracic until proven otherwise}} at rest.'
+
+it('underlines the phrase and keeps the tip closed until asked', async () => {
+ render()
+ const term = screen.getByRole('button', { name: 'stridor' })
+ expect(term).toHaveClass('tip-term')
+ expect(term).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByRole('note')).not.toBeInTheDocument()
+ // The marker itself never reaches the reader.
+ expect(screen.queryByText(/\{\{/)).not.toBeInTheDocument()
+})
+
+it('opens the teaching point where the phrase is, and closes again', async () => {
+ render()
+ const term = screen.getByRole('button', { name: 'stridor' })
+ await userEvent.click(term)
+ expect(screen.getByRole('note')).toHaveTextContent('extrathoracic until proven otherwise')
+ await userEvent.click(term)
+ expect(screen.queryByRole('note')).not.toBeInTheDocument()
+})
+
+it('closes on Escape', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'stridor' }))
+ await userEvent.keyboard('{Escape}')
+ expect(screen.queryByRole('note')).not.toBeInTheDocument()
+})
+
+it('leaves prose that merely looks like a marker alone', () => {
+ render()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.getByText(/1:1000 adrenaline/)).toBeInTheDocument()
+})
+
+it('keeps the text around several tips in order', async () => {
+ render()
+ expect(screen.getByRole('button', { name: 'One' })).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Two' }))
+ expect(screen.getByRole('note')).toHaveTextContent('second tip')
+})
diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx
index 8555a6c..f3c35f4 100644
--- a/frontend/src/pages/HandbookPage.jsx
+++ b/frontend/src/pages/HandbookPage.jsx
@@ -15,6 +15,7 @@ import './HandbookPage.css'
const SECTIONS = [
{ id: 'links', label: 'Article links' },
+ { id: 'tips', label: 'Tips in the prose' },
{ id: 'tutor', label: 'The AI tutor' },
{ id: 'blueprint', label: 'Exams and blueprints' },
{ id: 'deleting', label: 'Deleting things' },
@@ -119,6 +120,33 @@ export default function HandbookPage() {
+
+
Tips in the prose
+
+ A phrase in a stem, an option, an explanation or an article can carry
+ one sentence of teaching behind it. Written as{' '}
+ {'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}{' '}
+ — the phrase first, then a bar, then the tip.
+
+
+ The reader sees stridor underlined with a dotted
+ rule. Clicking it opens the sentence where the word is and clicking
+ again closes it; so does Esc. Nothing is revealed until
+ asked for, which is the point: a tip in the open is just more stem.
+
+
+ Use it for the thing a learner would otherwise have to be told
+ afterwards — why that vital sign matters, what the eponym is. When
+ the answer runs longer than a sentence it belongs in an article, and
+ a cross-reference is the better tool.
+
+
+ A phrase may not contain a bar or a brace, and a tip may not contain
+ braces. Anything that does not match the shape exactly is left in the
+ text as written — a stray {'{'} in a formula is safe.
+
+
+
The AI tutor
diff --git a/frontend/src/utils/tipTerms.js b/frontend/src/utils/tipTerms.js
new file mode 100644
index 0000000..425c32c
--- /dev/null
+++ b/frontend/src/utils/tipTerms.js
@@ -0,0 +1,83 @@
+/**
+ * `{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}`
+ * — a word in the prose that carries a teaching point behind it.
+ *
+ * The reader sees the phrase underlined; the tip appears where the phrase is,
+ * on a click, rather than sending them to a panel elsewhere on the page and
+ * back. It is the same idea as a cross-reference, minus the article: sometimes
+ * what the learner needs is one sentence, and writing a whole article to hang
+ * it on is how a sentence goes unwritten.
+ *
+ * A remark plugin rather than a search-and-replace on the source, because
+ * highlights and the read-aloud cursor are stored as offsets into the raw
+ * text. Rewriting the string before it is parsed would move every offset after
+ * the first tip. Splitting the parsed text node instead keeps each piece
+ * pointing at where it came from.
+ */
+
+// The phrase may not contain a bar or a brace; the tip may not contain braces.
+// Both are deliberate: it keeps a stray `{` in a formula from eating the rest
+// of a stem.
+const TIP = /\{\{([^{}|]+)\|([^{}]+)\}\}/g
+
+const walk = (node, visit) => {
+ if (!Array.isArray(node.children)) return
+ node.children = node.children.flatMap(child => {
+ walk(child, visit)
+ return visit(child) ?? [child]
+ })
+}
+
+/**
+ * Positions for the pieces a split leaves behind.
+ *
+ * Line and column travel with the offset because the tools downstream discard
+ * a point that carries only one of the three — which is how the first version
+ * of this lost every highlight past the first tip. Column is exact for a
+ * single-line run and approximate across a wrapped one; nothing reads it, and
+ * an offset with no column at all is read as no position.
+ */
+const pointAt = (offset, anchor) => ({
+ line: anchor.line, column: anchor.column + (offset - anchor.offset), offset,
+})
+
+const span = (from, to, anchor) => (from == null || !anchor ? undefined
+ : { start: pointAt(from, anchor), end: pointAt(to, anchor) })
+
+const text = (value, from, anchor) => ({
+ type: 'text', value, position: span(from, from + value.length, anchor),
+})
+
+export default function remarkTipTerms() {
+ return (tree) => {
+ walk(tree, (node) => {
+ if (node.type !== 'text' || !node.value.includes('{{')) return null
+ const anchor = node.position?.start
+ const base = anchor?.offset
+ const at = (index) => (base == null ? null : base + index)
+ const out = []
+ let last = 0
+ TIP.lastIndex = 0
+ for (let match = TIP.exec(node.value); match; match = TIP.exec(node.value)) {
+ const [whole, phrase, tip] = match
+ if (match.index > last) out.push(text(node.value.slice(last, match.index), at(last), anchor))
+ // The phrase sits after `{{` inside the marker; giving it that offset
+ // keeps a highlight drawn over it landing on the same characters.
+ const phraseAt = at(match.index + 2)
+ out.push({
+ type: 'tipTerm',
+ data: {
+ hName: 'span',
+ hProperties: { className: 'tip-term', 'data-tip': tip.trim() },
+ },
+ children: [text(phrase, phraseAt, anchor)],
+ position: span(at(match.index), at(match.index + whole.length), anchor),
+ })
+ last = match.index + whole.length
+ }
+ if (!out.length) return null
+ if (last < node.value.length) out.push(text(node.value.slice(last), at(last), anchor))
+ return out
+ })
+ }
+}