@brandon_m_behring/book-scaffold-astro 4.19.0 → 4.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -104,7 +104,7 @@ Two callout families coexist. Authors import what they need.
104
104
 
105
105
  **Provenance** (v4.8.0, any profile, **auto-injected by the chapter route — not author-imported**): per-chapter "How this was made" audit-trail block, rendered from the optional `provenance` frontmatter (`ai_tools`, `prompts_archive`, `decisions_log`, `audit_history`, `citation_backstop`). **Opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure). Repo-relative path fields render as `<code>`; only `http(s)` values link.
106
106
 
107
- **Study-guide (v4.17.0+, #112; opt-in).** A schema-validated `questions` content collection drives an exam-prep "question bank". Author questions under `src/content/questions/**.{md,mdx}` — frontmatter `id` (unique, required) / `type` (`mcq`|`free`|`cloze`) / `chapter` / `domain` (+ optional `part`/`bloom_level`/`objective_id`/`difficulty`), MDX body = the stem. MCQ carries `options: [{ id, text, correct }]` (exactly one `correct: true`); free-response carries an `answer` (model answer). An MCQ must **not** set `answer` — its answer is the option marked `correct`, and explanations for any type go in a `<Rationale>` body block. Declare the per-book domain taxonomy in `defineBookConfig({ examDomains: ['…'] })` — a question whose `domain` isn't registered **throws at build** (fail-loud, like `<BookLink>`'s `siblingBooks`). Enable the static `/practice-exam` route with `defineBookConfig({ routes: { practiceExam: true } })` (renders the bank grouped by domain with answers behind a `<details>` reveal; `cloze` is reserved/render-deferred). `<ObjectiveMap />` renders the exam-domain → chapter coverage matrix auto-derived from the collection (no separate data file). `<Rationale>` is a collapsible answer/explanation marker for a question's MDX body. `<Diagnostic>` (v4.19.0, #110) renders a per-chapter pre-reading "Do I Know This Already?" self-check (pedagogy family above; static `<details>` answer reveal). `<PartReview part={N} />` (v4.19.0, #111) aggregates a Part's `<Exercise>` items for interleaved review — reusing the `build-exercises` index + the chapters' `part` field (run `book-scaffold build-exercises` first; presence-gated otherwise). A **searchable glossary** (v4.19.0, #115): author terms under `src/content/glossary/**` (frontmatter `term` + an MDX-body definition), enable the static `/glossary` route via `defineBookConfig({ routes: { glossary: true } })`, and link inline with `<Term id="…">…</Term>` (→ `/glossary#term-<id>`). (Scoring, the rationale appendix, and flashcards are later increments — see `docs/plans/active/study-guide-epic_*.md`.)
107
+ **Study-guide (v4.17.0+, #112; opt-in).** A schema-validated `questions` content collection drives an exam-prep "question bank". Author questions under `src/content/questions/**.{md,mdx}` — frontmatter `id` (unique, required) / `type` (`mcq`|`free`|`cloze`) / `chapter` / `domain` (+ optional `part`/`bloom_level`/`objective_id`/`difficulty`), MDX body = the stem. MCQ carries `options: [{ id, text, correct }]` (exactly one `correct: true`); free-response carries an `answer` (model answer). An MCQ must **not** set `answer` — its answer is the option marked `correct`, and explanations for any type go in a `<Rationale>` body block. Declare the per-book domain taxonomy in `defineBookConfig({ examDomains: ['…'] })` — a question whose `domain` isn't registered **throws at build** (fail-loud, like `<BookLink>`'s `siblingBooks`). Enable the static `/practice-exam` route with `defineBookConfig({ routes: { practiceExam: true } })` (renders the bank grouped by domain with answers behind a `<details>` reveal; `cloze` is reserved/render-deferred). `<ObjectiveMap />` renders the exam-domain → chapter coverage matrix auto-derived from the collection (no separate data file). `<Rationale>` is a collapsible answer/explanation marker for a question's MDX body. `<Diagnostic>` (v4.19.0, #110) renders a per-chapter pre-reading "Do I Know This Already?" self-check (pedagogy family above; static `<details>` answer reveal). `<PartReview part={N} />` (v4.19.0, #111) aggregates a Part's `<Exercise>` items for interleaved review — reusing the `build-exercises` index + the chapters' `part` field (run `book-scaffold build-exercises` first; presence-gated otherwise). A **searchable glossary** (v4.19.0, #115): author terms under `src/content/glossary/**` (frontmatter `term` + an MDX-body definition), enable the static `/glossary` route via `defineBookConfig({ routes: { glossary: true } })`, and link inline with `<Term id="…">…</Term>` (→ `/glossary#term-<id>`). **Interactive layer (v4.21.0, #112-UI/#113/#114):** `/practice-exam` mounts the **ExamRunner island** (`client:idle`) — Start samples a form client-side (`sampleExam`), hides the rest of the bank + the answer reveals, scores checked radios on submit (`scoreExam`), and reads out per-domain results with weak-domain anchors (no JS → the static bank is the fallback). `<AssessmentTest />` is the whole-book front-matter diagnostic: a cross-domain sampled form whose weak-domain readout routes to the chapters carrying those domains' questions. The `/answers` route (`routes: { answers: true }`) is the Sybex back-appendix — every question grouped by chapter with the correct answer + rationale pre-expanded; `<Rationale appendix for="<id>">` renders inline as a link into it (and throws at build without `for=` or with the route disabled). (Flashcards are the remaining increment — see `docs/plans/active/study-guide-epic_*.md`.)
108
108
 
109
109
  Full reference in `recipes/04-component-library.md`.
110
110
 
@@ -0,0 +1,124 @@
1
+ ---
2
+ /**
3
+ * <AssessmentTest> — whole-book front-matter assessment test (v4.21.0, #113).
4
+ *
5
+ * The Sybex-style "where do I stand?" diagnostic: a cross-domain sampled form
6
+ * drawn from the WHOLE question bank, scored client-side with a weak-domain
7
+ * readout that routes the reader to the chapters carrying those domains'
8
+ * questions. Drop into a front-matter / intro page (like <ObjectiveMap>);
9
+ * any profile.
10
+ *
11
+ * Server side (this file): presence-gate the questions collection, fail loud on
12
+ * unregistered domains (assertKnownDomain), render every scoreable MCQ as a
13
+ * QuestionCard (stems are MDX — they can't cross into island props), and mount
14
+ * the ExamRunner island in assessment mode with the pure manifest + the
15
+ * domain→chapters routing map (deriveDomainRouting: string chapters link to
16
+ * /chapters/<slug>/, numeric chapters render as labels — no fabricated URLs).
17
+ * Cross-domain spread is the island's blueprint (spreadBlueprint).
18
+ *
19
+ * free/cloze questions are omitted here entirely (not just hidden): the
20
+ * assessment is the scored diagnostic, while /practice-exam remains the
21
+ * complete bank listing.
22
+ *
23
+ * No-JS fallback: the cards render with radios as a manual self-check and
24
+ * <details> answer reveals.
25
+ */
26
+ import bookConfig from 'virtual:book-scaffold/book-config';
27
+ import { render } from 'astro:content';
28
+ import QuestionCard from './QuestionCard.astro';
29
+ import ExamRunner from '@brandon_m_behring/book-scaffold-astro/components/ExamRunner';
30
+ import { getAllQuestions } from '../src/lib/questions';
31
+ import { assertKnownDomain } from '../src/lib/exam-domains';
32
+ import { buildExamManifest, deriveDomainRouting } from '../src/lib/exam-manifest';
33
+ import '../styles/exam-runner.css';
34
+
35
+ interface Props {
36
+ /** Heading above the test. Defaults to "Assessment test". */
37
+ title?: string;
38
+ /** Default form size (clamped to the scoreable pool). Defaults to 12. */
39
+ count?: number;
40
+ /** Weak-domain threshold (0–1) passed to scoreExam. Defaults to 0.7. */
41
+ passMark?: number;
42
+ }
43
+ const { title = 'Assessment test', count, passMark } = Astro.props;
44
+
45
+ // Fail loud on prop misuse (v4.15.0 closed-union-props line): passMark={70}
46
+ // (percent-vs-fraction confusion) would silently flag EVERY domain weak on
47
+ // every run — confidently-wrong output, the exact class this repo forbids.
48
+ if (passMark !== undefined && !(passMark > 0 && passMark <= 1)) {
49
+ throw new Error(
50
+ `<AssessmentTest passMark={${passMark}}> — passMark is a fraction in (0, 1] ` +
51
+ `(e.g. 0.7 for 70%), not a percent.`,
52
+ );
53
+ }
54
+ if (count !== undefined && (!Number.isInteger(count) || count < 1)) {
55
+ throw new Error(
56
+ `<AssessmentTest count={${count}}> — count must be a positive integer.`,
57
+ );
58
+ }
59
+
60
+ // Presence-gate: only touch the collection when the directory holds files
61
+ // (same twin-gate as <ObjectiveMap> / practice-exam.astro).
62
+ const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}', {
63
+ query: '?raw',
64
+ import: 'default',
65
+ eager: true,
66
+ });
67
+ const hasQuestions = Object.keys(questionModules).length > 0;
68
+
69
+ const entries = hasQuestions ? await getAllQuestions() : [];
70
+
71
+ // Fail loud: an unregistered/typo'd domain must throw at build, not silently
72
+ // skew the assessment's domain spread.
73
+ for (const e of entries) {
74
+ assertKnownDomain(bookConfig.examDomains, e.data.domain, { id: e.data.id });
75
+ }
76
+
77
+ // entries are already draft-filtered (getAllQuestions filters !draft), so this
78
+ // is type-shape filtering only — it matches buildExamManifest's pool exactly,
79
+ // which is what the island's drift guard assumes.
80
+ const scoreable = entries.filter(
81
+ (e) => e.data.type === 'mcq' && (e.data.options?.length ?? 0) > 0,
82
+ );
83
+ const manifest = buildExamManifest(entries);
84
+ const domainRouting = deriveDomainRouting(entries);
85
+ const practiceExamHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
86
+ ? '/practice-exam'
87
+ : null;
88
+
89
+ const rendered = await Promise.all(
90
+ scoreable.map(async (e) => ({ data: e.data, Content: (await render(e)).Content })),
91
+ );
92
+ ---
93
+ {!hasQuestions ? (
94
+ <p class="assessment-empty">
95
+ No questions found under <code>src/content/questions/</code> — the assessment
96
+ test draws from the question bank. Add question MDX files and declare your
97
+ <code>examDomains</code> in <code>defineBookConfig</code>.
98
+ </p>
99
+ ) : (
100
+ <section class="assessment-test" data-exam-root aria-label={title}>
101
+ <h2 class="assessment-title">{title}</h2>
102
+ {/* Without JS the island's Start button would render dead — hide the
103
+ controls; the cards below remain a manual self-check. */}
104
+ <noscript><style is:inline>.exam-runner { display: none; }</style></noscript>
105
+ <ExamRunner
106
+ client:idle
107
+ mode="assessment"
108
+ manifest={manifest}
109
+ count={count}
110
+ passMark={passMark}
111
+ domainRouting={domainRouting}
112
+ practiceExamHref={practiceExamHref}
113
+ />
114
+ {rendered.map((q) => (
115
+ <QuestionCard data={q.data} Content={q.Content} reserved={false} />
116
+ ))}
117
+ </section>
118
+ )}
119
+
120
+ <style>
121
+ .assessment-test { margin-block: 1.5rem; }
122
+ .assessment-title { margin-block-end: 0.5rem; }
123
+ .assessment-empty { color: var(--color-text-muted, #6b7280); }
124
+ </style>
@@ -0,0 +1,313 @@
1
+ /**
2
+ * ExamRunner — Preact island driving the interactive practice exam (#112-UI)
3
+ * and the front-matter assessment test (#113).
4
+ *
5
+ * Architecture: a CONTROLLER over server-rendered question cards, not a
6
+ * client-side renderer. MDX stems can't serialize into island props, so the
7
+ * .astro side renders every card statically (QuestionCard.astro: stem, options
8
+ * as radio inputs named `exam-<qid>`, answer behind <details>) and this island
9
+ * receives only the pure manifest — the exact `ExamQuestion` shape
10
+ * sampleExam/scoreExam consume. The island samples a form client-side, hides
11
+ * the cards that aren't in it, collects the checked radios on submit, scores
12
+ * with the SAME engine the node:test suite verifies, and renders the
13
+ * score/per-domain/weak-domain readout. No JS → the static bank with
14
+ * <details> reveals is untouched.
15
+ *
16
+ * DOM contract with the .astro side (QuestionCard inside a [data-exam-root]):
17
+ * [data-exam-root] wrapper section; gains
18
+ * data-exam-phase="active|review"
19
+ * [data-question-id="<id>"] one card per question; toggled via
20
+ * the `hidden` attribute; gains
21
+ * data-exam-result="correct|incorrect"
22
+ * input[name="exam-<id>"]:checked the reader's chosen option id
23
+ * details.question-reveal force-opened on review
24
+ *
25
+ * Hydrated with `client:idle`. Theme via CSS tokens only (no canvas — no
26
+ * book:theme:change listener needed).
27
+ */
28
+ import { useRef, useState } from 'preact/hooks';
29
+ import {
30
+ sampleExam,
31
+ scoreExam,
32
+ type ExamQuestion,
33
+ type ExamResult,
34
+ } from '../src/lib/exam-engine';
35
+ import { spreadBlueprint, type RoutingChapter } from '../src/lib/exam-manifest';
36
+
37
+ interface Props {
38
+ /** Scoreable MCQ pool (buildExamManifest output) — ids/domains/options only. */
39
+ manifest: ExamQuestion[];
40
+ /** practice: domain-agnostic sampling, weak domains anchor to #domain-<d> on
41
+ * the same page. assessment: cross-domain spread blueprint, weak domains
42
+ * route to chapters via `domainRouting`. */
43
+ mode: 'practice' | 'assessment';
44
+ /** Default form size (clamped to the pool; reader can adjust before start). */
45
+ count?: number;
46
+ /** Weak-domain threshold passed to scoreExam (default 0.7). */
47
+ passMark?: number;
48
+ /** Assessment mode: domain → chapters carrying its questions (deriveDomainRouting). */
49
+ domainRouting?: Record<string, RoutingChapter[]>;
50
+ /** Assessment mode: href of the practice bank when that route is enabled, else null. */
51
+ practiceExamHref?: string | null;
52
+ }
53
+
54
+ type Phase = 'idle' | 'active' | 'review';
55
+
56
+ export default function ExamRunner({
57
+ manifest,
58
+ mode,
59
+ count,
60
+ passMark = 0.7,
61
+ domainRouting = {},
62
+ practiceExamHref = null,
63
+ }: Props) {
64
+ const poolSize = manifest.length;
65
+ const domainCount = new Set(manifest.map((q) => q.domain)).size;
66
+ // Assessment floors at one question per domain (see start()); the default
67
+ // and the input's min respect that so the UI can't request a starved form.
68
+ const minCount = mode === 'assessment' ? Math.max(1, domainCount) : 1;
69
+ const defaultCount = Math.min(
70
+ Math.max(count ?? (mode === 'assessment' ? 12 : 10), minCount),
71
+ poolSize,
72
+ );
73
+ const [phase, setPhase] = useState<Phase>('idle');
74
+ const [requested, setRequested] = useState(defaultCount);
75
+ const [form, setForm] = useState<ExamQuestion[]>([]);
76
+ const [result, setResult] = useState<ExamResult | null>(null);
77
+ const ref = useRef<HTMLDivElement>(null);
78
+
79
+ function requireRoot(): HTMLElement {
80
+ // Fail loud (house invariant): a silently dead Start button is the worst
81
+ // failure mode. The throw surfaces as an uncaught console error.
82
+ const r = ref.current?.closest<HTMLElement>('[data-exam-root]');
83
+ if (!r) {
84
+ throw new Error(
85
+ 'ExamRunner: no [data-exam-root] ancestor — mount the island inside the ' +
86
+ 'wrapper that contains its QuestionCards (see the DOM contract in ExamRunner.tsx).',
87
+ );
88
+ }
89
+ return r;
90
+ }
91
+ function cards(r: HTMLElement): HTMLElement[] {
92
+ // Array.from, not spread — the dts tsconfig lib lacks DOM.Iterable.
93
+ return Array.from(r.querySelectorAll<HTMLElement>('[data-question-id]'));
94
+ }
95
+ function radios(r: HTMLElement): HTMLInputElement[] {
96
+ return Array.from(r.querySelectorAll<HTMLInputElement>('input[type="radio"]'));
97
+ }
98
+
99
+ function start(): void {
100
+ const r = requireRoot();
101
+ // Assessment mode floors at one question per domain — a "cross-domain"
102
+ // form that silently drops late-book domains would betray its own point
103
+ // (spreadBlueprint's quota order starves the tail otherwise).
104
+ const n = Math.max(minCount, Math.min(requested, poolSize));
105
+ const sampled =
106
+ mode === 'assessment'
107
+ ? sampleExam(manifest, spreadBlueprint(manifest, n))
108
+ : sampleExam(manifest, { count: n });
109
+ const inForm = new Set(sampled.map((q) => q.id));
110
+ const allCards = cards(r);
111
+ // Fail loud on manifest/DOM drift: a sampled question with no rendered
112
+ // card would be invisible yet scored incorrect — silently wrong results.
113
+ const cardIds = new Set(allCards.map((c) => c.dataset.questionId));
114
+ const missing = sampled.filter((q) => !cardIds.has(q.id));
115
+ if (missing.length > 0) {
116
+ throw new Error(
117
+ `ExamRunner: manifest/DOM drift — no rendered card for question(s): ` +
118
+ `${missing.map((q) => q.id).join(', ')}.`,
119
+ );
120
+ }
121
+ for (const card of allCards) {
122
+ card.hidden = !inForm.has(card.dataset.questionId ?? '');
123
+ card.removeAttribute('data-exam-result');
124
+ const reveal = card.querySelector<HTMLDetailsElement>('details.question-reveal');
125
+ if (reveal) reveal.open = false;
126
+ }
127
+ for (const input of radios(r)) {
128
+ input.checked = false;
129
+ }
130
+ r.setAttribute('data-exam-phase', 'active');
131
+ setForm(sampled);
132
+ setResult(null);
133
+ setPhase('active');
134
+ }
135
+
136
+ function submit(): void {
137
+ const r = requireRoot();
138
+ const answers: Record<string, string> = {};
139
+ for (const q of form) {
140
+ // CSS.escape: a question id containing a quote would otherwise break
141
+ // the selector and throw a DOMException mid-submit (frozen exam).
142
+ const checked = r.querySelector<HTMLInputElement>(
143
+ `input[name="exam-${CSS.escape(q.id)}"]:checked`,
144
+ );
145
+ if (checked) answers[q.id] = checked.value;
146
+ }
147
+ for (const q of form) {
148
+ const card = r.querySelector<HTMLElement>(
149
+ `[data-question-id="${CSS.escape(q.id)}"]`,
150
+ );
151
+ if (!card) {
152
+ // start() already guards drift; defense in depth, same loud failure.
153
+ throw new Error(`ExamRunner: no rendered card for question "${q.id}".`);
154
+ }
155
+ const right = q.options.some((o) => o.correct === true && o.id === answers[q.id]);
156
+ card.setAttribute('data-exam-result', right ? 'correct' : 'incorrect');
157
+ const reveal = card.querySelector<HTMLDetailsElement>('details.question-reveal');
158
+ if (reveal) reveal.open = true;
159
+ }
160
+ r.setAttribute('data-exam-phase', 'review');
161
+ setResult(scoreExam(form, answers, passMark));
162
+ setPhase('review');
163
+ }
164
+
165
+ function reset(): void {
166
+ const r = requireRoot();
167
+ for (const card of cards(r)) {
168
+ card.hidden = false;
169
+ card.removeAttribute('data-exam-result');
170
+ }
171
+ for (const input of radios(r)) {
172
+ input.checked = false;
173
+ }
174
+ r.removeAttribute('data-exam-phase');
175
+ setForm([]);
176
+ setResult(null);
177
+ setPhase('idle');
178
+ }
179
+
180
+ if (poolSize === 0) {
181
+ return (
182
+ <p class="exam-runner-empty">
183
+ No auto-scoreable (multiple-choice) questions available — free-response and
184
+ cloze items can't be machine-scored.
185
+ </p>
186
+ );
187
+ }
188
+
189
+ return (
190
+ <div class="exam-runner" ref={ref}>
191
+ {phase === 'idle' && (
192
+ <div class="exam-runner-controls">
193
+ <p class="exam-runner-intro">
194
+ {mode === 'assessment'
195
+ ? 'Take a cross-domain assessment: a sampled form spread over every exam domain, scored with a weak-domain readout routing you to the chapters to (re)read.'
196
+ : 'Take a scored practice exam: a random form sampled from the bank below, with a per-domain score readout.'}
197
+ </p>
198
+ <label class="exam-runner-count-label">
199
+ Questions:{' '}
200
+ <input
201
+ type="number"
202
+ class="exam-runner-count"
203
+ min={minCount}
204
+ max={poolSize}
205
+ value={requested}
206
+ onInput={(e) => {
207
+ const v = Number.parseInt((e.target as HTMLInputElement).value, 10);
208
+ if (Number.isFinite(v)) setRequested(Math.max(minCount, Math.min(v, poolSize)));
209
+ }}
210
+ />{' '}
211
+ of {poolSize}
212
+ </label>
213
+ <button type="button" class="exam-runner-button exam-runner-start" onClick={start}>
214
+ Start {mode === 'assessment' ? 'assessment' : 'practice exam'}
215
+ </button>
216
+ </div>
217
+ )}
218
+
219
+ {phase === 'active' && (
220
+ <div class="exam-runner-controls">
221
+ <p class="exam-runner-status" role="status">
222
+ {form.length} question{form.length === 1 ? '' : 's'} below — answers stay
223
+ hidden until you submit.
224
+ </p>
225
+ <button type="button" class="exam-runner-button exam-runner-submit" onClick={submit}>
226
+ Submit answers
227
+ </button>
228
+ <button type="button" class="exam-runner-button exam-runner-cancel" onClick={reset}>
229
+ Cancel
230
+ </button>
231
+ </div>
232
+ )}
233
+
234
+ {phase === 'review' && result && (
235
+ <div class="exam-runner-scoreboard" aria-live="polite">
236
+ <p class="exam-runner-score">
237
+ <strong>{result.pct}%</strong> — {result.correct} of {result.total} correct
238
+ </p>
239
+ <table class="exam-runner-domains">
240
+ <thead>
241
+ <tr>
242
+ <th scope="col">Domain</th>
243
+ <th scope="col">Score</th>
244
+ </tr>
245
+ </thead>
246
+ <tbody>
247
+ {result.byDomain.map((d) => (
248
+ <tr class={result.weakDomains.includes(d.domain) ? 'exam-runner-weak' : undefined}>
249
+ <th scope="row">{d.domain}</th>
250
+ <td>
251
+ {d.correct}/{d.total}
252
+ {result.weakDomains.includes(d.domain) && (
253
+ <span class="exam-runner-weak-mark"> — review</span>
254
+ )}
255
+ </td>
256
+ </tr>
257
+ ))}
258
+ </tbody>
259
+ </table>
260
+ {result.weakDomains.length > 0 && (
261
+ <div class="exam-runner-routing">
262
+ <p class="exam-runner-routing-lead">
263
+ {mode === 'assessment'
264
+ ? 'Weak domains — start with these chapters:'
265
+ : 'Weak domains — review these sections of the bank:'}
266
+ </p>
267
+ <ul class="exam-runner-routing-list">
268
+ {result.weakDomains.map((domain) => (
269
+ <li>
270
+ <strong class="exam-runner-routing-domain">{domain}</strong>
271
+ {mode === 'practice' && (
272
+ <>
273
+ {' '}
274
+ — <a href={`#domain-${domain}`}>jump to {domain} questions</a>
275
+ </>
276
+ )}
277
+ {mode === 'assessment' && (domainRouting[domain]?.length ?? 0) > 0 && (
278
+ <>
279
+ {' — '}
280
+ {domainRouting[domain]!.map((ch, i) => (
281
+ <>
282
+ {i > 0 && ', '}
283
+ {ch.href ? (
284
+ <a href={ch.href}>chapter {ch.label}</a>
285
+ ) : (
286
+ <span>chapter {ch.label}</span>
287
+ )}
288
+ </>
289
+ ))}
290
+ </>
291
+ )}
292
+ {mode === 'assessment' && practiceExamHref && (
293
+ <>
294
+ {' '}
295
+ (<a href={`${practiceExamHref}#domain-${domain}`}>practice more</a>)
296
+ </>
297
+ )}
298
+ </li>
299
+ ))}
300
+ </ul>
301
+ </div>
302
+ )}
303
+ <button type="button" class="exam-runner-button exam-runner-retake" onClick={start}>
304
+ Retake
305
+ </button>
306
+ <button type="button" class="exam-runner-button exam-runner-reset" onClick={reset}>
307
+ {mode === 'assessment' ? 'Show all questions' : 'Show full bank'}
308
+ </button>
309
+ </div>
310
+ )}
311
+ </div>
312
+ );
313
+ }
@@ -0,0 +1,166 @@
1
+ ---
2
+ /**
3
+ * QuestionCard — one server-rendered question card (v4.21.0, #112-UI/#113).
4
+ *
5
+ * The shared partial between /practice-exam and <AssessmentTest>: renders the
6
+ * MDX stem (passed as a rendered Content component — stems can't cross into
7
+ * island props), MCQ options as radio inputs the ExamRunner island reads on
8
+ * submit, and the answer behind the static <details> reveal (the no-JS
9
+ * fallback — radios still work as a manual self-check without the island).
10
+ *
11
+ * DOM contract with ExamRunner.tsx (see its module doc): data-question-id /
12
+ * data-exam-scoreable on the article; input[name="exam-<id>"] per option;
13
+ * details.question-reveal. The exam-phase CSS lives here (with :global() for
14
+ * the [data-exam-phase] ancestor the island sets on the wrapper section):
15
+ * answers hide during an active exam, cards get a result border on review.
16
+ */
17
+ import type { Question } from '../src/schemas.js';
18
+
19
+ interface Props {
20
+ /** The question's frontmatter (CollectionEntry data). */
21
+ data: Question;
22
+ /** The rendered MDX stem, or null for reserved (cloze) questions. */
23
+ Content: any | null;
24
+ /** True for cloze — schema-accepted, render-deferred. */
25
+ reserved: boolean;
26
+ }
27
+ const { data, Content, reserved } = Astro.props;
28
+
29
+ const scoreable = data.type === 'mcq' && (data.options?.length ?? 0) > 0;
30
+ const correct = data.type === 'mcq' && data.options
31
+ ? data.options.find((o) => o.correct)
32
+ : undefined;
33
+
34
+ /** Filled/hollow diamonds for a 1–4 difficulty, mirroring Practice.astro. */
35
+ function difficultyMarks(d: string | undefined): string {
36
+ if (!d) return '';
37
+ const n = Number.parseInt(d, 10);
38
+ return '◆'.repeat(n) + '◇'.repeat(4 - n);
39
+ }
40
+ ---
41
+ <article
42
+ class="question"
43
+ id={`question-${data.id}`}
44
+ data-question-id={data.id}
45
+ data-question-domain={data.domain}
46
+ data-exam-scoreable={scoreable ? 'true' : 'false'}
47
+ >
48
+ <header class="question-header">
49
+ <span class="question-meta-id">{data.id}</span>
50
+ <span class="question-meta-type">{data.type}</span>
51
+ {data.bloom_level && <span class="question-meta-bloom">{data.bloom_level}</span>}
52
+ {data.difficulty && (
53
+ <span class="question-meta-difficulty" aria-label={`Difficulty ${data.difficulty} of 4`}>
54
+ {difficultyMarks(data.difficulty)}
55
+ </span>
56
+ )}
57
+ </header>
58
+
59
+ {reserved ? (
60
+ <p class="question-reserved">
61
+ Cloze question — interactive rendering deferred to a later release.
62
+ </p>
63
+ ) : (
64
+ Content && (
65
+ <div class="question-stem"><Content /></div>
66
+ )
67
+ )}
68
+
69
+ {data.type === 'mcq' && data.options && (
70
+ <fieldset class="question-options">
71
+ <legend class="question-options-legend">Options</legend>
72
+ {data.options.map((o) => (
73
+ <label class="question-option">
74
+ <input type="radio" name={`exam-${data.id}`} value={o.id} />
75
+ <span class="question-option-key">{o.id}.</span>
76
+ <span class="question-option-text">{o.text ?? o.id}</span>
77
+ </label>
78
+ ))}
79
+ </fieldset>
80
+ )}
81
+
82
+ {!reserved && (
83
+ <details class="question-reveal">
84
+ <summary>Show answer</summary>
85
+ {data.type === 'mcq' && correct && (
86
+ <p class="question-answer-line">
87
+ Correct: <strong>{correct.text ?? correct.id}</strong>
88
+ </p>
89
+ )}
90
+ {data.type === 'free' && data.answer && (
91
+ <p class="question-answer-line">{data.answer}</p>
92
+ )}
93
+ </details>
94
+ )}
95
+ </article>
96
+
97
+ <style>
98
+ .question {
99
+ margin-block: 1.25rem;
100
+ padding: 1rem 1.1rem;
101
+ border: 1px solid var(--color-border, #e5e7eb);
102
+ border-radius: 0.5rem;
103
+ background: var(--color-surface, transparent);
104
+ }
105
+ .question-header {
106
+ display: flex;
107
+ flex-wrap: wrap;
108
+ gap: 0.5rem;
109
+ align-items: center;
110
+ font-size: 0.78rem;
111
+ color: var(--color-text-muted, #6b7280);
112
+ margin-block-end: 0.5rem;
113
+ }
114
+ .question-meta-id { font-family: var(--font-mono, ui-monospace, monospace); }
115
+ .question-meta-type,
116
+ .question-meta-bloom {
117
+ text-transform: uppercase;
118
+ letter-spacing: 0.04em;
119
+ padding: 0.05rem 0.4rem;
120
+ border-radius: 0.25rem;
121
+ background: var(--color-code-bg, rgba(127, 127, 127, 0.12));
122
+ }
123
+ .question-meta-difficulty { margin-inline-start: auto; letter-spacing: 0.1em; }
124
+ .question-options {
125
+ margin-block: 0.6rem;
126
+ border: none;
127
+ padding: 0;
128
+ }
129
+ .question-options-legend {
130
+ position: absolute;
131
+ width: 1px;
132
+ height: 1px;
133
+ overflow: hidden;
134
+ clip-path: inset(50%);
135
+ }
136
+ .question-option {
137
+ display: flex;
138
+ gap: 0.5rem;
139
+ align-items: baseline;
140
+ margin-block: 0.25rem;
141
+ cursor: pointer;
142
+ }
143
+ .question-option-key { color: var(--color-text-muted, #6b7280); }
144
+ .question-reveal { margin-block-start: 0.6rem; }
145
+ .question-reveal > summary {
146
+ cursor: pointer;
147
+ font-weight: 600;
148
+ color: var(--color-accent, #4f46e5);
149
+ }
150
+ .question-answer-line { margin-block: 0.5rem 0.25rem; }
151
+ .question-reserved { color: var(--color-text-muted, #6b7280); font-style: italic; }
152
+
153
+ /* Exam phases (ExamRunner island sets data-exam-phase on the wrapper
154
+ section): no peeking at answers during an active exam; result borders +
155
+ re-opened reveals on review. :global() crosses the wrapper's scope — the
156
+ element side stays scoped to this card. */
157
+ :global([data-exam-phase='active']) .question-reveal { display: none; }
158
+ .question[data-exam-result='correct'] {
159
+ border-color: var(--color-success, #15803d);
160
+ box-shadow: inset 3px 0 0 var(--color-success, #15803d);
161
+ }
162
+ .question[data-exam-result='incorrect'] {
163
+ border-color: var(--color-danger, #b91c1c);
164
+ box-shadow: inset 3px 0 0 var(--color-danger, #b91c1c);
165
+ }
166
+ </style>