diff --git a/frontend/src/components/MarkdownToolbar.jsx b/frontend/src/components/MarkdownToolbar.jsx
index 25ddacd..5d7c9dc 100644
--- a/frontend/src/components/MarkdownToolbar.jsx
+++ b/frontend/src/components/MarkdownToolbar.jsx
@@ -18,6 +18,11 @@ import './MarkdownToolbar.css'
const WRAP = 'wrap'
const LINE = 'line'
const BLOCK = 'block'
+const TIP = 'tip'
+
+//: What a tip reads as before anything has been typed into it.
+const TIP_PHRASE = 'phrase'
+const TIP_TEXT = 'the teaching point'
const ACTIONS = [
{ key: 'bold', label: 'B', title: 'Bold', kind: WRAP, before: '**', after: '**', style: { fontWeight: 800 } },
@@ -31,6 +36,8 @@ const ACTIONS = [
text: '\n| Test | Value | Reference |\n| --- | --- | --- |\n| | | |\n',
},
{ key: 'math', label: '∑', title: 'Inline maths', kind: WRAP, before: '$', after: '$' },
+ // Select the words first; the tip is what you type next.
+ { key: 'tip', label: '⚕ Tip', title: 'Turn the selected words into a tip', kind: TIP },
]
export default function MarkdownToolbar({ textareaRef, value, onChange, label = 'Formatting' }) {
@@ -42,6 +49,8 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label =
const text = value || ''
let next = text
let caret = end
+ // Where to leave the selection, when landing the caret is not enough.
+ let select = null
if (action.kind === WRAP) {
const selected = text.slice(start, end)
@@ -63,6 +72,17 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label =
const replaced = lines.join('\n')
next = text.slice(0, lineStart) + replaced + text.slice(stop)
caret = lineStart + replaced.length
+ } else if (action.kind === TIP) {
+ // With words selected, they become the underlined phrase and the caret
+ // waits where the tip goes. With nothing selected there is a placeholder
+ // to type over, selected so the first keystroke replaces it.
+ const selected = text.slice(start, end)
+ const phrase = selected || TIP_PHRASE
+ const tip = selected ? '' : TIP_TEXT
+ next = `${text.slice(0, start)}{{${phrase}|${tip}}}${text.slice(end)}`
+ const tipAt = start + 2 + phrase.length + 1
+ select = selected ? [tipAt, tipAt] : [start + 2, start + 2 + phrase.length]
+ caret = tipAt
} else {
next = text.slice(0, start) + action.text + text.slice(end)
caret = start + action.text.length
@@ -72,7 +92,7 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label =
// The caret has to be restored after React has written the new value.
requestAnimationFrame(() => {
field.focus()
- field.setSelectionRange(caret, caret)
+ field.setSelectionRange(...(select || [caret, caret]))
})
}
diff --git a/frontend/src/components/MarkdownToolbar.test.jsx b/frontend/src/components/MarkdownToolbar.test.jsx
index 6524348..6f4711f 100644
--- a/frontend/src/components/MarkdownToolbar.test.jsx
+++ b/frontend/src/components/MarkdownToolbar.test.jsx
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useRef, useState } from 'react'
import MarkdownToolbar from './MarkdownToolbar'
@@ -65,6 +65,27 @@ describe('markdown formatting buttons', () => {
expect(box().value.startsWith('Labs:')).toBe(true)
})
+ it('turns the selected words into a tip, waiting where the tip goes', async () => {
+ render()
+ select(12, 19)
+ await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' }))
+ expect(box()).toHaveValue('A child has {{stridor|}} at rest.')
+ // The caret sits after the bar, so the next thing typed is the tip itself.
+ await waitFor(() => expect(box().selectionStart).toBe('A child has {{stridor|'.length))
+ })
+
+ it('with nothing selected, offers a placeholder to type over', async () => {
+ render()
+ select(0, 0)
+ await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' }))
+ expect(box()).toHaveValue('{{phrase|the teaching point}}')
+ // "phrase" is selected, so the first keystroke replaces it.
+ await waitFor(() => {
+ expect(box().selectionStart).toBe(2)
+ expect(box().selectionEnd).toBe(8)
+ })
+ })
+
it('leaves everything else exactly as typed — no reflow', async () => {
// The whole reason this is not a WYSIWYG: a stem carries learners'
// highlight offsets, so untouched text must come back byte for byte.
diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx
index f3c35f4..126ea46 100644
--- a/frontend/src/pages/HandbookPage.jsx
+++ b/frontend/src/pages/HandbookPage.jsx
@@ -122,9 +122,18 @@ export default function HandbookPage() {
Tips in the prose
+ 1. A tip on one phrase
+
+ In the question editor, select the words in the stem
+ (or an option, or the explanation) and press ⚕ Tip on
+ that field's toolbar. The words become the phrase and the caret waits
+ where the tip goes — type the sentence and you are done. With nothing
+ selected the button drops in a marker with phrase
+ highlighted to type over.
+
A phrase in a stem, an option, an explanation or an article can carry
- one sentence of teaching behind it. Written as{' '}
+ one sentence of teaching behind it. What the button writes is{' '}
{'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}{' '}
— the phrase first, then a bar, then the tip.
@@ -145,6 +154,24 @@ export default function HandbookPage() {
braces. Anything that does not match the shape exactly is left in the
text as written — a stray {'{'} in a formula is safe.
+
+ 2. A tip for the whole question
+
+ The Attending tip field at the foot of the editor.
+ One nudge for the question as a whole, offered in the player behind a
+ ⚕ button above the options. Leave it empty and no button
+ appears — a question with nothing to say says nothing.
+
+
+ What it costs the learner
+
+ Either kind, opened before the answer goes in, is recorded
+ with that answer. It still counts as correct and the percentage is not
+ docked; it is shown on Analysis as its own slice —{' '}
+ correct after a tip — so a learner can see how much of a
+ score leaned on one. A tip opened while reading the explanation is
+ revision and costs nothing.
+
diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css
index f9387da..9cbae9f 100644
--- a/frontend/src/pages/QuestionEditPage.css
+++ b/frontend/src/pages/QuestionEditPage.css
@@ -46,6 +46,8 @@
.qe-field textarea { min-height: 200px; resize: vertical; line-height: 1.65; }
.qe-field textarea.qe-stem { min-height: 340px; }
.qe-field textarea.qe-explanation { min-height: 280px; }
+/* Shorter than an explanation: the tip is meant to be one sentence. */
+.qe-field textarea.qe-tip { min-height: 84px; }
/* ── Options ──────────────────────────────────────────────────────── */
.qe-option { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; }
diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx
index 0f6f5b1..d2dedd8 100644
--- a/frontend/src/pages/QuestionEditPage.jsx
+++ b/frontend/src/pages/QuestionEditPage.jsx
@@ -22,7 +22,7 @@ const apiError = (err, fallback) => {
const blank = () => ({
question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '',
- explanation: '', question_category_id: '', extraCategoryIds: [], option_explanations: {},
+ explanation: '', attending_tip: '', question_category_id: '', extraCategoryIds: [], option_explanations: {},
difficulty: '', image_path: '', explanation_image_path: '',
})
@@ -46,6 +46,7 @@ export default function QuestionEditPage({ mode = 'edit' }) {
const [picking, setPicking] = useState(null)
const stemRef = useRef(null)
const explanationRef = useRef(null)
+ const tipRef = useRef(null)
// One ref per option, kept across renders so the toolbar can find the
// field it belongs to when options are added or reordered.
const optionRefs = useRef([])
@@ -74,6 +75,7 @@ export default function QuestionEditPage({ mode = 'edit' }) {
options: found.options ? [...found.options] : [],
correct_answer: found.correct_answer || '',
explanation: found.explanation || '',
+ attending_tip: found.attending_tip || '',
question_category_id: found.question_category_id || '',
extraCategoryIds: (found.category_ids || []).filter(c => c !== found.question_category_id),
option_explanations: { ...(found.option_explanations || {}) },
@@ -134,6 +136,7 @@ export default function QuestionEditPage({ mode = 'edit' }) {
options: form.question_type === 'mcq' ? form.options.filter(o => o.trim()) : null,
correct_answer: form.correct_answer.trim(),
explanation: form.explanation || null,
+ attending_tip: form.attending_tip.trim() || null,
question_category_id: form.question_category_id === '' ? null : Number(form.question_category_id),
additional_category_ids: form.extraCategoryIds.filter(c => c !== Number(form.question_category_id)),
option_explanations: form.option_explanations,
@@ -302,6 +305,23 @@ export default function QuestionEditPage({ mode = 'edit' }) {
diff --git a/frontend/src/pages/QuestionEditPage.test.jsx b/frontend/src/pages/QuestionEditPage.test.jsx
new file mode 100644
index 0000000..01e6663
--- /dev/null
+++ b/frontend/src/pages/QuestionEditPage.test.jsx
@@ -0,0 +1,68 @@
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { beforeEach, expect, it, vi } from 'vitest'
+import QuestionEditPage from './QuestionEditPage'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({
+ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn() },
+}))
+
+const question = (over = {}) => ({
+ id: 7, question_text: 'A child has stridor at rest.', question_type: 'mcq',
+ options: ['Croup', 'Epiglottitis'], correct_answer: 'Croup',
+ explanation: 'Barking cough.', attending_tip: 'Look at the growth chart first.',
+ option_explanations: {}, category_ids: [], question_category_id: null, figures: [],
+ difficulty: '', image_path: null, explanation_image_path: null, ...over,
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ api.get.mockImplementation(url => Promise.resolve({
+ // Only the question itself is an object; figures and categories are lists.
+ data: url === '/questions/detail/7' ? question() : [],
+ }))
+ api.patch.mockResolvedValue({ data: {} })
+})
+
+const mount = () => render(
+
+ } />
+ )
+
+it('shows the attending tip the question already carries', async () => {
+ mount()
+ expect(await screen.findByLabelText('Attending tip'))
+ .toHaveValue('Look at the growth chart first.')
+})
+
+it('saves an edited tip', async () => {
+ mount()
+ const field = await screen.findByLabelText('Attending tip')
+ await userEvent.clear(field)
+ await userEvent.type(field, 'Plot the weight.')
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7',
+ expect.objectContaining({ attending_tip: 'Plot the weight.' })))
+})
+
+it('an emptied tip is cleared, not saved as an empty panel', async () => {
+ mount()
+ await userEvent.clear(await screen.findByLabelText('Attending tip'))
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ // Null, so the player offers no button at all on this question.
+ await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7',
+ expect.objectContaining({ attending_tip: null })))
+})
+
+it('turns a selection in the stem into a tip marker', async () => {
+ mount()
+ const stem = await screen.findByLabelText('Question text')
+ stem.setSelectionRange(12, 19)
+ stem.focus()
+ // Every field has its own toolbar, so the stem's is the one asked.
+ const bar = within(screen.getByRole('toolbar', { name: 'Stem formatting' }))
+ await userEvent.click(bar.getByRole('button', { name: 'Turn the selected words into a tip' }))
+ expect(stem).toHaveValue('A child has {{stridor|}} at rest.')
+})
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx
index 4a8c71e..827f374 100644
--- a/frontend/src/pages/QuizPage.test.jsx
+++ b/frontend/src/pages/QuizPage.test.jsx
@@ -509,6 +509,29 @@ describe('quiz player', () => {
}
})
+ it('offers no tip button on a question that has none', async () => {
+ await begin(false)
+ // Nothing to open, so nothing is offered: an empty panel behind a button
+ // is worse than no button.
+ expect(screen.queryByRole('button', { name: /Attending tip/ })).not.toBeInTheDocument()
+ })
+
+ it('offers the attending tip only where the question carries one', async () => {
+ questions[0].attending_tip = 'Look at the growth chart before the vitals.'
+ try {
+ await begin(false)
+ const open = screen.getByRole('button', { name: /Attending tip/ })
+ await userEvent.click(open)
+ expect(inCard().getByText('Look at the growth chart before the vitals.')).toBeInTheDocument()
+ // The next question has none, so the button goes with it.
+ await userEvent.click(screen.getAllByRole('button', { name: /Skip|Next/ })[0])
+ await findStem('Full second clinical question.')
+ expect(screen.queryByRole('button', { name: /Attending tip/ })).not.toBeInTheDocument()
+ } finally {
+ delete questions[0].attending_tip
+ }
+ })
+
it('starts timed quizzes in exam mode without a mode prompt', async () => {
quizModeVar = 'timed'
await begin(false)