@brandon_m_behring/book-scaffold-astro 4.21.0 → 4.23.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/Flashcards.tsx +247 -0
- package/components/Sidebar.astro +8 -4
- package/dist/components/Flashcards.d.ts +12 -0
- package/dist/components/Flashcards.mjs +217 -0
- package/dist/flashcards-DPFFQhP8.d.ts +36 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.mjs +25 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +10 -0
- package/dist/{types-uerMuJwT.d.ts → types-CGN95mrX.d.ts} +19 -0
- package/package.json +6 -1
- package/pages/flashcards.astro +109 -0
- package/recipes/04-component-library.md +3 -1
- package/src/lib/flashcards.ts +40 -0
- package/src/profile-kit.ts +8 -0
- package/src/profiles/academic.ts +1 -0
- package/src/profiles/course-notes.ts +1 -0
- package/src/profiles/minimal.ts +1 -0
- package/src/profiles/research-portfolio.ts +1 -0
- package/src/profiles/tools.ts +1 -0
- package/styles/flashcards.css +82 -0
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).
|
|
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
|
+
}
|
package/components/Sidebar.astro
CHANGED
|
@@ -14,16 +14,20 @@
|
|
|
14
14
|
* Visual: ~280px wide, sticky to top, scrollable independently of main.
|
|
15
15
|
* Site title at top doubles as a "home" link.
|
|
16
16
|
*
|
|
17
|
-
* Customize:
|
|
18
|
-
*
|
|
17
|
+
* Customize (v4.23.0, #135): the brand reads `defineBookConfig({ title,
|
|
18
|
+
* subtitle })` via the book-config virtual module — consumers set both in
|
|
19
|
+
* astro.config.mjs with zero component overrides. The previous hardcoded
|
|
20
|
+
* strings remain the fallbacks, so existing books render unchanged until
|
|
21
|
+
* they configure a title.
|
|
19
22
|
*/
|
|
20
23
|
import { getCollection } from 'astro:content';
|
|
24
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
21
25
|
import { academicParts } from '../src/schemas';
|
|
22
26
|
import { academicPartHeading } from '../src/lib/academic-parts';
|
|
23
27
|
|
|
24
28
|
const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
|
|
25
|
-
const siteTitle = 'Book';
|
|
26
|
-
const siteSubtitle = 'A scaffold-astro book';
|
|
29
|
+
const siteTitle = bookConfig.title ?? 'Book';
|
|
30
|
+
const siteSubtitle = bookConfig.subtitle ?? 'A scaffold-astro book';
|
|
27
31
|
|
|
28
32
|
// Academic profile: part is a string enum. `academicParts` (schemas.ts) is
|
|
29
33
|
// the canonical order, shared with the renderer and ChapterHeader (#95); the
|
|
@@ -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-
|
|
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-
|
|
2
|
+
import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, l as Style } from './types-CGN95mrX.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-CGN95mrX.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
|
|
@@ -1175,6 +1188,7 @@ function bookScaffoldIntegration(opts) {
|
|
|
1175
1188
|
mdxComponentsModule,
|
|
1176
1189
|
// v4.5.0: landing-page data, propagated via virtual module to /index.astro.
|
|
1177
1190
|
title,
|
|
1191
|
+
subtitle,
|
|
1178
1192
|
description,
|
|
1179
1193
|
portfolio,
|
|
1180
1194
|
// v4.6.0: book-level author + SEO config, propagated through the
|
|
@@ -1238,6 +1252,7 @@ function bookScaffoldIntegration(opts) {
|
|
|
1238
1252
|
makeMdxComponentsVitePlugin(resolvedMdxPath),
|
|
1239
1253
|
makeBookConfigVitePlugin({
|
|
1240
1254
|
title: title ?? null,
|
|
1255
|
+
subtitle: subtitle ?? null,
|
|
1241
1256
|
description: description ?? null,
|
|
1242
1257
|
portfolio: portfolio ?? false,
|
|
1243
1258
|
enabledRoutes: enabledRouteNames,
|
|
@@ -1375,6 +1390,8 @@ async function defineBookConfig(opts) {
|
|
|
1375
1390
|
// v4.5.0: pass landing-page data through to the integration so it can
|
|
1376
1391
|
// be exposed to the auto-injected /index.astro via the virtual module.
|
|
1377
1392
|
title: opts.title,
|
|
1393
|
+
// v4.23.0 (#135): sidebar brand subtitle.
|
|
1394
|
+
subtitle: opts.subtitle,
|
|
1378
1395
|
description: opts.description,
|
|
1379
1396
|
portfolio: resolvedPortfolio,
|
|
1380
1397
|
// v4.6.0: book-level author + SEO config (ogImage, twitterHandle),
|
|
@@ -1426,6 +1443,7 @@ async function defineBookConfig(opts) {
|
|
|
1426
1443
|
katexMacros: _katexMacros,
|
|
1427
1444
|
// v4.5.0: strip new landing-related opts so they don't leak into AstroUserConfig.
|
|
1428
1445
|
title: _title,
|
|
1446
|
+
subtitle: _subtitle,
|
|
1429
1447
|
description: _description,
|
|
1430
1448
|
portfolio: _portfolio,
|
|
1431
1449
|
// v4.6.0: strip new book-level SEO opts (author + seo block).
|
|
@@ -1450,6 +1468,7 @@ async function defineBookConfig(opts) {
|
|
|
1450
1468
|
void _markdown;
|
|
1451
1469
|
void _katexMacros;
|
|
1452
1470
|
void _title;
|
|
1471
|
+
void _subtitle;
|
|
1453
1472
|
void _description;
|
|
1454
1473
|
void _portfolio;
|
|
1455
1474
|
void _author;
|
|
@@ -1725,6 +1744,11 @@ function spreadBlueprint(pool, count) {
|
|
|
1725
1744
|
};
|
|
1726
1745
|
}
|
|
1727
1746
|
|
|
1747
|
+
// src/lib/flashcards.ts
|
|
1748
|
+
function buildFlashcardDeck(entries) {
|
|
1749
|
+
return entries.filter((e) => !e.data.draft).map((e) => ({ id: e.id, front: e.data.term })).sort((a, b) => a.front.localeCompare(b.front));
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1728
1752
|
// src/lib/part-review.ts
|
|
1729
1753
|
function selectPartExercises(chapters, byChapter, part) {
|
|
1730
1754
|
const want = String(part);
|
|
@@ -1795,6 +1819,7 @@ export {
|
|
|
1795
1819
|
bloomLevels,
|
|
1796
1820
|
bookScaffoldIntegration,
|
|
1797
1821
|
buildExamManifest,
|
|
1822
|
+
buildFlashcardDeck,
|
|
1798
1823
|
buildGithubUrl,
|
|
1799
1824
|
changeKinds,
|
|
1800
1825
|
changelogSchema,
|
package/dist/schemas.d.ts
CHANGED
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 {
|
|
@@ -584,6 +592,14 @@ interface BookConfigOptions {
|
|
|
584
592
|
* default <title> when no per-page title is supplied.
|
|
585
593
|
*/
|
|
586
594
|
title?: string;
|
|
595
|
+
/**
|
|
596
|
+
* v4.23.0 (#135): Sidebar brand subtitle — the second line under the brand
|
|
597
|
+
* title in the left chapter-nav. Optional; defaults to the scaffold's
|
|
598
|
+
* placeholder ('A scaffold-astro book') for backward compatibility. The
|
|
599
|
+
* brand TITLE is the existing `title` field above (previously the sidebar
|
|
600
|
+
* hardcoded both strings, so every consumer shipped the placeholder).
|
|
601
|
+
*/
|
|
602
|
+
subtitle?: string;
|
|
587
603
|
/**
|
|
588
604
|
* v4.5.0: Book description. Read by the auto-injected `/` landing page (lead paragraph + <meta description>).
|
|
589
605
|
* Optional; landing renders no description paragraph if unset.
|
|
@@ -700,6 +716,9 @@ interface BookScaffoldIntegrationOptions {
|
|
|
700
716
|
extraStyles?: readonly string[];
|
|
701
717
|
/** v4.5.0: book title, propagated to `/` landing via vite.define. */
|
|
702
718
|
title?: string;
|
|
719
|
+
/** v4.23.0 (#135): sidebar brand subtitle, propagated via the book-config
|
|
720
|
+
* virtual module to Sidebar.astro. */
|
|
721
|
+
subtitle?: string;
|
|
703
722
|
/** v4.5.0: book description, propagated to `/` landing via vite.define. */
|
|
704
723
|
description?: string;
|
|
705
724
|
/**
|
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.
|
|
4
|
+
"version": "4.23.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
|
-
|
|
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
|
+
}
|
package/src/profile-kit.ts
CHANGED
|
@@ -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. */
|
package/src/profiles/academic.ts
CHANGED
|
@@ -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'],
|
package/src/profiles/minimal.ts
CHANGED
|
@@ -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'],
|
package/src/profiles/tools.ts
CHANGED
|
@@ -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
|
+
}
|