feat: tips in the prose, underlined where they are needed

`{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}`
in any stem, option, explanation or article renders the phrase with a
dotted underline; clicking it opens the sentence where the word is.
Nothing is revealed until asked for, and Esc or a second click closes it.

The question already had an Attending tip — one panel, for the whole
question, reached from the toolbar. This is the other half: the point
that belongs to one word, said next to that word.

Done as 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. The split pieces carry line and column as
well as offset — a point with only one of the three is discarded
downstream, which cost the first version every highlight past the tip.
There is a test for exactly that.

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-12 01:44:29 +02:00
parent 2c6b821f6f
commit f048b1f4b6
7 changed files with 262 additions and 1 deletions

View file

@ -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}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
remarkPlugins={[remarkGfm, remarkMath, remarkTipTerms]}
rehypePlugins={rehypePlugins}
components={{
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
// `{{phrase|tip}}` a teaching point that opens where the phrase is.
span: ({ node, children, ...props }) => (
props.className === 'tip-term'
? <TipTerm tip={props['data-tip']}>{children}</TipTerm>
: <span {...props}>{children}</span>
),
a: ({ node, href, children, ...props }) => {
const target = linkArticles && internalArticle(href)
if (target) return <ArticleLink slug={target}>{children}</ArticleLink>

View file

@ -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({

View file

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

View file

@ -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 (
<span className="tip-anchor" ref={anchor}>
<button type="button" className={`tip-term${open ? ' is-open' : ''}`}
aria-expanded={open} onClick={toggle}>
{children}
</button>
{open && (
<span className={`tip-card${above ? ' is-above' : ''}`} role="note">
<span className="tip-card-mark" aria-hidden="true"></span>
<span className="tip-card-text">{tip}</span>
</span>
)}
</span>
)
}

View file

@ -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(<RichText value={stem} />)
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(<RichText value={stem} />)
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(<RichText value={stem} />)
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(<RichText value={'Give 0.1 mL/kg {{of} 1:1000 adrenaline.'} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.getByText(/1:1000 adrenaline/)).toBeInTheDocument()
})
it('keeps the text around several tips in order', async () => {
render(<RichText value={'{{One|first tip}} then {{Two|second tip}} end.'} />)
expect(screen.getByRole('button', { name: 'One' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Two' }))
expect(screen.getByRole('note')).toHaveTextContent('second tip')
})

View file

@ -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() {
</p>
</section>
<section id="tips">
<h2>Tips in the prose</h2>
<p>
A phrase in a stem, an option, an explanation or an article can carry
one sentence of teaching behind it. Written as{' '}
<code>{'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}</code>{' '}
the phrase first, then a bar, then the tip.
</p>
<p>
The reader sees <strong>stridor</strong> underlined with a dotted
rule. Clicking it opens the sentence where the word is and clicking
again closes it; so does <kbd>Esc</kbd>. Nothing is revealed until
asked for, which is the point: a tip in the open is just more stem.
</p>
<p>
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.
</p>
<p className="hb-warn">
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 <code>{'{'}</code> in a formula is safe.
</p>
</section>
<section id="tutor">
<h2>The AI tutor</h2>
<p>

View file

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