Imported from alexfdez1010/mental-models (
.claude/skills/exercise-components/SKILL.md). Install upstream withnpx skills add alexfdez1010/mental-models --skill exercise-components. Copyright stays with the author.
Exercises & reusable interactive components
Exercises live in src/components/react/ as React 19 islands and are dropped
into MDX with a client:* directive. Always prefer reusing/extending an
existing component over inlining one-off JSX in a lesson.
Placement & density (REQUIRED)
- Every
##section gets at least one exercise — ideally two: a prequestion to open it and a check to close it. Lessons should be exercise-dense, not a text wall with a quiz at the bottom. When in doubt, add another exercise. - Open sections with a pretest (
MCQ pretest): asking the learner to guess before reading boosts encoding even when the guess is wrong (the pretesting effect). See "Pretest mode" below. - Put the check exercises after each explanation, interleaved — not pooled at the end.
- Rotate types every time — never the same format twice in a row. Available:
single
MCQ, multi-answerMCQ,MCQ pretest,MatchConcepts(concept→definition),Categorize(sort into buckets),FillBlank(pick-one-of-three cloze), scoredQuiz, andFinalExam(graded, lock-on-submit — final-exam lessons only; see below). - Vary the questions themselves — repetition is the #1 quality problem.
Rotating the component is not enough; the questions inside a
Quiz/FinalExammust vary too. Across a question set, deliberately mix:- Type of ask: numeric/calculation (show the arithmetic in the explanation), scenario/application, "which statement is TRUE", spot-the-trap, plain definition, side-by-side comparison, cause→effect, ranking-as-single-answer.
- Stem wording: never reuse the same sentence frame twice in a row, and don't open five questions with "Which of the following…". Rephrase.
- Difficulty: interleave easy recall with multi-step reasoning. If two questions feel interchangeable, rewrite one. A pool that drills the same idea with the same phrasing teaches pattern-matching, not understanding.
- For a term worth pinning down, reach for
FillBlank: each blank is a pick-one-of-three choice (the right term flanked by two convincing distractors), so the learner must still discriminate it from look-alikes without being punished for a typo. - Spaced recall: some questions reference earlier sections so the user recalls prior material, not only the latest paragraph.
- Close the lesson with a chunking recap — a learner-built
MindMapplus a mixedQuiz, not a passive bullet summary. See thelesson-animations/new-lessonskills for the MindMap close.
Write options that don't give themselves away (REQUIRED)
The fastest way to wreck a question is to make the correct option the long, detailed, careful one and the distractors short throwaways — learners pick the longest answer without reading. So:
- Length parity. All options for a question are roughly the same length. The correct one is never noticeably longer than the rest. If the right answer needs a qualifier, give the distractors comparable qualifiers.
- Plausible distractors. Every wrong option is a believable mistake — a real misconception, an adjacent term, a true-but-irrelevant fact — not an obvious joke. (Keep at most one light "nonsense" option per lesson, not per question.)
- Harder than obvious. Aim for distractors that a learner who half gets it would fall for. Test discrimination between close ideas, not recall of a single keyword sitting alone among nonsense.
- This applies to
MCQ,Quiz, andFillBlankchoices alike.
Pretest mode (prequestion)
MCQ takes a pretest boolean. In pretest mode it shows a low-stakes "Before
you read — take a guess" eyebrow and never reports to a Quiz score, so a
wrong guess costs nothing. Use one to open a section, before you've taught
the answer:
<MCQ
client:visible
pretest
question="Guess: which of these best describes the lesson's core idea?"
options={[
{ text: 'The precise, correct description', correct: true },
{ text: 'A close-but-wrong neighbour' },
{ text: 'A common misconception' },
]}
explanation="The first option is right — keep it in mind as you read on."
/>
Spanish twins pass pretestLabel="Antes de leer — adivina".
Every island must teach or test in place
Don't use a component as a fake-interactive list or to point the learner
at an exercise to do elsewhere ("now try sketching this", "go work through an
example on paper"). An off-page instruction adds nothing — the learner won't
leave the page to do it. If a step is worth doing, make it a real, graded island
here (FillBlank, Categorize, MCQ, MatchConcepts).
No StepThrough. It was removed — a click-to-advance wrapper around a
static list adds nothing a plain numbered list doesn't, and its children-API
silently rendered nothing under Astro islands. For genuinely sequential
content, just author a numbered Markdown list (1. 2. 3.). For a sequence
worth testing, use Categorize or FillBlank.
Misconfigured components MUST fail the build, not ship blank (REQUIRED)
This is non-negotiable. A component handed bad/empty/mis-shaped input must
throw during render (top of the function body, before any hooks-free early
return) so astro build fails loudly — never silently return null, render
an empty card, or render nothing. A green build must mean every exercise on the
page is answerable and correct. Silent-empty exercises are a release blocker.
Every exercise component now enforces this: MCQ (≥2 options, ≥1 correct),
Quiz (every question has a prompt, ≥2 options, a marked correct answer),
Categorize (≥2 buckets, every item's bucket matches one), MatchConcepts
(≥2 pairs, non-empty term+definition), MindMap (non-empty root.label and
children), FillBlank (each blank ≥2 choices, text has ≥1 blank). Hold any new
component to the same bar.
The island-boundary trap (this is what bit us)
Never author a client:* island that takes its content as React children.
Across a client:visible boundary Astro renders children to an opaque HTML
slot — the client component receives a slot wrapper whose props carry
none of your data. So Quiz built from <MCQ> children, and the old
StepThrough, both rendered blank after hydration while the build stayed
green. Authoring API for composed exercises is always a prop, not children:
{/* ✅ works — data crosses the boundary as a prop */}
<Quiz client:visible questions={[{ question, options, correct, explanation }, …]} />
{/* ❌ renders an empty card — children become an HTML slot, props are lost */}
<Quiz client:visible><MCQ question="…" options={[…]} correct={0} /></Quiz>
If a component must accept children, it has to detect the slot case and
throw (as Quiz now does when a derived question lacks a question/options).
Prove the guard fires (REQUIRED before you commit)
A guard you didn't watch fail is a guard you don't have. After adding one,
temporarily author the broken input in a throwaway lesson
(src/content/lessons/en/<topic>/_guardtest.mdx), run bun run build, confirm
it exits red with your named error, then delete the file and confirm a clean
build. Don't skip this — it's the only proof the build actually catches the
mistake.
Existing components (reuse these first)
Import from the barrel: import { MCQ, Quiz } from '@/components/react';
-
MCQ— single- or multi-answer question. SetallowMultipleto turn radios into checkboxes (multiple correct options):<MCQ client:visible allowMultiple question="Which of these belong to the category being taught? (select all)" options={[ { text: 'A correct member', correct: true }, { text: 'Another correct member', correct: true }, { text: 'A plausible non-member' }, { text: 'A third correct member', correct: true }, ]} explanation="The non-member is an adjacent concept, not part of this category." /> -
Quiz— sequences severalMCQprop objects and tracks a score. Practice exercise: the learner can go Back, retry a question, and Restart at the end. Use it inside teaching lessons. -
FinalExam— the graded, irreversible exam island, reserved for a course'sfinal-examlesson. Takes a 20–30 question pool viaquestions={[{ question, options, correct?, explanation?, allowMultiple? }]}(same per-question shape asMCQ). One question at a time; the learner selects and clicks Submit, which locks the answer for good — there is no Back, no Try again, and no Restart, so a wrong answer simply fails that question. The result reveals instantly on submit, but the running score stays hidden until the final screen, which shows a pass/fail verdict (passPercent, default70) and a per-question review. Because nothing resets, the exam can't be retaken in-session. Author it directly — do not wrap it in themed recap sections.<FinalExam client:visible passPercent={70} questions={[ { question: "…", options: [{ text: "…", correct: true }, { text: "…" }, { text: "…" }], explanation: "…" }, { question: "Select every true statement.", allowMultiple: true, options: [{ text: "…", correct: true }, { text: "…", correct: true }, { text: "…" }], explanation: "…" }, /* … 20–30 total, varied (see "Vary the questions themselves") … */ ]} />
All three accept i18n label props (checkLabel, retryLabel, scoreLabel,
submitLabel, lockWarningLabel, passLabel, …) — pass the Spanish strings in
es lessons (see the translate-lesson and new-lesson skills for the full
FinalExam Spanish prop set).
Concept → definition matching (new component pattern)
For "link concepts to their definitions" exercises, build a reusable
MatchConcepts (a.k.a. concept-linking) island rather than ad-hoc markup.
Shape it like the others:
// src/components/react/MatchConcepts.tsx
export interface MatchPair {
/** The concept/term shown on the left. */
term: string;
/** Its correct definition shown on the right. */
definition: string;
}
export interface MatchConceptsProps {
pairs: MatchPair[];
/** Revealed-on-success / labels, localizable. */
checkLabel?: string; // default 'Check'
retryLabel?: string; // default 'Try again'
className?: string;
onResult?: (correct: boolean) => void; // lets Quiz aggregate score
}
Behavior: shuffle definitions, let the user link each term→definition
(click-to-pair or drag), Check grades, correct links go success-green and
wrong ones danger-red, an aria-live region announces the result, Try again
resets. Mirror MCQ's interaction and grading model exactly so it composes
inside Quiz.
For a lighter "term ⇒ meaning" glossary link (definition on hover/expand, not a
graded exercise), prefer Reveal/Callout or a small Defn inline component
over a full matcher.
Categorize — sort items into buckets
For "which group does this belong to?" ideas (any two-or-more-way split your subject offers). Each item exposes one button per bucket; Check grades all of them and reveals the right bucket on a miss.
<Categorize
client:visible
question="Sort each item into the group it belongs to."
buckets={['Group A', 'Group B']}
items={[
{ text: 'First item', bucket: 'Group A' },
{ text: 'Second item', bucket: 'Group A' },
{ text: 'Third item', bucket: 'Group B' },
]}
explanation="Explain the distinguishing rule that separates Group A from Group B."
/>
FillBlank — pick-one-of-three cloze
Each blank is a small inline choice group, not a text input. Author the blank as
{{correct|distractor|distractor}}: pipe-separated options where the first
is correct and the rest are distractors. The component shuffles them with a
stable, content-seeded order (so the answer isn't always in the same slot and
SSR/CSR markup match). Three options per blank is the target; fewer than two
throws at build time.
Make distractors plausible — same category as the answer (sibling terms from the same part of your subject), never throwaway nonsense. That's what keeps it a real discrimination test rather than a freebie.
<FillBlank
client:visible
question="Fill in the right term for each blank."
text="The {{correct-term|sibling-term|another-sibling}} is the one that {{does the key thing|does a related thing|does an unrelated thing}} in this lesson."
explanation="State why the correct term fits and the look-alikes don't."
/>
Exposes onResult(correct), so it composes inside Quiz like MCQ. Pass
Spanish label props (checkLabel, retryLabel, explanationLabel, plus
instructions) in the es twin.
Rules for ANY new reusable component
- File + barrel. Create
src/components/react/<Name>.tsx; export the component, itsdefault, and all prop/option types fromsrc/components/react/index.ts(follow the existing pattern exactly). @alias only, and use the sharedcxhelper (@/components/react/cx) for class merging — never relative imports, neverclsxad-hoc.- Design tokens only. Style with Tailwind token utilities
(
bg-surface,text-ink-700,bg-brand-600, success/danger tokens) — no raw hex. SeeDESIGN.md. - Accessibility is required. Real form controls (
<input>/<button>),radiogroup/groupsemantics, full keyboard operability,aria-livefor dynamic results, visible focus rings, and honorprefers-reduced-motion. - i18n. Every user-facing string is a prop with an English default so Spanish lessons can pass translated labels. No hard-coded copy inside.
- Composability. Graded exercises expose
onResult(correct)soQuizcan aggregate them. - Build-time guards (REQUIRED). Validate inputs at the top of the render and
throwon anything mis-shaped — empty/too-few options, no correct answer, orphan references, empty data — with a message naming the offending prop. The content authoring API is a prop, never children (see the island-boundary trap above). Then prove it: temp-author the broken input, confirmbun run buildgoes red, delete it. A misconfigured exercise must never ship blank. - Type + verify. Document props with JSDoc, then
bun run check(0 errors).
Wire it into a lesson
import { MCQ, Quiz, MatchConcepts, Categorize, FillBlank, MindMap } from '@/components/react';
<MatchConcepts
client:visible
pairs={[
{ term: 'First term', definition: 'A crisp, correct definition of the first term.' },
{ term: 'Second term', definition: 'A crisp, correct definition of the second term.' },
]}
/>
Aim for at least one exercise per lesson. Then regenerate OG and run
bun run check.