@brandon_m_behring/book-scaffold-astro 4.21.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 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>`). **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 incrementsee `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 (v4.22.0, #116):** the `/flashcards` route (`routes: { flashcards: true }`) turns the glossary into a spaced-recall deck shuffled, one term at a time, flip-to-check, with knew-it/still-learning buckets persisted to localStorage and a "review unknown only" pass; no JS → the full front+back list reads like a compact glossary. That completes the study-guide epic (#122).
108
108
 
109
109
  Full reference in `recipes/04-component-library.md`.
110
110
 
@@ -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,12 @@
1
+ import * as preact from 'preact';
2
+ import { F as FlashcardRef } from '../flashcards-DPFFQhP8.js';
3
+ import '../schemas-DDWDRUxs.js';
4
+ import 'astro/zod';
5
+
6
+ interface Props {
7
+ /** Deck manifest (buildFlashcardDeck output) — card ids + fronts only. */
8
+ deck: FlashcardRef[];
9
+ }
10
+ declare function Flashcards({ deck }: Props): preact.JSX.Element;
11
+
12
+ export { Flashcards as default };
@@ -0,0 +1,217 @@
1
+ // components/Flashcards.tsx
2
+ import { useEffect, useRef, useState } from "preact/hooks";
3
+
4
+ // src/lib/exam-engine.ts
5
+ function shuffle(arr, rng = Math.random) {
6
+ const a = arr.slice();
7
+ for (let i = a.length - 1; i > 0; i--) {
8
+ const j = Math.min(i, Math.floor(rng() * (i + 1)));
9
+ const ai = a[i];
10
+ a[i] = a[j];
11
+ a[j] = ai;
12
+ }
13
+ return a;
14
+ }
15
+
16
+ // components/Flashcards.tsx
17
+ import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
18
+ var STORAGE_KEY = "book:flashcards:known";
19
+ function readKnown() {
20
+ try {
21
+ const raw = localStorage.getItem(STORAGE_KEY);
22
+ if (!raw) return /* @__PURE__ */ new Set();
23
+ const parsed = JSON.parse(raw);
24
+ if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
25
+ return new Set(parsed.filter((s) => typeof s === "string"));
26
+ } catch {
27
+ return /* @__PURE__ */ new Set();
28
+ }
29
+ }
30
+ function writeKnown(known) {
31
+ try {
32
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...known]));
33
+ } catch {
34
+ }
35
+ }
36
+ function Flashcards({ deck }) {
37
+ const [phase, setPhase] = useState("idle");
38
+ const [order, setOrder] = useState([]);
39
+ const [pos, setPos] = useState(0);
40
+ const [flipped, setFlipped] = useState(false);
41
+ const [known, setKnown] = useState(/* @__PURE__ */ new Set());
42
+ const [unknownOnly, setUnknownOnly] = useState(false);
43
+ const ref = useRef(null);
44
+ useEffect(() => {
45
+ const stored = readKnown();
46
+ const deckIds = new Set(deck.map((c) => c.id));
47
+ const cleaned = new Set([...stored].filter((id) => deckIds.has(id)));
48
+ if (cleaned.size !== stored.size) writeKnown(cleaned);
49
+ setKnown(cleaned);
50
+ }, []);
51
+ function requireRoot() {
52
+ const r = ref.current?.closest("[data-flashcards-root]");
53
+ if (!r) {
54
+ throw new Error(
55
+ "Flashcards: no [data-flashcards-root] ancestor \u2014 mount the island inside the wrapper that contains its cards (see the DOM contract in Flashcards.tsx)."
56
+ );
57
+ }
58
+ return r;
59
+ }
60
+ function cardEl(r, id) {
61
+ const el = r.querySelector(`[data-card-id="${CSS.escape(id)}"]`);
62
+ if (!el) {
63
+ throw new Error(`Flashcards: deck/DOM drift \u2014 no rendered card for term "${id}".`);
64
+ }
65
+ return el;
66
+ }
67
+ function allCards(r) {
68
+ return Array.from(r.querySelectorAll("[data-card-id]"));
69
+ }
70
+ function showOnly(r, id) {
71
+ for (const card of allCards(r)) {
72
+ card.hidden = card.dataset.cardId !== id;
73
+ card.classList.remove("flashcard-flipped");
74
+ }
75
+ setFlipped(false);
76
+ }
77
+ function start() {
78
+ const r = requireRoot();
79
+ const pool = unknownOnly ? deck.filter((c) => !known.has(c.id)) : deck;
80
+ if (pool.length === 0) return;
81
+ const shuffled = shuffle(pool.map((c) => c.id));
82
+ for (const id of shuffled) cardEl(r, id);
83
+ r.setAttribute("data-flashcards-mode", "deck");
84
+ showOnly(r, shuffled[0]);
85
+ setOrder(shuffled);
86
+ setPos(0);
87
+ setPhase("deck");
88
+ }
89
+ function goTo(next) {
90
+ const r = requireRoot();
91
+ const clamped = Math.max(0, Math.min(next, order.length - 1));
92
+ showOnly(r, order[clamped]);
93
+ setPos(clamped);
94
+ }
95
+ function flip() {
96
+ const r = requireRoot();
97
+ const card = cardEl(r, order[pos]);
98
+ const next = !flipped;
99
+ card.classList.toggle("flashcard-flipped", next);
100
+ setFlipped(next);
101
+ }
102
+ function mark(knewIt) {
103
+ const id = order[pos];
104
+ const next = new Set(known);
105
+ if (knewIt) next.add(id);
106
+ else next.delete(id);
107
+ setKnown(next);
108
+ writeKnown(next);
109
+ if (pos < order.length - 1) goTo(pos + 1);
110
+ else end();
111
+ }
112
+ function end() {
113
+ const r = requireRoot();
114
+ for (const card of allCards(r)) {
115
+ card.hidden = false;
116
+ card.classList.remove("flashcard-flipped");
117
+ }
118
+ r.removeAttribute("data-flashcards-mode");
119
+ setPhase("idle");
120
+ setOrder([]);
121
+ setPos(0);
122
+ setFlipped(false);
123
+ }
124
+ function resetKnown() {
125
+ const next = /* @__PURE__ */ new Set();
126
+ setKnown(next);
127
+ writeKnown(next);
128
+ }
129
+ if (deck.length === 0) {
130
+ return /* @__PURE__ */ jsx("p", { class: "flashcards-empty", children: "No glossary terms to study yet." });
131
+ }
132
+ const unknownCount = deck.filter((c) => !known.has(c.id)).length;
133
+ const poolSize = unknownOnly ? unknownCount : deck.length;
134
+ return /* @__PURE__ */ jsxs("div", { class: "flashcards-runner", ref, children: [
135
+ phase === "idle" && /* @__PURE__ */ jsxs("div", { class: "flashcards-controls", children: [
136
+ /* @__PURE__ */ jsx("p", { class: "flashcards-intro", children: 'Study the glossary as flashcards: shuffled, one term at a time \u2014 recall the definition, flip to check, and sort each card into "knew it" / "still learning".' }),
137
+ /* @__PURE__ */ jsxs("p", { class: "flashcards-stats", role: "status", children: [
138
+ deck.length,
139
+ " card",
140
+ deck.length === 1 ? "" : "s",
141
+ " \xB7 ",
142
+ known.size,
143
+ " marked known",
144
+ known.size > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
145
+ " ",
146
+ "(",
147
+ /* @__PURE__ */ jsx("button", { type: "button", class: "flashcards-link", onClick: resetKnown, children: "reset" }),
148
+ ")"
149
+ ] })
150
+ ] }),
151
+ /* @__PURE__ */ jsxs("label", { class: "flashcards-filter-label", children: [
152
+ /* @__PURE__ */ jsx(
153
+ "input",
154
+ {
155
+ type: "checkbox",
156
+ checked: unknownOnly,
157
+ onInput: (e) => setUnknownOnly(e.target.checked)
158
+ }
159
+ ),
160
+ " ",
161
+ "only cards I don't know yet (",
162
+ unknownCount,
163
+ ")"
164
+ ] }),
165
+ /* @__PURE__ */ jsx(
166
+ "button",
167
+ {
168
+ type: "button",
169
+ class: "flashcards-button flashcards-start",
170
+ onClick: start,
171
+ disabled: poolSize === 0,
172
+ children: poolSize === 0 ? "All cards marked known \u2014 reset to review" : `Start deck (${poolSize})`
173
+ }
174
+ )
175
+ ] }),
176
+ phase === "deck" && /* @__PURE__ */ jsxs("div", { class: "flashcards-controls", children: [
177
+ /* @__PURE__ */ jsxs("p", { class: "flashcards-progress", role: "status", children: [
178
+ "Card ",
179
+ pos + 1,
180
+ " of ",
181
+ order.length,
182
+ " \xB7 ",
183
+ known.size,
184
+ " known"
185
+ ] }),
186
+ /* @__PURE__ */ jsxs("div", { class: "flashcards-buttons", children: [
187
+ /* @__PURE__ */ jsx("button", { type: "button", class: "flashcards-button flashcards-flip", onClick: flip, children: flipped ? "Hide definition" : "Flip" }),
188
+ /* @__PURE__ */ jsx("button", { type: "button", class: "flashcards-button flashcards-knew", onClick: () => mark(true), children: "Knew it" }),
189
+ /* @__PURE__ */ jsx("button", { type: "button", class: "flashcards-button flashcards-learning", onClick: () => mark(false), children: "Still learning" }),
190
+ /* @__PURE__ */ jsx(
191
+ "button",
192
+ {
193
+ type: "button",
194
+ class: "flashcards-button flashcards-prev",
195
+ onClick: () => goTo(pos - 1),
196
+ disabled: pos === 0,
197
+ children: "\u2190 Prev"
198
+ }
199
+ ),
200
+ /* @__PURE__ */ jsx(
201
+ "button",
202
+ {
203
+ type: "button",
204
+ class: "flashcards-button flashcards-next",
205
+ onClick: () => goTo(pos + 1),
206
+ disabled: pos === order.length - 1,
207
+ children: "Next \u2192"
208
+ }
209
+ ),
210
+ /* @__PURE__ */ jsx("button", { type: "button", class: "flashcards-button flashcards-end", onClick: end, children: "End \u2014 show all" })
211
+ ] })
212
+ ] })
213
+ ] });
214
+ }
215
+ export {
216
+ Flashcards as default
217
+ };
@@ -0,0 +1,36 @@
1
+ import { G as GlossaryTerm } from './schemas-DDWDRUxs.js';
2
+
3
+ /**
4
+ * flashcards.ts — PURE deck-manifest bridge between the `glossary` collection
5
+ * and the Flashcards island (#116).
6
+ *
7
+ * Same architecture as exam-manifest.ts: card backs are MDX definitions
8
+ * (server-rendered — not serializable into island props), so the island
9
+ * receives only this manifest (id + front text) and controls the
10
+ * server-rendered cards by id. Question/objective-derived cards are a
11
+ * deliberate later increment; v1 decks are glossary-only.
12
+ *
13
+ * No astro:content import — node-tested straight from dist/
14
+ * (tests/flashcards.test.mjs).
15
+ */
16
+
17
+ /** One deck entry: the stable card id + the front (the display term). */
18
+ interface FlashcardRef {
19
+ id: string;
20
+ front: string;
21
+ }
22
+ /** The entry shape accepted — a CollectionEntry-like wrapper (glossary
23
+ * entries carry the file-derived `id` at the top level, not in data). */
24
+ type GlossaryLike = {
25
+ id: string;
26
+ data: Pick<GlossaryTerm, 'term' | 'draft'>;
27
+ };
28
+ /**
29
+ * Build the deck manifest: published (non-draft) terms, alphabetical by
30
+ * display term (locale-aware — same order as /glossary, so the static list
31
+ * fallback and the deck agree). Draft filtering is defensive; the shipped
32
+ * caller passes getCollection('glossary', e => !e.data.draft) output.
33
+ */
34
+ declare function buildFlashcardDeck(entries: readonly GlossaryLike[]): FlashcardRef[];
35
+
36
+ export { type FlashcardRef as F, buildFlashcardDeck as b };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, l as Style } from './types-uerMuJwT.js';
3
- export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, P as PartKey, j as PartialRouteToggles, k as ProfileDefinition, R as RouteToggles, S as StatusBadge, m as StyleInput, V as VolatilityBadge, n as composeStyles, o as defineProfile, p as defineStyle, q as normalizeFrontmatterConfig, r as resolvePreset, s as resolveProfile } from './types-uerMuJwT.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, l as Style } from './types-CwsA0C9T.js';
3
+ export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, P as PartKey, j as PartialRouteToggles, k as ProfileDefinition, R as RouteToggles, S as StatusBadge, m as StyleInput, V as VolatilityBadge, n as composeStyles, o as defineProfile, p as defineStyle, q as normalizeFrontmatterConfig, r as resolvePreset, s as resolveProfile } from './types-CwsA0C9T.js';
4
4
  import { D as volatilityLevels, c as academicParts, Q as Question } from './schemas-DDWDRUxs.js';
5
5
  export { A as AcademicChapter, B as BloomLevel, C as CourseNotesChapter, G as GlossaryTerm, M as MinimalChapter, P as Provenance, a as QuestionType, R as ResearchPortfolioChapter, T as ToolsChapter, b as academicChapterSchema, d as bloomLevels, e as changeKinds, f as changelogSchema, g as chapterStatus, h as citationBackstops, i as courseNotesChapterSchema, j as glossarySchema, m as minimalChapterSchema, p as patternCategories, k as patternsSchema, l as provenanceObject, n as provenanceSchema, q as questionDifficulties, o as questionSchema, r as questionTypes, s as refineQuestion, t as refinedQuestionSchema, u as researchPortfolioChapterSchema, v as sourceTiers, w as sourceTiersResearch, x as sourcesSchema, y as toolSlugs, z as toolsChapterSchema } from './schemas-DDWDRUxs.js';
6
6
  export { KIND_LABEL, ResolvedTheoremLabel, THEOREM_KINDS, TheoremKind, TheoremLabelProps, resolveTheoremNumber, theoremLabel } from './lib/theorem-label.js';
7
7
  export { D as DomainScore, E as ExamBlueprint, a as ExamQuestion, b as ExamResult, R as RoutingChapter, c as buildExamManifest, d as deriveDomainRouting, s as sampleExam, e as scoreExam, f as shuffle, g as spreadBlueprint } from './exam-manifest-DbTHo90M.js';
8
+ export { F as FlashcardRef, b as buildFlashcardDeck } from './flashcards-DPFFQhP8.js';
8
9
  import 'astro/zod';
9
10
 
10
11
  /**
package/dist/index.mjs CHANGED
@@ -571,6 +571,8 @@ var academicProfile = defineProfile({
571
571
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
572
572
  answers: false,
573
573
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
574
+ flashcards: false,
575
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
574
576
  landing: true
575
577
  // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
576
578
  },
@@ -700,6 +702,8 @@ var toolsProfile = defineProfile({
700
702
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
701
703
  answers: false,
702
704
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
705
+ flashcards: false,
706
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
703
707
  landing: true
704
708
  // v4.5.0: auto-inject minimal root landing
705
709
  },
@@ -791,6 +795,8 @@ var minimalProfile = defineProfile({
791
795
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
792
796
  answers: false,
793
797
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
798
+ flashcards: false,
799
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
794
800
  landing: true
795
801
  // v4.5.0: auto-inject minimal root landing
796
802
  },
@@ -822,6 +828,8 @@ var courseNotesProfile = defineProfile({
822
828
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
823
829
  answers: false,
824
830
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
831
+ flashcards: false,
832
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
825
833
  landing: true
826
834
  // v4.5.0: auto-inject minimal root landing
827
835
  },
@@ -857,6 +865,8 @@ var researchPortfolioProfile = defineProfile({
857
865
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
858
866
  answers: false,
859
867
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
868
+ flashcards: false,
869
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
860
870
  landing: true
861
871
  // v4.5.0: auto-inject minimal root landing
862
872
  },
@@ -1146,6 +1156,9 @@ var ROUTE_REGISTRY = {
1146
1156
  // v4.21.0 (#114): answer-rationale back-appendix. Opt-in via routes.answers:
1147
1157
  // true; reads the `questions` collection with everything revealed.
1148
1158
  answers: { pattern: "/answers", file: "answers.astro" },
1159
+ // v4.22.0 (#116): glossary flashcards deck. Opt-in via routes.flashcards:
1160
+ // true; reads the `glossary` collection (src/content/glossary/).
1161
+ flashcards: { pattern: "/flashcards", file: "flashcards.astro" },
1149
1162
  // v4.5.0: minimal root landing page. Reads title/description/portfolio/routes
1150
1163
  // from vite.define-injected import.meta.env vars. Default-on per profile;
1151
1164
  // consumers with their own src/pages/index.astro override (file-system route
@@ -1725,6 +1738,11 @@ function spreadBlueprint(pool, count) {
1725
1738
  };
1726
1739
  }
1727
1740
 
1741
+ // src/lib/flashcards.ts
1742
+ function buildFlashcardDeck(entries) {
1743
+ return entries.filter((e) => !e.data.draft).map((e) => ({ id: e.id, front: e.data.term })).sort((a, b) => a.front.localeCompare(b.front));
1744
+ }
1745
+
1728
1746
  // src/lib/part-review.ts
1729
1747
  function selectPartExercises(chapters, byChapter, part) {
1730
1748
  const want = String(part);
@@ -1795,6 +1813,7 @@ export {
1795
1813
  bloomLevels,
1796
1814
  bookScaffoldIntegration,
1797
1815
  buildExamManifest,
1816
+ buildFlashcardDeck,
1798
1817
  buildGithubUrl,
1799
1818
  changeKinds,
1800
1819
  changelogSchema,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-uerMuJwT.js';
2
+ import { g as BookSchemasOptions } from './types-CwsA0C9T.js';
3
3
  import 'astro';
4
4
  import './schemas-DDWDRUxs.js';
5
5
  import 'astro/zod';
package/dist/schemas.mjs CHANGED
@@ -449,6 +449,8 @@ var academicProfile = defineProfile({
449
449
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
450
450
  answers: false,
451
451
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
452
+ flashcards: false,
453
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
452
454
  landing: true
453
455
  // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
454
456
  },
@@ -578,6 +580,8 @@ var toolsProfile = defineProfile({
578
580
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
579
581
  answers: false,
580
582
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
583
+ flashcards: false,
584
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
581
585
  landing: true
582
586
  // v4.5.0: auto-inject minimal root landing
583
587
  },
@@ -669,6 +673,8 @@ var minimalProfile = defineProfile({
669
673
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
670
674
  answers: false,
671
675
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
676
+ flashcards: false,
677
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
672
678
  landing: true
673
679
  // v4.5.0: auto-inject minimal root landing
674
680
  },
@@ -700,6 +706,8 @@ var courseNotesProfile = defineProfile({
700
706
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
701
707
  answers: false,
702
708
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
709
+ flashcards: false,
710
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
703
711
  landing: true
704
712
  // v4.5.0: auto-inject minimal root landing
705
713
  },
@@ -735,6 +743,8 @@ var researchPortfolioProfile = defineProfile({
735
743
  // v4.19.0 #115: opt-in per book; requires src/content/glossary/
736
744
  answers: false,
737
745
  // v4.21.0 #114: opt-in per book; requires src/content/questions/
746
+ flashcards: false,
747
+ // v4.22.0 #116: opt-in per book; requires src/content/glossary/
738
748
  landing: true
739
749
  // v4.5.0: auto-inject minimal root landing
740
750
  },
@@ -187,6 +187,14 @@ interface RouteToggles {
187
187
  * add a src/content/questions/ directory.
188
188
  */
189
189
  answers: boolean;
190
+ /**
191
+ * v4.22.0 (#116): auto-inject `/flashcards` — a spaced-recall deck generated
192
+ * from the `glossary` collection (front = term, back = definition), with a
193
+ * shuffle/flip/known-bucket island persisted to localStorage. Default
194
+ * `false` per profile — opt in via defineBookConfig({ routes:
195
+ * { flashcards: true } }) AND add a src/content/glossary/ directory.
196
+ */
197
+ flashcards: boolean;
190
198
  }
191
199
  /** Profile definition — declarative shape for one book profile. */
192
200
  interface ProfileDefinition {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.21.0",
4
+ "version": "4.22.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -107,6 +107,10 @@
107
107
  "types": "./dist/components/ExamRunner.d.ts",
108
108
  "import": "./dist/components/ExamRunner.mjs"
109
109
  },
110
+ "./components/Flashcards": {
111
+ "types": "./dist/components/Flashcards.d.ts",
112
+ "import": "./dist/components/Flashcards.mjs"
113
+ },
110
114
  "./components/WarnBox.astro": "./components/WarnBox.astro",
111
115
  "./components/WeekRef.astro": "./components/WeekRef.astro",
112
116
  "./components/WorkedExample.astro": "./components/WorkedExample.astro",
@@ -122,6 +126,7 @@
122
126
  "./styles/poc-layouts.css": "./styles/poc-layouts.css",
123
127
  "./styles/tool-filter.css": "./styles/tool-filter.css",
124
128
  "./styles/exam-runner.css": "./styles/exam-runner.css",
129
+ "./styles/flashcards.css": "./styles/flashcards.css",
125
130
  "./layouts/Base.astro": "./layouts/Base.astro",
126
131
  "./layouts/Chapter.astro": "./layouts/Chapter.astro",
127
132
  "./lib": {
@@ -0,0 +1,109 @@
1
+ ---
2
+ /**
3
+ * Auto-injected /flashcards route — the spaced-recall deck (v4.22.0, #116).
4
+ *
5
+ * Sybex-style "electronic flashcards" over the glossary collection: each term
6
+ * is a card (front = the term, back = the rendered MDX definition). The
7
+ * Flashcards island (client:idle) shuffles a deck and shows one card at a
8
+ * time with flip + knew-it/still-learning buckets persisted to localStorage;
9
+ * "review unknown only" survives reloads. Deliberately an appendix-style
10
+ * surface, not inline (the Bjork cheat-sheet caution: recall practice, not
11
+ * re-reading prompts inside prose).
12
+ *
13
+ * Card backs are MDX (render() components — not serializable), so all cards
14
+ * are server-rendered and the island is a controller over them (the
15
+ * ExamRunner architecture). No JS → the full front+back list reads like a
16
+ * compact glossary; the deck controls hide via <noscript>.
17
+ *
18
+ * Gated on routes.flashcards (default false on every profile). Twin-gate
19
+ * (mirrors /glossary): presence-probe src/content/glossary/ and render an
20
+ * honest empty state rather than touching an unregistered collection.
21
+ * Question/objective-derived cards are a later increment.
22
+ */
23
+ import Base from '../layouts/Base.astro';
24
+ import { getCollection, render } from 'astro:content';
25
+ import Flashcards from '@brandon_m_behring/book-scaffold-astro/components/Flashcards';
26
+ import { buildFlashcardDeck } from '../src/lib/flashcards';
27
+ import '../styles/flashcards.css';
28
+
29
+ // Presence-gate: only touch the collection when the directory holds files.
30
+ const glossModules = import.meta.glob('/src/content/glossary/**/*.{md,mdx}', {
31
+ query: '?raw',
32
+ import: 'default',
33
+ eager: true,
34
+ });
35
+ const hasGlossary = Object.keys(glossModules).length > 0;
36
+
37
+ const entries = hasGlossary ? await getCollection('glossary', (e) => !e.data.draft) : [];
38
+ const deck = buildFlashcardDeck(entries);
39
+ // Render definitions in deck (alphabetical) order so cards and manifest agree.
40
+ const byId = new Map(entries.map((e) => [e.id, e]));
41
+ const rendered = await Promise.all(
42
+ deck.map(async (card) => {
43
+ const entry = byId.get(card.id)!;
44
+ return { ...card, Content: (await render(entry)).Content };
45
+ }),
46
+ );
47
+ ---
48
+ <Base
49
+ title="Flashcards"
50
+ description="Glossary flashcards: shuffled recall practice with flip-to-check and known/still-learning buckets."
51
+ >
52
+ <article class="prose flashcards-page" data-flashcards-root>
53
+ <h1>Flashcards</h1>
54
+
55
+ {!hasGlossary && (
56
+ <p class="flashcards-empty">
57
+ No terms found under <code>src/content/glossary/</code> — flashcards are generated
58
+ from the glossary collection. Add term MDX files (frontmatter <code>term</code> +
59
+ a definition body) and keep <code>routes: {'{'} flashcards: true {'}'}</code>.
60
+ </p>
61
+ )}
62
+
63
+ {hasGlossary && (
64
+ <Fragment>
65
+ {/* Without JS the island's deck controls would render dead — hide
66
+ them; the full card list below stays readable. */}
67
+ <noscript><style is:inline>.flashcards-runner { display: none; }</style></noscript>
68
+ <Flashcards client:idle deck={deck.map(({ id, front }) => ({ id, front }))} />
69
+ <section class="flashcards-list">
70
+ {rendered.map((card) => {
71
+ const Content = card.Content;
72
+ return (
73
+ <div class="flashcard" data-card-id={card.id} id={`card-${card.id}`}>
74
+ <p class="flashcard-front">{card.front}</p>
75
+ <div class="flashcard-back"><Content /></div>
76
+ </div>
77
+ );
78
+ })}
79
+ </section>
80
+ </Fragment>
81
+ )}
82
+ </article>
83
+
84
+ <style>
85
+ .flashcards-page { max-width: 48rem; }
86
+ .flashcards-empty { color: var(--color-text-muted, #6b7280); }
87
+ .flashcard {
88
+ margin-block: 1rem;
89
+ padding: 1rem 1.1rem;
90
+ border: 1px solid var(--color-border, #e5e7eb);
91
+ border-radius: 0.5rem;
92
+ background: var(--color-surface, transparent);
93
+ }
94
+ .flashcard-front {
95
+ font-weight: 600;
96
+ font-size: 1.05rem;
97
+ margin-block: 0 0.5rem;
98
+ }
99
+ /* Deck mode (island sets data-flashcards-mode on the wrapper): the back
100
+ stays hidden until the card is flipped — that's the recall attempt. */
101
+ [data-flashcards-mode='deck'] .flashcard {
102
+ min-height: 8rem;
103
+ border-color: var(--color-accent, #4f46e5);
104
+ }
105
+ [data-flashcards-mode='deck'] .flashcard:not(.flashcard-flipped) .flashcard-back {
106
+ display: none;
107
+ }
108
+ </style>
109
+ </Base>
@@ -147,7 +147,9 @@ TLS provides confidentiality and integrity via the negotiated cipher suite… <C
147
147
 
148
148
  - **`<AssessmentTest title? count? passMark?>`** (v4.21.0, #113) — the Sybex-style whole-book front-matter assessment: a cross-domain sampled form (every domain gets a quota, `spreadBlueprint`) scored client-side, with a weak-domain readout routing the reader to the chapters carrying those domains' questions (string chapters link to `/chapters/<slug>/`; numeric chapters render as labels). Drop into a front-matter / intro page like `<ObjectiveMap>`. Presence-gated; only scoreable MCQs render.
149
149
 
150
- The `/practice-exam` route renders the bank grouped by domain with each answer behind a "Show answer" reveal — and (v4.21.0, #112-UI) mounts the **ExamRunner island** (`client:idle`): Start samples a form client-side (pure `sampleExam`), hides the rest of the bank, hides answer reveals while the exam is active, scores checked radio options on submit (`scoreExam`), and renders a per-domain readout with weak-domain anchors. No JS → the static bank with radios + reveals is the fallback. The `/answers` route (v4.21.0, #114; `routes: { answers: true }`) is the back-appendix: every question grouped by chapter with options, the correct answer, and rationales pre-expanded. `cloze` questions are reserved (schema-accepted, render-deferred). Flashcards are the remaining increment see `docs/plans/active/study-guide-epic_*.md`. (`<Diagnostic>` #110, `<PartReview>` #111, and `/glossary` #115 shipped in v4.19.0; the interactive layer #112-UI/#113/#114 in v4.21.0.)
150
+ - **`/flashcards` route** (v4.22.0, #116; `routes: { flashcards: true }`) Sybex-style electronic flashcards generated from the glossary collection (front = term, back = the rendered MDX definition). The island shuffles a deck, shows one card at a time (recall first the back hides until Flip), sorts cards into knew-it/still-learning buckets persisted to localStorage (`book:flashcards:known`), and offers a "review unknown only" pass. Appendix-style surface, deliberately not inline (Bjork: recall practice, not re-reading). No JS the full front+back list stays readable. Question/objective-derived cards are a later increment.
151
+
152
+ The `/practice-exam` route renders the bank grouped by domain with each answer behind a "Show answer" reveal — and (v4.21.0, #112-UI) mounts the **ExamRunner island** (`client:idle`): Start samples a form client-side (pure `sampleExam`), hides the rest of the bank, hides answer reveals while the exam is active, scores checked radio options on submit (`scoreExam`), and renders a per-domain readout with weak-domain anchors. No JS → the static bank with radios + reveals is the fallback. The `/answers` route (v4.21.0, #114; `routes: { answers: true }`) is the back-appendix: every question grouped by chapter with options, the correct answer, and rationales pre-expanded. `cloze` questions are reserved (schema-accepted, render-deferred). (`<Diagnostic>` #110, `<PartReview>` #111, and `/glossary` #115 shipped in v4.19.0; the interactive layer #112-UI/#113/#114 in v4.21.0; flashcards #116 in v4.22.0 — completing epic #122. History: `docs/plans/active/study-guide-epic_*.md`.)
151
153
 
152
154
  ## Mixing families
153
155
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * flashcards.ts — PURE deck-manifest bridge between the `glossary` collection
3
+ * and the Flashcards island (#116).
4
+ *
5
+ * Same architecture as exam-manifest.ts: card backs are MDX definitions
6
+ * (server-rendered — not serializable into island props), so the island
7
+ * receives only this manifest (id + front text) and controls the
8
+ * server-rendered cards by id. Question/objective-derived cards are a
9
+ * deliberate later increment; v1 decks are glossary-only.
10
+ *
11
+ * No astro:content import — node-tested straight from dist/
12
+ * (tests/flashcards.test.mjs).
13
+ */
14
+ import type { GlossaryTerm } from '../schemas.js';
15
+
16
+ /** One deck entry: the stable card id + the front (the display term). */
17
+ export interface FlashcardRef {
18
+ id: string;
19
+ front: string;
20
+ }
21
+
22
+ /** The entry shape accepted — a CollectionEntry-like wrapper (glossary
23
+ * entries carry the file-derived `id` at the top level, not in data). */
24
+ type GlossaryLike = {
25
+ id: string;
26
+ data: Pick<GlossaryTerm, 'term' | 'draft'>;
27
+ };
28
+
29
+ /**
30
+ * Build the deck manifest: published (non-draft) terms, alphabetical by
31
+ * display term (locale-aware — same order as /glossary, so the static list
32
+ * fallback and the deck agree). Draft filtering is defensive; the shipped
33
+ * caller passes getCollection('glossary', e => !e.data.draft) output.
34
+ */
35
+ export function buildFlashcardDeck(entries: readonly GlossaryLike[]): FlashcardRef[] {
36
+ return entries
37
+ .filter((e) => !e.data.draft)
38
+ .map((e) => ({ id: e.id, front: e.data.term }))
39
+ .sort((a, b) => a.front.localeCompare(b.front));
40
+ }
@@ -102,6 +102,14 @@ export interface RouteToggles {
102
102
  * add a src/content/questions/ directory.
103
103
  */
104
104
  answers: boolean;
105
+ /**
106
+ * v4.22.0 (#116): auto-inject `/flashcards` — a spaced-recall deck generated
107
+ * from the `glossary` collection (front = term, back = definition), with a
108
+ * shuffle/flip/known-bucket island persisted to localStorage. Default
109
+ * `false` per profile — opt in via defineBookConfig({ routes:
110
+ * { flashcards: true } }) AND add a src/content/glossary/ directory.
111
+ */
112
+ flashcards: boolean;
105
113
  }
106
114
 
107
115
  /** Profile definition — declarative shape for one book profile. */
@@ -28,6 +28,7 @@ export const academicProfile = defineProfile({
28
28
  practiceExam: false, // v4.17.0 #112: opt-in per book; requires src/content/questions/
29
29
  glossary: false, // v4.19.0 #115: opt-in per book; requires src/content/glossary/
30
30
  answers: false, // v4.21.0 #114: opt-in per book; requires src/content/questions/
31
+ flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
31
32
  landing: true, // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
32
33
  },
33
34
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
@@ -30,6 +30,7 @@ export const courseNotesProfile = defineProfile({
30
30
  practiceExam: false, // v4.17.0 #112: opt-in per book; requires src/content/questions/
31
31
  glossary: false, // v4.19.0 #115: opt-in per book; requires src/content/glossary/
32
32
  answers: false, // v4.21.0 #114: opt-in per book; requires src/content/questions/
33
+ flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
33
34
  landing: true, // v4.5.0: auto-inject minimal root landing
34
35
  },
35
36
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
@@ -25,6 +25,7 @@ export const minimalProfile = defineProfile({
25
25
  practiceExam: false, // v4.17.0 #112: opt-in per book; requires src/content/questions/
26
26
  glossary: false, // v4.19.0 #115: opt-in per book; requires src/content/glossary/
27
27
  answers: false, // v4.21.0 #114: opt-in per book; requires src/content/questions/
28
+ flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
28
29
  landing: true, // v4.5.0: auto-inject minimal root landing
29
30
  },
30
31
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
@@ -41,6 +41,7 @@ export const researchPortfolioProfile = defineProfile({
41
41
  practiceExam: false, // v4.17.0 #112: opt-in per book; requires src/content/questions/
42
42
  glossary: false, // v4.19.0 #115: opt-in per book; requires src/content/glossary/
43
43
  answers: false, // v4.21.0 #114: opt-in per book; requires src/content/questions/
44
+ flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
44
45
  landing: true, // v4.5.0: auto-inject minimal root landing
45
46
  },
46
47
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
@@ -25,6 +25,7 @@ export const toolsProfile = defineProfile({
25
25
  practiceExam: false, // v4.17.0 #112: opt-in per book; requires src/content/questions/
26
26
  glossary: false, // v4.19.0 #115: opt-in per book; requires src/content/glossary/
27
27
  answers: false, // v4.21.0 #114: opt-in per book; requires src/content/questions/
28
+ flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
28
29
  landing: true, // v4.5.0: auto-inject minimal root landing
29
30
  },
30
31
  styles: [
@@ -0,0 +1,82 @@
1
+ /* Flashcards island (v4.22.0, #116) — imported by flashcards.astro
2
+ * (framework-component markup is outside Astro's style scoping, so these ship
3
+ * as a plain stylesheet like exam-runner.css). Tokens only → dark-mode free. */
4
+
5
+ .flashcards-runner {
6
+ margin-block: 1.25rem;
7
+ padding: 1rem 1.1rem;
8
+ border: 1px solid var(--color-accent, #4f46e5);
9
+ border-radius: 0.5rem;
10
+ background: var(--color-surface, transparent);
11
+ }
12
+
13
+ .flashcards-empty {
14
+ color: var(--color-text-muted, #6b7280);
15
+ font-style: italic;
16
+ }
17
+
18
+ .flashcards-intro {
19
+ margin-block: 0 0.5rem;
20
+ font-size: 0.95rem;
21
+ }
22
+
23
+ .flashcards-stats,
24
+ .flashcards-progress {
25
+ margin-block: 0 0.6rem;
26
+ font-size: 0.9rem;
27
+ color: var(--color-text-muted, #6b7280);
28
+ }
29
+
30
+ .flashcards-filter-label {
31
+ display: block;
32
+ margin-block-end: 0.75rem;
33
+ font-size: 0.95rem;
34
+ }
35
+
36
+ .flashcards-buttons {
37
+ display: flex;
38
+ flex-wrap: wrap;
39
+ gap: 0.5rem;
40
+ }
41
+
42
+ .flashcards-button {
43
+ cursor: pointer;
44
+ padding: 0.35rem 0.9rem;
45
+ border: 1px solid var(--color-accent, #4f46e5);
46
+ border-radius: 0.35rem;
47
+ background: transparent;
48
+ color: var(--color-accent, #4f46e5);
49
+ font: inherit;
50
+ font-weight: 600;
51
+ }
52
+
53
+ .flashcards-button:disabled {
54
+ opacity: 0.45;
55
+ cursor: not-allowed;
56
+ }
57
+
58
+ .flashcards-start,
59
+ .flashcards-flip {
60
+ background: var(--color-accent, #4f46e5);
61
+ color: var(--color-bg, #fff);
62
+ }
63
+
64
+ .flashcards-knew {
65
+ border-color: var(--color-success, #15803d);
66
+ color: var(--color-success, #15803d);
67
+ }
68
+
69
+ .flashcards-learning {
70
+ border-color: var(--color-danger, #b91c1c);
71
+ color: var(--color-danger, #b91c1c);
72
+ }
73
+
74
+ .flashcards-link {
75
+ cursor: pointer;
76
+ border: none;
77
+ background: none;
78
+ padding: 0;
79
+ font: inherit;
80
+ color: var(--color-accent, #4f46e5);
81
+ text-decoration: underline;
82
+ }