@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.
@@ -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 <details> so the answer stays
5
- * hidden until the reader chooses to reveal it (Bjork desirable-difficulties:
6
- * delayed feedback aids retention). Authored in a question's MDX body; the
7
- * answer-rationale back-appendix (#114) will later hoist these into an appendix
8
- * using this same marker so a rich, cited rationale has a stable home now.
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
- const { title = 'Answer & rationale' } = Astro.props;
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
- <details class="question-rationale">
20
- <summary>{title}</summary>
21
- <div class="question-rationale-body"><slot /></div>
22
- </details>
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 };