@brandon_m_behring/book-scaffold-astro 4.20.0 → 4.22.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 +1 -1
- package/components/AssessmentTest.astro +124 -0
- package/components/ExamRunner.tsx +313 -0
- package/components/Flashcards.tsx +247 -0
- package/components/QuestionCard.astro +166 -0
- package/components/Rationale.astro +58 -11
- package/dist/components/ExamRunner.d.ts +24 -0
- package/dist/components/ExamRunner.mjs +291 -0
- package/dist/components/Flashcards.d.ts +12 -0
- package/dist/components/Flashcards.mjs +217 -0
- package/dist/exam-manifest-DbTHo90M.d.ts +132 -0
- package/dist/flashcards-DPFFQhP8.d.ts +36 -0
- package/dist/index.d.ts +7 -81
- package/dist/index.mjs +68 -0
- package/dist/schemas-DDWDRUxs.d.ts +491 -0
- package/dist/schemas.d.ts +2 -1
- package/dist/schemas.mjs +20 -0
- package/dist/{types-DJgLQGP9.d.ts → types-CwsA0C9T.d.ts} +19 -490
- package/package.json +13 -1
- package/pages/answers.astro +189 -0
- package/pages/flashcards.astro +109 -0
- package/pages/practice-exam.astro +37 -104
- package/recipes/04-component-library.md +6 -2
- package/recipes/09-validation.md +1 -1
- package/scripts/validate.mjs +42 -0
- package/src/lib/exam-manifest.ts +90 -0
- package/src/lib/flashcards.ts +40 -0
- package/src/profile-kit.ts +17 -0
- package/src/profiles/academic.ts +2 -0
- package/src/profiles/course-notes.ts +2 -0
- package/src/profiles/minimal.ts +2 -0
- package/src/profiles/research-portfolio.ts +2 -0
- package/src/profiles/tools.ts +2 -0
- package/styles/exam-runner.css +90 -0
- package/styles/flashcards.css +82 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flashcards — Preact island for the spaced-recall deck (#116).
|
|
3
|
+
*
|
|
4
|
+
* Same controller-over-server-rendered-cards architecture as ExamRunner: card
|
|
5
|
+
* backs are MDX glossary definitions (not serializable into island props), so
|
|
6
|
+
* flashcards.astro renders every card statically and this island receives only
|
|
7
|
+
* the deck manifest (id + front). Start shuffles (the exam-engine Fisher–Yates)
|
|
8
|
+
* and shows ONE card at a time — others get the `hidden` attribute, the
|
|
9
|
+
* wrapper gains data-flashcards-mode="deck", and CSS keyed on that attribute
|
|
10
|
+
* hides the back of the unflipped current card. Know/still-learning buckets
|
|
11
|
+
* persist to localStorage (ToolFilter pattern) so the "review unknown only"
|
|
12
|
+
* pass survives reloads. No JS → the full front+back list stays readable.
|
|
13
|
+
*
|
|
14
|
+
* DOM contract with flashcards.astro:
|
|
15
|
+
* [data-flashcards-root] wrapper; gains data-flashcards-mode="deck"
|
|
16
|
+
* [data-card-id="<id>"] one card per term; `hidden` toggled;
|
|
17
|
+
* gains/loses class "flashcard-flipped"
|
|
18
|
+
*
|
|
19
|
+
* Fail-loud (house invariant): a missing wrapper or deck/DOM drift throws a
|
|
20
|
+
* named error — never silently dead buttons or a silently short deck.
|
|
21
|
+
*
|
|
22
|
+
* Hydrated with `client:idle`. Colors via CSS tokens only.
|
|
23
|
+
*/
|
|
24
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
25
|
+
import { shuffle } from '../src/lib/exam-engine';
|
|
26
|
+
import type { FlashcardRef } from '../src/lib/flashcards';
|
|
27
|
+
|
|
28
|
+
const STORAGE_KEY = 'book:flashcards:known';
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
/** Deck manifest (buildFlashcardDeck output) — card ids + fronts only. */
|
|
32
|
+
deck: FlashcardRef[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Phase = 'idle' | 'deck';
|
|
36
|
+
|
|
37
|
+
function readKnown(): Set<string> {
|
|
38
|
+
try {
|
|
39
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
40
|
+
if (!raw) return new Set();
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (!Array.isArray(parsed)) return new Set();
|
|
43
|
+
return new Set(parsed.filter((s): s is string => typeof s === 'string'));
|
|
44
|
+
} catch {
|
|
45
|
+
return new Set();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeKnown(known: Set<string>): void {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify([...known]));
|
|
52
|
+
} catch {
|
|
53
|
+
/* localStorage unavailable — keep in-memory state only */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function Flashcards({ deck }: Props) {
|
|
58
|
+
const [phase, setPhase] = useState<Phase>('idle');
|
|
59
|
+
const [order, setOrder] = useState<string[]>([]);
|
|
60
|
+
const [pos, setPos] = useState(0);
|
|
61
|
+
const [flipped, setFlipped] = useState(false);
|
|
62
|
+
const [known, setKnown] = useState<Set<string>>(new Set());
|
|
63
|
+
const [unknownOnly, setUnknownOnly] = useState(false);
|
|
64
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
// Intersect the stored bucket with the CURRENT deck: a term deleted from
|
|
68
|
+
// the glossary would otherwise inflate "marked known" forever (even past
|
|
69
|
+
// deck.length) — evict stale ids on mount and persist the cleaned set.
|
|
70
|
+
const stored = readKnown();
|
|
71
|
+
const deckIds = new Set(deck.map((c) => c.id));
|
|
72
|
+
const cleaned = new Set([...stored].filter((id) => deckIds.has(id)));
|
|
73
|
+
if (cleaned.size !== stored.size) writeKnown(cleaned);
|
|
74
|
+
setKnown(cleaned);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
function requireRoot(): HTMLElement {
|
|
78
|
+
const r = ref.current?.closest<HTMLElement>('[data-flashcards-root]');
|
|
79
|
+
if (!r) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'Flashcards: no [data-flashcards-root] ancestor — mount the island inside ' +
|
|
82
|
+
'the wrapper that contains its cards (see the DOM contract in Flashcards.tsx).',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return r;
|
|
86
|
+
}
|
|
87
|
+
function cardEl(r: HTMLElement, id: string): HTMLElement {
|
|
88
|
+
const el = r.querySelector<HTMLElement>(`[data-card-id="${CSS.escape(id)}"]`);
|
|
89
|
+
if (!el) {
|
|
90
|
+
throw new Error(`Flashcards: deck/DOM drift — no rendered card for term "${id}".`);
|
|
91
|
+
}
|
|
92
|
+
return el;
|
|
93
|
+
}
|
|
94
|
+
function allCards(r: HTMLElement): HTMLElement[] {
|
|
95
|
+
return Array.from(r.querySelectorAll<HTMLElement>('[data-card-id]'));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function showOnly(r: HTMLElement, id: string): void {
|
|
99
|
+
for (const card of allCards(r)) {
|
|
100
|
+
card.hidden = card.dataset.cardId !== id;
|
|
101
|
+
card.classList.remove('flashcard-flipped');
|
|
102
|
+
}
|
|
103
|
+
setFlipped(false);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function start(): void {
|
|
107
|
+
const r = requireRoot();
|
|
108
|
+
const pool = unknownOnly ? deck.filter((c) => !known.has(c.id)) : deck;
|
|
109
|
+
if (pool.length === 0) return; // the idle UI disables Start in this state
|
|
110
|
+
const shuffled = shuffle(pool.map((c) => c.id));
|
|
111
|
+
// Fail loud on drift before hiding anything (ExamRunner precedent).
|
|
112
|
+
for (const id of shuffled) cardEl(r, id);
|
|
113
|
+
r.setAttribute('data-flashcards-mode', 'deck');
|
|
114
|
+
showOnly(r, shuffled[0]!);
|
|
115
|
+
setOrder(shuffled);
|
|
116
|
+
setPos(0);
|
|
117
|
+
setPhase('deck');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function goTo(next: number): void {
|
|
121
|
+
const r = requireRoot();
|
|
122
|
+
const clamped = Math.max(0, Math.min(next, order.length - 1));
|
|
123
|
+
showOnly(r, order[clamped]!);
|
|
124
|
+
setPos(clamped);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function flip(): void {
|
|
128
|
+
const r = requireRoot();
|
|
129
|
+
const card = cardEl(r, order[pos]!);
|
|
130
|
+
const next = !flipped;
|
|
131
|
+
card.classList.toggle('flashcard-flipped', next);
|
|
132
|
+
setFlipped(next);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mark(knewIt: boolean): void {
|
|
136
|
+
const id = order[pos]!;
|
|
137
|
+
const next = new Set(known);
|
|
138
|
+
if (knewIt) next.add(id);
|
|
139
|
+
else next.delete(id);
|
|
140
|
+
setKnown(next);
|
|
141
|
+
writeKnown(next);
|
|
142
|
+
if (pos < order.length - 1) goTo(pos + 1);
|
|
143
|
+
else end();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function end(): void {
|
|
147
|
+
const r = requireRoot();
|
|
148
|
+
for (const card of allCards(r)) {
|
|
149
|
+
card.hidden = false;
|
|
150
|
+
card.classList.remove('flashcard-flipped');
|
|
151
|
+
}
|
|
152
|
+
r.removeAttribute('data-flashcards-mode');
|
|
153
|
+
setPhase('idle');
|
|
154
|
+
setOrder([]);
|
|
155
|
+
setPos(0);
|
|
156
|
+
setFlipped(false);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resetKnown(): void {
|
|
160
|
+
const next = new Set<string>();
|
|
161
|
+
setKnown(next);
|
|
162
|
+
writeKnown(next);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (deck.length === 0) {
|
|
166
|
+
return <p class="flashcards-empty">No glossary terms to study yet.</p>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const unknownCount = deck.filter((c) => !known.has(c.id)).length;
|
|
170
|
+
const poolSize = unknownOnly ? unknownCount : deck.length;
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div class="flashcards-runner" ref={ref}>
|
|
174
|
+
{phase === 'idle' && (
|
|
175
|
+
<div class="flashcards-controls">
|
|
176
|
+
<p class="flashcards-intro">
|
|
177
|
+
Study the glossary as flashcards: shuffled, one term at a time — recall the
|
|
178
|
+
definition, flip to check, and sort each card into "knew it" / "still learning".
|
|
179
|
+
</p>
|
|
180
|
+
<p class="flashcards-stats" role="status">
|
|
181
|
+
{deck.length} card{deck.length === 1 ? '' : 's'} · {known.size} marked known
|
|
182
|
+
{known.size > 0 && (
|
|
183
|
+
<>
|
|
184
|
+
{' '}
|
|
185
|
+
(<button type="button" class="flashcards-link" onClick={resetKnown}>reset</button>)
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
188
|
+
</p>
|
|
189
|
+
<label class="flashcards-filter-label">
|
|
190
|
+
<input
|
|
191
|
+
type="checkbox"
|
|
192
|
+
checked={unknownOnly}
|
|
193
|
+
onInput={(e) => setUnknownOnly((e.target as HTMLInputElement).checked)}
|
|
194
|
+
/>{' '}
|
|
195
|
+
only cards I don't know yet ({unknownCount})
|
|
196
|
+
</label>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
class="flashcards-button flashcards-start"
|
|
200
|
+
onClick={start}
|
|
201
|
+
disabled={poolSize === 0}
|
|
202
|
+
>
|
|
203
|
+
{poolSize === 0 ? 'All cards marked known — reset to review' : `Start deck (${poolSize})`}
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{phase === 'deck' && (
|
|
209
|
+
<div class="flashcards-controls">
|
|
210
|
+
<p class="flashcards-progress" role="status">
|
|
211
|
+
Card {pos + 1} of {order.length} · {known.size} known
|
|
212
|
+
</p>
|
|
213
|
+
<div class="flashcards-buttons">
|
|
214
|
+
<button type="button" class="flashcards-button flashcards-flip" onClick={flip}>
|
|
215
|
+
{flipped ? 'Hide definition' : 'Flip'}
|
|
216
|
+
</button>
|
|
217
|
+
<button type="button" class="flashcards-button flashcards-knew" onClick={() => mark(true)}>
|
|
218
|
+
Knew it
|
|
219
|
+
</button>
|
|
220
|
+
<button type="button" class="flashcards-button flashcards-learning" onClick={() => mark(false)}>
|
|
221
|
+
Still learning
|
|
222
|
+
</button>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
class="flashcards-button flashcards-prev"
|
|
226
|
+
onClick={() => goTo(pos - 1)}
|
|
227
|
+
disabled={pos === 0}
|
|
228
|
+
>
|
|
229
|
+
← Prev
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
class="flashcards-button flashcards-next"
|
|
234
|
+
onClick={() => goTo(pos + 1)}
|
|
235
|
+
disabled={pos === order.length - 1}
|
|
236
|
+
>
|
|
237
|
+
Next →
|
|
238
|
+
</button>
|
|
239
|
+
<button type="button" class="flashcards-button flashcards-end" onClick={end}>
|
|
240
|
+
End — show all
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -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>
|
|
@@ -1,28 +1,71 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* <Rationale> — collapsible answer/explanation for a study-guide question
|
|
4
|
-
* (v4.17.0, Tier 3). Renders its slot inside a
|
|
5
|
-
* hidden until the reader chooses to reveal it
|
|
6
|
-
* delayed feedback aids retention).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* (v4.17.0, Tier 3; appendix mode v4.21.0, #114). Renders its slot inside a
|
|
5
|
+
* <details> so the answer stays hidden until the reader chooses to reveal it
|
|
6
|
+
* (Bjork desirable-difficulties: delayed feedback aids retention).
|
|
7
|
+
*
|
|
8
|
+
* Appendix mode (#114): `<Rationale appendix for="<question-id>">` keeps the
|
|
9
|
+
* chapter/bank body clean Sybex-style — everywhere EXCEPT the /answers route
|
|
10
|
+
* it renders a link to /answers#answer-<id> instead of the details; on
|
|
11
|
+
* /answers itself (where the full question body is re-rendered) it renders the
|
|
12
|
+
* details as normal so the appendix carries the content. Fail-loud, both
|
|
13
|
+
* misuse modes throw at build: `appendix` without `for=` (no anchor target),
|
|
14
|
+
* and `appendix` with the answers route disabled (the link would 404).
|
|
9
15
|
*
|
|
10
16
|
* Any profile. Register it in src/mdx-components.ts (defineMdxComponents) to use
|
|
11
17
|
* inside src/content/questions/**.mdx. Slot freely: prose, math, <Cite>, code.
|
|
12
18
|
*/
|
|
19
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
20
|
+
|
|
13
21
|
interface Props {
|
|
14
22
|
/** Summary label for the reveal. Defaults to "Answer & rationale". */
|
|
15
23
|
title?: string;
|
|
24
|
+
/** Render as a link into the /answers appendix instead of inline details
|
|
25
|
+
* (except on /answers itself). Requires `for` + routes.answers enabled. */
|
|
26
|
+
appendix?: boolean;
|
|
27
|
+
/** The question's frontmatter `id` — the /answers#answer-<id> anchor target.
|
|
28
|
+
* Required with `appendix`. */
|
|
29
|
+
for?: string;
|
|
30
|
+
}
|
|
31
|
+
const { title = 'Answer & rationale', appendix = false, for: forId } = Astro.props;
|
|
32
|
+
|
|
33
|
+
if (appendix && !forId) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`<Rationale appendix> requires for="<question-id>" — it is the ` +
|
|
36
|
+
`/answers#answer-<id> anchor target.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (appendix && !(bookConfig.enabledRoutes ?? []).includes('answers')) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`<Rationale appendix for="${forId}"> — the answers route is not enabled, so ` +
|
|
42
|
+
`the appendix link would 404. Set routes: { answers: true } in ` +
|
|
43
|
+
`defineBookConfig (or drop the appendix prop for an inline reveal).`,
|
|
44
|
+
);
|
|
16
45
|
}
|
|
17
|
-
|
|
46
|
+
|
|
47
|
+
// Exact match against the configured base — on the appendix route itself the
|
|
48
|
+
// full rationale must render (the appendix re-renders question bodies via
|
|
49
|
+
// render()). BASE_URL-prefixed + trailing-slash tolerant; an endsWith()
|
|
50
|
+
// heuristic would also fire on e.g. a chapter whose slug is "answers".
|
|
51
|
+
const basePath = (import.meta.env.BASE_URL ?? '/').replace(/\/+$/, '');
|
|
52
|
+
const onAnswersRoute = Astro.url.pathname.replace(/\/+$/, '') === `${basePath}/answers`;
|
|
53
|
+
const asLink = appendix && !onAnswersRoute;
|
|
18
54
|
---
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
</
|
|
55
|
+
{asLink ? (
|
|
56
|
+
<p class="question-rationale-ref">
|
|
57
|
+
<a href={`/answers#answer-${forId}`}>{title} →</a>
|
|
58
|
+
</p>
|
|
59
|
+
) : (
|
|
60
|
+
<details class="question-rationale">
|
|
61
|
+
<summary>{title}</summary>
|
|
62
|
+
<div class="question-rationale-body"><slot /></div>
|
|
63
|
+
</details>
|
|
64
|
+
)}
|
|
23
65
|
|
|
24
66
|
<style>
|
|
25
|
-
.question-rationale
|
|
67
|
+
.question-rationale,
|
|
68
|
+
.question-rationale-ref {
|
|
26
69
|
margin-block: 0.75rem;
|
|
27
70
|
border-inline-start: 3px solid var(--color-accent, #4f46e5);
|
|
28
71
|
padding-inline-start: 0.75rem;
|
|
@@ -32,6 +75,10 @@ const { title = 'Answer & rationale' } = Astro.props;
|
|
|
32
75
|
font-weight: 600;
|
|
33
76
|
color: var(--color-accent, #4f46e5);
|
|
34
77
|
}
|
|
78
|
+
.question-rationale-ref > a {
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
color: var(--color-accent, #4f46e5);
|
|
81
|
+
}
|
|
35
82
|
.question-rationale-body {
|
|
36
83
|
margin-block-start: 0.5rem;
|
|
37
84
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as preact from 'preact';
|
|
2
|
+
import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-DbTHo90M.js';
|
|
3
|
+
import '../schemas-DDWDRUxs.js';
|
|
4
|
+
import 'astro/zod';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Scoreable MCQ pool (buildExamManifest output) — ids/domains/options only. */
|
|
8
|
+
manifest: ExamQuestion[];
|
|
9
|
+
/** practice: domain-agnostic sampling, weak domains anchor to #domain-<d> on
|
|
10
|
+
* the same page. assessment: cross-domain spread blueprint, weak domains
|
|
11
|
+
* route to chapters via `domainRouting`. */
|
|
12
|
+
mode: 'practice' | 'assessment';
|
|
13
|
+
/** Default form size (clamped to the pool; reader can adjust before start). */
|
|
14
|
+
count?: number;
|
|
15
|
+
/** Weak-domain threshold passed to scoreExam (default 0.7). */
|
|
16
|
+
passMark?: number;
|
|
17
|
+
/** Assessment mode: domain → chapters carrying its questions (deriveDomainRouting). */
|
|
18
|
+
domainRouting?: Record<string, RoutingChapter[]>;
|
|
19
|
+
/** Assessment mode: href of the practice bank when that route is enabled, else null. */
|
|
20
|
+
practiceExamHref?: string | null;
|
|
21
|
+
}
|
|
22
|
+
declare function ExamRunner({ manifest, mode, count, passMark, domainRouting, practiceExamHref, }: Props): preact.JSX.Element;
|
|
23
|
+
|
|
24
|
+
export { ExamRunner as default };
|