From f048b1f4b617fb19d3c13981921c0479faa18d6f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 01:44:29 +0200 Subject: [PATCH] feat: tips in the prose, underlined where they are needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{{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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/RichText.jsx | 10 ++- frontend/src/components/RichText.test.jsx | 15 ++++ frontend/src/components/TipTerm.css | 29 ++++++++ frontend/src/components/TipTerm.jsx | 53 +++++++++++++++ frontend/src/components/TipTerm.test.jsx | 45 ++++++++++++ frontend/src/pages/HandbookPage.jsx | 28 ++++++++ frontend/src/utils/tipTerms.js | 83 +++++++++++++++++++++++ 7 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/TipTerm.css create mode 100644 frontend/src/components/TipTerm.jsx create mode 100644 frontend/src/components/TipTerm.test.jsx create mode 100644 frontend/src/utils/tipTerms.js 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 + }) + } +}