@ai-gui/plugin-flashcard 0.18.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/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # @ai-gui/plugin-flashcard
2
+
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a22ba20: **New package: `@ai-gui/plugin-flashcard` — cards to revise from.**
8
+
9
+ The moment a vocabulary list stops being a list. A word shown beside its meaning is a word being
10
+ _read_, and reading a word you have already read teaches nothing — what moves it into memory is being
11
+ asked for it and finding out whether it came. So a card hides its back, and the person says how it went
12
+ before they are told.
13
+
14
+ ```flashcards
15
+ {"version":1,"id":"de-week-1","gradeAction":"revise.word","cards":[{"id":"word-1","front":"der Kühlschrank","back":"冰箱","hint":"der Kühl-schrank","example":"Der Kühlschrank ist leer."}]}
16
+ ```
17
+
18
+ It schedules nothing: which card comes back tomorrow and which in a month is the host's, because only
19
+ the host knows what else this person is learning and when they last saw it. What travels out is one
20
+ grade per card — `again` / `hard` / `good` — through the same action allowlist a form's submission uses.
21
+ Three grades because "I half knew it" is the commonest answer and a two-way split forces it into a lie
22
+ in either direction.
23
+
24
+ `reveal: "immediate"` shows both sides of every card and grades nothing, for the teaching moment: hiding
25
+ the meaning of a word nobody has been told is a quiz on a lesson that has not happened.
26
+
27
+ The answer is written into the DOM only when it is asked for, not merely hidden — `hidden` is a style,
28
+ and an answer sitting in the card's text is one select-all away from being read first. Each card
29
+ dispatches under its own `cardId`, so the runtime's dedupe means "this card graded twice in one breath"
30
+ rather than dropping the second card's grade while the first is still in flight. Space or Enter reveals,
31
+ `1` `2` `3` grade, and a number pressed before the answer is shown does nothing.
32
+
33
+ ### Patch Changes
34
+
35
+ - Updated dependencies [a22ba20]
36
+ - @ai-gui/core@0.18.0
37
+
38
+ ## 0.18.0
39
+
40
+ ### Minor Changes
41
+
42
+ - a22ba20: **New package: cards to revise from.**
43
+
44
+ The moment a vocabulary list stops being a list. A word shown beside its meaning is a word being
45
+ _read_, and reading a word you have already read teaches nothing — what moves it into memory is being
46
+ asked for it and finding out whether it came. So a card hides its back, and the person says how it went
47
+ before they are told.
48
+
49
+ ```flashcards
50
+ {"version":1,"id":"de-week-1","gradeAction":"revise.word","cards":[{"id":"word-1","front":"der Kühlschrank","back":"冰箱","hint":"der Kühl-schrank","example":"Der Kühlschrank ist leer."}]}
51
+ ```
52
+
53
+ It schedules nothing: which card comes back tomorrow and which in a month is the host's, because only
54
+ the host knows what else this person is learning and when they last saw it. What travels out is one
55
+ grade per card — `again` / `hard` / `good` — through the same action allowlist a form's submission uses.
56
+ Three grades because "I half knew it" is the commonest answer and a two-way split forces it into a lie
57
+ in either direction.
58
+
59
+ `reveal: "immediate"` shows both sides of every card and grades nothing, for the teaching moment: hiding
60
+ the meaning of a word nobody has been told is a quiz on a lesson that has not happened.
61
+
62
+ The answer is written into the DOM only when it is asked for, not merely hidden — `hidden` is a style,
63
+ and an answer sitting in the card's text is one select-all away from being read first. Each card
64
+ dispatches under its own `cardId`, so the runtime's dedupe means "this card graded twice in one breath"
65
+ rather than dropping the second card's grade while the first is still in flight. Space or Enter reveals,
66
+ `1` `2` `3` grade, and a number pressed before the answer is shown does nothing.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liang Li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @ai-gui/plugin-flashcard
2
+
3
+ Cards to revise from: one at a time, answer hidden, self-graded.
4
+
5
+ The moment a vocabulary list stops being a list. A word shown beside its meaning is a word being
6
+ *read*, and reading a word you have already read teaches nothing — what moves it into memory is being
7
+ asked for it and finding out whether it came. So a card hides its back, and the person says how it went
8
+ before they are told.
9
+
10
+ ```md
11
+ ```flashcards
12
+ {"version":1,"id":"de-week-1","title":"本周德语词","gradeAction":"revise.word","cards":[
13
+ {"id":"word-1","front":"der Kühlschrank","back":"冰箱","hint":"der Kühl-schrank","example":"Der Kühlschrank ist leer."},
14
+ {"id":"word-2","front":"möchten","back":"想要"}
15
+ ]}
16
+ ```
17
+ ```
18
+
19
+ ```ts
20
+ import { flashcards } from "@ai-gui/plugin-flashcard"
21
+
22
+ const plugin = flashcards({ actionRuntime })
23
+ ```
24
+
25
+ ## What it does not do
26
+
27
+ **Scheduling.** Which card comes back tomorrow and which in a month is the host's: only the host knows
28
+ what else this person is learning and when they last saw it. What travels out is one grade per card —
29
+ `again` / `hard` / `good` — dispatched through the same action allowlist a form's submission uses:
30
+
31
+ ```ts
32
+ registry.register({
33
+ type: "revise.word",
34
+ run: ({ cardId, grade }) => scheduleNextAppearance(cardId, grade),
35
+ })
36
+ ```
37
+
38
+ Three grades, not two: "I half knew it" is the commonest answer, and a two-way split forces it into a
39
+ lie in either direction.
40
+
41
+ ## Two modes
42
+
43
+ `reveal: "hidden"` (the default) is revision — one card, answer hidden, graded.
44
+
45
+ `reveal: "immediate"` shows both sides of every card at once and grades nothing. That is the teaching
46
+ moment: the person is meeting these for the first time, and hiding the meaning of a word nobody has
47
+ been told is a quiz on a lesson that has not happened.
48
+
49
+ ## Keyboard
50
+
51
+ Space or Enter reveals; `1` `2` `3` grade. A number pressed before the answer is shown does nothing —
52
+ otherwise a stray keypress grades a card the person never saw, and the schedule believes it.
package/dist/index.cjs ADDED
@@ -0,0 +1,399 @@
1
+ "use strict";
2
+ //#region rolldown:runtime
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+
24
+ //#endregion
25
+ const __ai_gui_core = __toESM(require("@ai-gui/core"));
26
+
27
+ //#region src/index.ts
28
+ /**
29
+ * Cards to test yourself against, one at a time.
30
+ *
31
+ * The moment a vocabulary list stops being a list: a word shown beside its meaning is a word being
32
+ * read, and reading a word you have already read teaches nothing. What moves a word into memory is
33
+ * being asked for it and finding out whether it came — so a card hides its back, and the person says
34
+ * whether they knew it before they are told.
35
+ *
36
+ * That self-report is the whole output. This plugin does not schedule anything: which card comes back
37
+ * in a day and which in a month is the host's, because only the host knows what else the person is
38
+ * learning and when they last saw it. What travels out is one grade per card, through the same action
39
+ * allowlist a form's submission uses.
40
+ */
41
+ const DEFAULTS = {
42
+ maxSourceBytes: 16 * 1024,
43
+ maxCards: 60
44
+ };
45
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
46
+ const DECK_KEYS = new Set([
47
+ "id",
48
+ "version",
49
+ "reveal",
50
+ "gradeAction",
51
+ "title",
52
+ "cards"
53
+ ]);
54
+ const CARD_KEYS = new Set([
55
+ "id",
56
+ "front",
57
+ "back",
58
+ "hint",
59
+ "example",
60
+ "note"
61
+ ]);
62
+ const DEFAULT_LABELS = {
63
+ reveal: "看答案",
64
+ again: "不认识",
65
+ hard: "模糊",
66
+ good: "认识",
67
+ progress: (seen, total) => `${seen} / ${total}`,
68
+ done: (counts) => `这一轮:认识 ${counts.good},模糊 ${counts.hard},不认识 ${counts.again}`
69
+ };
70
+ /** Read a deck out of a block, strictly. */
71
+ function parseFlashcards(source, options = {}) {
72
+ const limits = {
73
+ ...DEFAULTS,
74
+ ...options
75
+ };
76
+ if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
77
+ valid: false,
78
+ issues: ["Deck is too large."]
79
+ };
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(source);
83
+ } catch {
84
+ return {
85
+ valid: false,
86
+ issues: ["Deck must be valid JSON."]
87
+ };
88
+ }
89
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
90
+ valid: false,
91
+ issues: ["Deck must be a JSON object."]
92
+ };
93
+ const value = parsed;
94
+ const issues = [];
95
+ for (const key of Object.keys(value)) if (!DECK_KEYS.has(key)) issues.push(`$.${key} is not a deck property.`);
96
+ if (value.version !== 1) issues.push("$.version must be 1.");
97
+ const id = typeof value.id === "string" ? value.id.trim() : "";
98
+ if (!id || !SAFE_ID.test(id)) issues.push("$.id must be a safe identifier.");
99
+ const reveal = value.reveal === void 0 ? "hidden" : value.reveal;
100
+ if (reveal !== "hidden" && reveal !== "immediate") issues.push("$.reveal must be \"hidden\" or \"immediate\".");
101
+ let gradeAction;
102
+ if (value.gradeAction !== void 0) if (typeof value.gradeAction !== "string" || !SAFE_ID.test(value.gradeAction)) issues.push("$.gradeAction must name a registered action.");
103
+ else gradeAction = value.gradeAction;
104
+ const title = typeof value.title === "string" ? value.title.trim() : void 0;
105
+ if (!Array.isArray(value.cards)) issues.push("$.cards must be an array.");
106
+ const cards = [];
107
+ const seen = new Set();
108
+ for (const [index, entry] of (Array.isArray(value.cards) ? value.cards : []).entries()) {
109
+ const path = `$.cards[${index}]`;
110
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
111
+ issues.push(`${path} must be an object.`);
112
+ continue;
113
+ }
114
+ const card = entry;
115
+ for (const key of Object.keys(card)) if (!CARD_KEYS.has(key)) issues.push(`${path}.${key} is not a card property.`);
116
+ const cardId = typeof card.id === "string" ? card.id.trim() : "";
117
+ const front = typeof card.front === "string" ? card.front.trim() : "";
118
+ const back = typeof card.back === "string" ? card.back.trim() : "";
119
+ if (!cardId || !SAFE_ID.test(cardId)) issues.push(`${path}.id must be a safe identifier.`);
120
+ if (!front) issues.push(`${path}.front is required.`);
121
+ if (!back) issues.push(`${path}.back is required.`);
122
+ if (cardId && seen.has(cardId)) issues.push(`${path}.id is already used in this deck.`);
123
+ seen.add(cardId);
124
+ if (!cardId || !front || !back) continue;
125
+ cards.push({
126
+ id: cardId,
127
+ front,
128
+ back,
129
+ ...typeof card.hint === "string" && card.hint.trim() ? { hint: card.hint.trim() } : {},
130
+ ...typeof card.example === "string" && card.example.trim() ? { example: card.example.trim() } : {},
131
+ ...typeof card.note === "string" && card.note.trim() ? { note: card.note.trim() } : {}
132
+ });
133
+ if (cards.length > limits.maxCards) {
134
+ issues.push(`$.cards holds more than ${limits.maxCards} cards.`);
135
+ break;
136
+ }
137
+ }
138
+ if (cards.length === 0 && issues.length === 0) issues.push("$.cards is empty.");
139
+ if (issues.length > 0) return {
140
+ valid: false,
141
+ issues
142
+ };
143
+ return {
144
+ valid: true,
145
+ data: {
146
+ id,
147
+ version: 1,
148
+ reveal,
149
+ gradeAction,
150
+ title,
151
+ cards
152
+ }
153
+ };
154
+ }
155
+ function escapeHtml(value) {
156
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
157
+ }
158
+ /** Every card at once, with both sides shown — the teaching case, where nothing is being tested yet. */
159
+ function renderOpenDeck(deck) {
160
+ const rows = deck.cards.map((card) => {
161
+ const hint = card.hint ? `<span data-aigui-card-hint>${escapeHtml(card.hint)}</span>` : "";
162
+ const example = card.example ? `<p data-aigui-card-example>${escapeHtml(card.example)}</p>` : "";
163
+ return `<li><div data-aigui-card-front><strong>${escapeHtml(card.front)}</strong>${hint}</div><div data-aigui-card-back>${escapeHtml(card.back)}</div>${example}</li>`;
164
+ }).join("");
165
+ const title = deck.title ? `<p data-aigui-deck-title>${escapeHtml(deck.title)}</p>` : "";
166
+ return `<div data-aigui-flashcards="${escapeHtml(deck.id)}" data-reveal="immediate">${title}<ul data-aigui-card-list>${rows}</ul></div>`;
167
+ }
168
+ /**
169
+ * A deck that asks first.
170
+ *
171
+ * Mounted rather than rendered as HTML: the whole point is what happens between showing the front and
172
+ * showing the back, and that is a sequence of events, not a string.
173
+ */
174
+ function mountDeck(host, deck, options) {
175
+ const labels = {
176
+ ...DEFAULT_LABELS,
177
+ ...options.labels
178
+ };
179
+ const root = document.createElement("div");
180
+ root.setAttribute("data-aigui-flashcards", deck.id);
181
+ root.setAttribute("data-reveal", "hidden");
182
+ const header = document.createElement("div");
183
+ header.setAttribute("data-aigui-deck-header", "");
184
+ const title = document.createElement("span");
185
+ title.setAttribute("data-aigui-deck-title", "");
186
+ title.textContent = deck.title ?? "";
187
+ const progress = document.createElement("span");
188
+ progress.setAttribute("data-aigui-deck-progress", "");
189
+ progress.setAttribute("aria-live", "polite");
190
+ header.append(title, progress);
191
+ const card = document.createElement("div");
192
+ card.setAttribute("data-aigui-card", "");
193
+ card.tabIndex = 0;
194
+ card.setAttribute("role", "button");
195
+ const front = document.createElement("div");
196
+ front.setAttribute("data-aigui-card-front", "");
197
+ const hint = document.createElement("span");
198
+ hint.setAttribute("data-aigui-card-hint", "");
199
+ const back = document.createElement("div");
200
+ back.setAttribute("data-aigui-card-back", "");
201
+ back.hidden = true;
202
+ const example = document.createElement("p");
203
+ example.setAttribute("data-aigui-card-example", "");
204
+ example.hidden = true;
205
+ card.append(front, hint, back, example);
206
+ const reveal = document.createElement("button");
207
+ reveal.type = "button";
208
+ reveal.setAttribute("data-aigui-card-reveal", "");
209
+ reveal.textContent = labels.reveal;
210
+ const grades = document.createElement("div");
211
+ grades.setAttribute("data-aigui-card-grades", "");
212
+ grades.hidden = true;
213
+ for (const grade$1 of [
214
+ "again",
215
+ "hard",
216
+ "good"
217
+ ]) {
218
+ const button = document.createElement("button");
219
+ button.type = "button";
220
+ button.dataset.aiguiCardGrade = grade$1;
221
+ button.textContent = labels[grade$1];
222
+ grades.appendChild(button);
223
+ }
224
+ const done = document.createElement("p");
225
+ done.setAttribute("data-aigui-deck-done", "");
226
+ done.hidden = true;
227
+ root.append(header, card, reveal, grades, done);
228
+ host.replaceChildren(root);
229
+ let index = 0;
230
+ let shown = false;
231
+ const counts = {
232
+ again: 0,
233
+ hard: 0,
234
+ good: 0
235
+ };
236
+ let disposed = false;
237
+ const draw = () => {
238
+ const current = deck.cards[index];
239
+ if (!current) {
240
+ card.hidden = true;
241
+ reveal.hidden = true;
242
+ grades.hidden = true;
243
+ done.hidden = false;
244
+ done.textContent = labels.done(counts);
245
+ root.setAttribute("data-finished", "true");
246
+ progress.textContent = labels.progress(deck.cards.length, deck.cards.length);
247
+ return;
248
+ }
249
+ front.textContent = current.front;
250
+ hint.textContent = current.hint ?? "";
251
+ hint.hidden = !current.hint;
252
+ back.textContent = shown ? current.back : "";
253
+ example.textContent = shown ? current.example ?? "" : "";
254
+ back.hidden = !shown;
255
+ example.hidden = !shown || !current.example;
256
+ reveal.hidden = shown;
257
+ grades.hidden = !shown;
258
+ card.setAttribute("data-shown", String(shown));
259
+ progress.textContent = labels.progress(index + (shown ? 1 : 0), deck.cards.length);
260
+ };
261
+ const show = () => {
262
+ if (shown) return;
263
+ shown = true;
264
+ draw();
265
+ grades.querySelector("button")?.focus();
266
+ };
267
+ const grade = (value) => {
268
+ const current = deck.cards[index];
269
+ if (!current || disposed) return;
270
+ counts[value] += 1;
271
+ if (deck.gradeAction) options.actionRuntime.dispatch({
272
+ type: deck.gradeAction,
273
+ cardType: deck.id,
274
+ cardId: current.id,
275
+ params: {
276
+ deckId: deck.id,
277
+ cardId: current.id,
278
+ grade: value
279
+ }
280
+ }, { owner: root }).catch((error) => {
281
+ if (error instanceof __ai_gui_core.ActionRuntimeError) return;
282
+ throw error;
283
+ });
284
+ index += 1;
285
+ shown = false;
286
+ draw();
287
+ if (index < deck.cards.length) card.focus();
288
+ };
289
+ const onCardActivate = (event) => {
290
+ if (event instanceof KeyboardEvent) {
291
+ if (event.key !== "Enter" && event.key !== " ") return;
292
+ event.preventDefault();
293
+ }
294
+ show();
295
+ };
296
+ card.addEventListener("click", onCardActivate);
297
+ card.addEventListener("keydown", onCardActivate);
298
+ reveal.addEventListener("click", show);
299
+ const onGrade = (event) => {
300
+ const button = event.target.closest("[data-aigui-card-grade]");
301
+ if (!button) return;
302
+ grade(button.dataset.aiguiCardGrade);
303
+ };
304
+ grades.addEventListener("click", onGrade);
305
+ const onKey = (event) => {
306
+ if (!shown) return;
307
+ const byNumber = {
308
+ "1": "again",
309
+ "2": "hard",
310
+ "3": "good"
311
+ };
312
+ const chosen = byNumber[event.key];
313
+ if (!chosen) return;
314
+ event.preventDefault();
315
+ grade(chosen);
316
+ };
317
+ root.addEventListener("keydown", onKey);
318
+ draw();
319
+ return () => {
320
+ disposed = true;
321
+ card.removeEventListener("click", onCardActivate);
322
+ card.removeEventListener("keydown", onCardActivate);
323
+ reveal.removeEventListener("click", show);
324
+ grades.removeEventListener("click", onGrade);
325
+ root.removeEventListener("keydown", onKey);
326
+ };
327
+ }
328
+ function flashcardPromptSpec() {
329
+ return [
330
+ "Flashcards (fenced): ```flashcards <safe deck JSON>```.",
331
+ "Deck: {\"version\":1,\"id\":\"...\",\"reveal\":\"hidden|immediate\"?,\"gradeAction\":\"...\"?,\"title\":\"...\"?,\"cards\":[{\"id\":\"...\",\"front\":\"...\",\"back\":\"...\",\"hint\":\"...\"?,\"example\":\"...\"?}]}.",
332
+ "`hidden` (the default) shows one card at a time and asks the person to say how it went before showing the answer — use it to revise. `immediate` shows both sides of every card at once and grades nothing — use it when they are meeting these for the first time.",
333
+ "`front` is what is being asked and `back` is what it is checked against; a `hint` is shown with the front and must never give the answer away. `id` on each card is what a grade is reported against, so use the host's own ids.",
334
+ "gradeAction must name an application-registered Action. Never emit URLs, scripts, HTML or handlers."
335
+ ].join("\n");
336
+ }
337
+ const flashcardCss = `
338
+ [data-aigui-flashcards] { margin: 14px 0; padding: 14px; border: 1px solid #c8d6ce; border-radius: 12px; background: #f7f9f6; }
339
+ [data-aigui-deck-header] { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; font-size: 12px; color: #5d6f65; }
340
+ [data-aigui-card] { display: grid; gap: 6px; padding: 22px 16px; text-align: center; border: 1px solid #c4d1ca; border-radius: 10px; background: #fff; cursor: pointer; }
341
+ [data-aigui-card]:focus-visible { outline: 2px solid #2f6b4f; outline-offset: 2px; }
342
+ [data-aigui-card-front] { font-size: 20px; font-weight: 700; }
343
+ [data-aigui-card-hint] { font-size: 12px; color: #7b8b81; }
344
+ [data-aigui-card-back] { padding-top: 8px; border-top: 1px dashed #d3ded7; font-size: 16px; }
345
+ [data-aigui-card-example] { margin: 0; font-size: 13px; color: #55655c; }
346
+ [data-aigui-card-reveal] { width: 100%; margin-top: 10px; padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }
347
+ [data-aigui-card-grades] { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; }
348
+ [data-aigui-card-grades] button { padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }
349
+ [data-aigui-card-grades] button[data-aigui-card-grade="again"] { color: #8a2b20; border-color: #e3b4ad; }
350
+ [data-aigui-card-grades] button[data-aigui-card-grade="good"] { color: #23543c; border-color: #a9c9b6; }
351
+ [data-aigui-card-list] { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
352
+ [data-aigui-card-list] li { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; }
353
+ [data-aigui-deck-done] { margin: 10px 0 0; text-align: center; font-size: 13px; color: #23543c; }
354
+ `;
355
+ /**
356
+ * Cards to revise from.
357
+ *
358
+ * @example
359
+ * ```ts
360
+ * const plugin = flashcards({ actionRuntime })
361
+ * ```
362
+ */
363
+ function flashcards(options) {
364
+ if (!options?.actionRuntime) throw new TypeError("flashcards() requires an actionRuntime");
365
+ return {
366
+ name: "flashcards",
367
+ nodeRenderers: { flashcards: (node) => {
368
+ const parsed = parseFlashcards(node.content ?? "", options);
369
+ if (!parsed.valid) return {
370
+ kind: "html",
371
+ html: `<div data-aigui-flashcards-error>${escapeHtml(parsed.issues[0] ?? "Invalid deck.")}</div>`,
372
+ trusted: true
373
+ };
374
+ const deck = parsed.data;
375
+ if (deck.gradeAction && !options.actionRuntime.hasAction(deck.gradeAction)) return {
376
+ kind: "html",
377
+ html: `<div data-aigui-flashcards-error>${escapeHtml(`Unknown action: ${deck.gradeAction}`)}</div>`,
378
+ trusted: true
379
+ };
380
+ if (deck.reveal === "immediate") return {
381
+ kind: "html",
382
+ html: renderOpenDeck(deck),
383
+ trusted: true
384
+ };
385
+ return {
386
+ kind: "mount",
387
+ mount: (host) => mountDeck(host, deck, options)
388
+ };
389
+ } },
390
+ promptSpec: flashcardPromptSpec(),
391
+ css: flashcardCss
392
+ };
393
+ }
394
+
395
+ //#endregion
396
+ exports.flashcardCss = flashcardCss
397
+ exports.flashcardPromptSpec = flashcardPromptSpec
398
+ exports.flashcards = flashcards
399
+ exports.parseFlashcards = parseFlashcards
@@ -0,0 +1,75 @@
1
+ import { AIGuiPlugin, ActionRuntime } from "@ai-gui/core";
2
+
3
+ //#region src/index.d.ts
4
+ /** How the person said it went. Three, because "I half knew it" is the commonest answer and a
5
+ * two-way split forces it into a lie either way. */
6
+ /** How the person said it went. Three, because "I half knew it" is the commonest answer and a
7
+ * two-way split forces it into a lie either way. */
8
+ type CardGrade = "again" | "hard" | "good";
9
+ interface FlashcardOptions {
10
+ maxSourceBytes?: number;
11
+ maxCards?: number;
12
+ }
13
+ interface FlashcardPluginOptions extends FlashcardOptions {
14
+ /** Shared runtime whose registry is the only allowlist for `gradeAction`. */
15
+ actionRuntime: ActionRuntime;
16
+ /** Labels, so a deck can speak the language the lesson is in. */
17
+ labels?: Partial<FlashcardLabels>;
18
+ }
19
+ interface FlashcardLabels {
20
+ reveal: string;
21
+ again: string;
22
+ hard: string;
23
+ good: string;
24
+ progress: (seen: number, total: number) => string;
25
+ done: (counts: Record<CardGrade, number>) => string;
26
+ }
27
+ interface Flashcard {
28
+ /** The host's own id for this card — what a grade is reported against. */
29
+ id: string;
30
+ /** What is asked. A word, a formula, a question. */
31
+ front: string;
32
+ /** What is checked against. */
33
+ back: string;
34
+ /** Shown with the front: a reading, a first letter, a category. Never the answer itself. */
35
+ hint?: string;
36
+ /** Shown with the back: the sentence that makes the answer usable. */
37
+ example?: string;
38
+ note?: string;
39
+ }
40
+ interface FlashcardDeck {
41
+ id: string;
42
+ version: 1;
43
+ /**
44
+ * `hidden` asks before it tells — the revision case, and the default.
45
+ *
46
+ * `immediate` shows both sides at once and grades nothing: the teaching case, where the person is
47
+ * meeting these for the first time and there is nothing yet to test.
48
+ */
49
+ reveal: "hidden" | "immediate";
50
+ /** The registered action a grade is reported to. Absent means nothing is reported. */
51
+ gradeAction?: string;
52
+ title?: string;
53
+ cards: Flashcard[];
54
+ }
55
+ type FlashcardParseResult = {
56
+ valid: true;
57
+ data: FlashcardDeck;
58
+ } | {
59
+ valid: false;
60
+ issues: string[];
61
+ };
62
+ /** Read a deck out of a block, strictly. */
63
+ declare function parseFlashcards(source: string, options?: FlashcardOptions): FlashcardParseResult;
64
+ declare function flashcardPromptSpec(): string;
65
+ declare const flashcardCss = "\n[data-aigui-flashcards] { margin: 14px 0; padding: 14px; border: 1px solid #c8d6ce; border-radius: 12px; background: #f7f9f6; }\n[data-aigui-deck-header] { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; font-size: 12px; color: #5d6f65; }\n[data-aigui-card] { display: grid; gap: 6px; padding: 22px 16px; text-align: center; border: 1px solid #c4d1ca; border-radius: 10px; background: #fff; cursor: pointer; }\n[data-aigui-card]:focus-visible { outline: 2px solid #2f6b4f; outline-offset: 2px; }\n[data-aigui-card-front] { font-size: 20px; font-weight: 700; }\n[data-aigui-card-hint] { font-size: 12px; color: #7b8b81; }\n[data-aigui-card-back] { padding-top: 8px; border-top: 1px dashed #d3ded7; font-size: 16px; }\n[data-aigui-card-example] { margin: 0; font-size: 13px; color: #55655c; }\n[data-aigui-card-reveal] { width: 100%; margin-top: 10px; padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }\n[data-aigui-card-grades] { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; }\n[data-aigui-card-grades] button { padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }\n[data-aigui-card-grades] button[data-aigui-card-grade=\"again\"] { color: #8a2b20; border-color: #e3b4ad; }\n[data-aigui-card-grades] button[data-aigui-card-grade=\"good\"] { color: #23543c; border-color: #a9c9b6; }\n[data-aigui-card-list] { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }\n[data-aigui-card-list] li { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; }\n[data-aigui-deck-done] { margin: 10px 0 0; text-align: center; font-size: 13px; color: #23543c; }\n";
66
+ /**
67
+ * Cards to revise from.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const plugin = flashcards({ actionRuntime })
72
+ * ```
73
+ */
74
+ declare function flashcards(options: FlashcardPluginOptions): AIGuiPlugin; //#endregion
75
+ export { CardGrade, Flashcard, FlashcardDeck, FlashcardLabels, FlashcardOptions, FlashcardParseResult, FlashcardPluginOptions, flashcardCss, flashcardPromptSpec, flashcards, parseFlashcards };
@@ -0,0 +1,75 @@
1
+ import { AIGuiPlugin, ActionRuntime } from "@ai-gui/core";
2
+
3
+ //#region src/index.d.ts
4
+ /** How the person said it went. Three, because "I half knew it" is the commonest answer and a
5
+ * two-way split forces it into a lie either way. */
6
+ /** How the person said it went. Three, because "I half knew it" is the commonest answer and a
7
+ * two-way split forces it into a lie either way. */
8
+ type CardGrade = "again" | "hard" | "good";
9
+ interface FlashcardOptions {
10
+ maxSourceBytes?: number;
11
+ maxCards?: number;
12
+ }
13
+ interface FlashcardPluginOptions extends FlashcardOptions {
14
+ /** Shared runtime whose registry is the only allowlist for `gradeAction`. */
15
+ actionRuntime: ActionRuntime;
16
+ /** Labels, so a deck can speak the language the lesson is in. */
17
+ labels?: Partial<FlashcardLabels>;
18
+ }
19
+ interface FlashcardLabels {
20
+ reveal: string;
21
+ again: string;
22
+ hard: string;
23
+ good: string;
24
+ progress: (seen: number, total: number) => string;
25
+ done: (counts: Record<CardGrade, number>) => string;
26
+ }
27
+ interface Flashcard {
28
+ /** The host's own id for this card — what a grade is reported against. */
29
+ id: string;
30
+ /** What is asked. A word, a formula, a question. */
31
+ front: string;
32
+ /** What is checked against. */
33
+ back: string;
34
+ /** Shown with the front: a reading, a first letter, a category. Never the answer itself. */
35
+ hint?: string;
36
+ /** Shown with the back: the sentence that makes the answer usable. */
37
+ example?: string;
38
+ note?: string;
39
+ }
40
+ interface FlashcardDeck {
41
+ id: string;
42
+ version: 1;
43
+ /**
44
+ * `hidden` asks before it tells — the revision case, and the default.
45
+ *
46
+ * `immediate` shows both sides at once and grades nothing: the teaching case, where the person is
47
+ * meeting these for the first time and there is nothing yet to test.
48
+ */
49
+ reveal: "hidden" | "immediate";
50
+ /** The registered action a grade is reported to. Absent means nothing is reported. */
51
+ gradeAction?: string;
52
+ title?: string;
53
+ cards: Flashcard[];
54
+ }
55
+ type FlashcardParseResult = {
56
+ valid: true;
57
+ data: FlashcardDeck;
58
+ } | {
59
+ valid: false;
60
+ issues: string[];
61
+ };
62
+ /** Read a deck out of a block, strictly. */
63
+ declare function parseFlashcards(source: string, options?: FlashcardOptions): FlashcardParseResult;
64
+ declare function flashcardPromptSpec(): string;
65
+ declare const flashcardCss = "\n[data-aigui-flashcards] { margin: 14px 0; padding: 14px; border: 1px solid #c8d6ce; border-radius: 12px; background: #f7f9f6; }\n[data-aigui-deck-header] { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; font-size: 12px; color: #5d6f65; }\n[data-aigui-card] { display: grid; gap: 6px; padding: 22px 16px; text-align: center; border: 1px solid #c4d1ca; border-radius: 10px; background: #fff; cursor: pointer; }\n[data-aigui-card]:focus-visible { outline: 2px solid #2f6b4f; outline-offset: 2px; }\n[data-aigui-card-front] { font-size: 20px; font-weight: 700; }\n[data-aigui-card-hint] { font-size: 12px; color: #7b8b81; }\n[data-aigui-card-back] { padding-top: 8px; border-top: 1px dashed #d3ded7; font-size: 16px; }\n[data-aigui-card-example] { margin: 0; font-size: 13px; color: #55655c; }\n[data-aigui-card-reveal] { width: 100%; margin-top: 10px; padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }\n[data-aigui-card-grades] { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; }\n[data-aigui-card-grades] button { padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }\n[data-aigui-card-grades] button[data-aigui-card-grade=\"again\"] { color: #8a2b20; border-color: #e3b4ad; }\n[data-aigui-card-grades] button[data-aigui-card-grade=\"good\"] { color: #23543c; border-color: #a9c9b6; }\n[data-aigui-card-list] { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }\n[data-aigui-card-list] li { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; }\n[data-aigui-deck-done] { margin: 10px 0 0; text-align: center; font-size: 13px; color: #23543c; }\n";
66
+ /**
67
+ * Cards to revise from.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const plugin = flashcards({ actionRuntime })
72
+ * ```
73
+ */
74
+ declare function flashcards(options: FlashcardPluginOptions): AIGuiPlugin; //#endregion
75
+ export { CardGrade, Flashcard, FlashcardDeck, FlashcardLabels, FlashcardOptions, FlashcardParseResult, FlashcardPluginOptions, flashcardCss, flashcardPromptSpec, flashcards, parseFlashcards };
package/dist/index.js ADDED
@@ -0,0 +1,372 @@
1
+ import { ActionRuntimeError } from "@ai-gui/core";
2
+
3
+ //#region src/index.ts
4
+ /**
5
+ * Cards to test yourself against, one at a time.
6
+ *
7
+ * The moment a vocabulary list stops being a list: a word shown beside its meaning is a word being
8
+ * read, and reading a word you have already read teaches nothing. What moves a word into memory is
9
+ * being asked for it and finding out whether it came — so a card hides its back, and the person says
10
+ * whether they knew it before they are told.
11
+ *
12
+ * That self-report is the whole output. This plugin does not schedule anything: which card comes back
13
+ * in a day and which in a month is the host's, because only the host knows what else the person is
14
+ * learning and when they last saw it. What travels out is one grade per card, through the same action
15
+ * allowlist a form's submission uses.
16
+ */
17
+ const DEFAULTS = {
18
+ maxSourceBytes: 16 * 1024,
19
+ maxCards: 60
20
+ };
21
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
22
+ const DECK_KEYS = new Set([
23
+ "id",
24
+ "version",
25
+ "reveal",
26
+ "gradeAction",
27
+ "title",
28
+ "cards"
29
+ ]);
30
+ const CARD_KEYS = new Set([
31
+ "id",
32
+ "front",
33
+ "back",
34
+ "hint",
35
+ "example",
36
+ "note"
37
+ ]);
38
+ const DEFAULT_LABELS = {
39
+ reveal: "看答案",
40
+ again: "不认识",
41
+ hard: "模糊",
42
+ good: "认识",
43
+ progress: (seen, total) => `${seen} / ${total}`,
44
+ done: (counts) => `这一轮:认识 ${counts.good},模糊 ${counts.hard},不认识 ${counts.again}`
45
+ };
46
+ /** Read a deck out of a block, strictly. */
47
+ function parseFlashcards(source, options = {}) {
48
+ const limits = {
49
+ ...DEFAULTS,
50
+ ...options
51
+ };
52
+ if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
53
+ valid: false,
54
+ issues: ["Deck is too large."]
55
+ };
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(source);
59
+ } catch {
60
+ return {
61
+ valid: false,
62
+ issues: ["Deck must be valid JSON."]
63
+ };
64
+ }
65
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
66
+ valid: false,
67
+ issues: ["Deck must be a JSON object."]
68
+ };
69
+ const value = parsed;
70
+ const issues = [];
71
+ for (const key of Object.keys(value)) if (!DECK_KEYS.has(key)) issues.push(`$.${key} is not a deck property.`);
72
+ if (value.version !== 1) issues.push("$.version must be 1.");
73
+ const id = typeof value.id === "string" ? value.id.trim() : "";
74
+ if (!id || !SAFE_ID.test(id)) issues.push("$.id must be a safe identifier.");
75
+ const reveal = value.reveal === void 0 ? "hidden" : value.reveal;
76
+ if (reveal !== "hidden" && reveal !== "immediate") issues.push("$.reveal must be \"hidden\" or \"immediate\".");
77
+ let gradeAction;
78
+ if (value.gradeAction !== void 0) if (typeof value.gradeAction !== "string" || !SAFE_ID.test(value.gradeAction)) issues.push("$.gradeAction must name a registered action.");
79
+ else gradeAction = value.gradeAction;
80
+ const title = typeof value.title === "string" ? value.title.trim() : void 0;
81
+ if (!Array.isArray(value.cards)) issues.push("$.cards must be an array.");
82
+ const cards = [];
83
+ const seen = new Set();
84
+ for (const [index, entry] of (Array.isArray(value.cards) ? value.cards : []).entries()) {
85
+ const path = `$.cards[${index}]`;
86
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
87
+ issues.push(`${path} must be an object.`);
88
+ continue;
89
+ }
90
+ const card = entry;
91
+ for (const key of Object.keys(card)) if (!CARD_KEYS.has(key)) issues.push(`${path}.${key} is not a card property.`);
92
+ const cardId = typeof card.id === "string" ? card.id.trim() : "";
93
+ const front = typeof card.front === "string" ? card.front.trim() : "";
94
+ const back = typeof card.back === "string" ? card.back.trim() : "";
95
+ if (!cardId || !SAFE_ID.test(cardId)) issues.push(`${path}.id must be a safe identifier.`);
96
+ if (!front) issues.push(`${path}.front is required.`);
97
+ if (!back) issues.push(`${path}.back is required.`);
98
+ if (cardId && seen.has(cardId)) issues.push(`${path}.id is already used in this deck.`);
99
+ seen.add(cardId);
100
+ if (!cardId || !front || !back) continue;
101
+ cards.push({
102
+ id: cardId,
103
+ front,
104
+ back,
105
+ ...typeof card.hint === "string" && card.hint.trim() ? { hint: card.hint.trim() } : {},
106
+ ...typeof card.example === "string" && card.example.trim() ? { example: card.example.trim() } : {},
107
+ ...typeof card.note === "string" && card.note.trim() ? { note: card.note.trim() } : {}
108
+ });
109
+ if (cards.length > limits.maxCards) {
110
+ issues.push(`$.cards holds more than ${limits.maxCards} cards.`);
111
+ break;
112
+ }
113
+ }
114
+ if (cards.length === 0 && issues.length === 0) issues.push("$.cards is empty.");
115
+ if (issues.length > 0) return {
116
+ valid: false,
117
+ issues
118
+ };
119
+ return {
120
+ valid: true,
121
+ data: {
122
+ id,
123
+ version: 1,
124
+ reveal,
125
+ gradeAction,
126
+ title,
127
+ cards
128
+ }
129
+ };
130
+ }
131
+ function escapeHtml(value) {
132
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
133
+ }
134
+ /** Every card at once, with both sides shown — the teaching case, where nothing is being tested yet. */
135
+ function renderOpenDeck(deck) {
136
+ const rows = deck.cards.map((card) => {
137
+ const hint = card.hint ? `<span data-aigui-card-hint>${escapeHtml(card.hint)}</span>` : "";
138
+ const example = card.example ? `<p data-aigui-card-example>${escapeHtml(card.example)}</p>` : "";
139
+ return `<li><div data-aigui-card-front><strong>${escapeHtml(card.front)}</strong>${hint}</div><div data-aigui-card-back>${escapeHtml(card.back)}</div>${example}</li>`;
140
+ }).join("");
141
+ const title = deck.title ? `<p data-aigui-deck-title>${escapeHtml(deck.title)}</p>` : "";
142
+ return `<div data-aigui-flashcards="${escapeHtml(deck.id)}" data-reveal="immediate">${title}<ul data-aigui-card-list>${rows}</ul></div>`;
143
+ }
144
+ /**
145
+ * A deck that asks first.
146
+ *
147
+ * Mounted rather than rendered as HTML: the whole point is what happens between showing the front and
148
+ * showing the back, and that is a sequence of events, not a string.
149
+ */
150
+ function mountDeck(host, deck, options) {
151
+ const labels = {
152
+ ...DEFAULT_LABELS,
153
+ ...options.labels
154
+ };
155
+ const root = document.createElement("div");
156
+ root.setAttribute("data-aigui-flashcards", deck.id);
157
+ root.setAttribute("data-reveal", "hidden");
158
+ const header = document.createElement("div");
159
+ header.setAttribute("data-aigui-deck-header", "");
160
+ const title = document.createElement("span");
161
+ title.setAttribute("data-aigui-deck-title", "");
162
+ title.textContent = deck.title ?? "";
163
+ const progress = document.createElement("span");
164
+ progress.setAttribute("data-aigui-deck-progress", "");
165
+ progress.setAttribute("aria-live", "polite");
166
+ header.append(title, progress);
167
+ const card = document.createElement("div");
168
+ card.setAttribute("data-aigui-card", "");
169
+ card.tabIndex = 0;
170
+ card.setAttribute("role", "button");
171
+ const front = document.createElement("div");
172
+ front.setAttribute("data-aigui-card-front", "");
173
+ const hint = document.createElement("span");
174
+ hint.setAttribute("data-aigui-card-hint", "");
175
+ const back = document.createElement("div");
176
+ back.setAttribute("data-aigui-card-back", "");
177
+ back.hidden = true;
178
+ const example = document.createElement("p");
179
+ example.setAttribute("data-aigui-card-example", "");
180
+ example.hidden = true;
181
+ card.append(front, hint, back, example);
182
+ const reveal = document.createElement("button");
183
+ reveal.type = "button";
184
+ reveal.setAttribute("data-aigui-card-reveal", "");
185
+ reveal.textContent = labels.reveal;
186
+ const grades = document.createElement("div");
187
+ grades.setAttribute("data-aigui-card-grades", "");
188
+ grades.hidden = true;
189
+ for (const grade$1 of [
190
+ "again",
191
+ "hard",
192
+ "good"
193
+ ]) {
194
+ const button = document.createElement("button");
195
+ button.type = "button";
196
+ button.dataset.aiguiCardGrade = grade$1;
197
+ button.textContent = labels[grade$1];
198
+ grades.appendChild(button);
199
+ }
200
+ const done = document.createElement("p");
201
+ done.setAttribute("data-aigui-deck-done", "");
202
+ done.hidden = true;
203
+ root.append(header, card, reveal, grades, done);
204
+ host.replaceChildren(root);
205
+ let index = 0;
206
+ let shown = false;
207
+ const counts = {
208
+ again: 0,
209
+ hard: 0,
210
+ good: 0
211
+ };
212
+ let disposed = false;
213
+ const draw = () => {
214
+ const current = deck.cards[index];
215
+ if (!current) {
216
+ card.hidden = true;
217
+ reveal.hidden = true;
218
+ grades.hidden = true;
219
+ done.hidden = false;
220
+ done.textContent = labels.done(counts);
221
+ root.setAttribute("data-finished", "true");
222
+ progress.textContent = labels.progress(deck.cards.length, deck.cards.length);
223
+ return;
224
+ }
225
+ front.textContent = current.front;
226
+ hint.textContent = current.hint ?? "";
227
+ hint.hidden = !current.hint;
228
+ back.textContent = shown ? current.back : "";
229
+ example.textContent = shown ? current.example ?? "" : "";
230
+ back.hidden = !shown;
231
+ example.hidden = !shown || !current.example;
232
+ reveal.hidden = shown;
233
+ grades.hidden = !shown;
234
+ card.setAttribute("data-shown", String(shown));
235
+ progress.textContent = labels.progress(index + (shown ? 1 : 0), deck.cards.length);
236
+ };
237
+ const show = () => {
238
+ if (shown) return;
239
+ shown = true;
240
+ draw();
241
+ grades.querySelector("button")?.focus();
242
+ };
243
+ const grade = (value) => {
244
+ const current = deck.cards[index];
245
+ if (!current || disposed) return;
246
+ counts[value] += 1;
247
+ if (deck.gradeAction) options.actionRuntime.dispatch({
248
+ type: deck.gradeAction,
249
+ cardType: deck.id,
250
+ cardId: current.id,
251
+ params: {
252
+ deckId: deck.id,
253
+ cardId: current.id,
254
+ grade: value
255
+ }
256
+ }, { owner: root }).catch((error) => {
257
+ if (error instanceof ActionRuntimeError) return;
258
+ throw error;
259
+ });
260
+ index += 1;
261
+ shown = false;
262
+ draw();
263
+ if (index < deck.cards.length) card.focus();
264
+ };
265
+ const onCardActivate = (event) => {
266
+ if (event instanceof KeyboardEvent) {
267
+ if (event.key !== "Enter" && event.key !== " ") return;
268
+ event.preventDefault();
269
+ }
270
+ show();
271
+ };
272
+ card.addEventListener("click", onCardActivate);
273
+ card.addEventListener("keydown", onCardActivate);
274
+ reveal.addEventListener("click", show);
275
+ const onGrade = (event) => {
276
+ const button = event.target.closest("[data-aigui-card-grade]");
277
+ if (!button) return;
278
+ grade(button.dataset.aiguiCardGrade);
279
+ };
280
+ grades.addEventListener("click", onGrade);
281
+ const onKey = (event) => {
282
+ if (!shown) return;
283
+ const byNumber = {
284
+ "1": "again",
285
+ "2": "hard",
286
+ "3": "good"
287
+ };
288
+ const chosen = byNumber[event.key];
289
+ if (!chosen) return;
290
+ event.preventDefault();
291
+ grade(chosen);
292
+ };
293
+ root.addEventListener("keydown", onKey);
294
+ draw();
295
+ return () => {
296
+ disposed = true;
297
+ card.removeEventListener("click", onCardActivate);
298
+ card.removeEventListener("keydown", onCardActivate);
299
+ reveal.removeEventListener("click", show);
300
+ grades.removeEventListener("click", onGrade);
301
+ root.removeEventListener("keydown", onKey);
302
+ };
303
+ }
304
+ function flashcardPromptSpec() {
305
+ return [
306
+ "Flashcards (fenced): ```flashcards <safe deck JSON>```.",
307
+ "Deck: {\"version\":1,\"id\":\"...\",\"reveal\":\"hidden|immediate\"?,\"gradeAction\":\"...\"?,\"title\":\"...\"?,\"cards\":[{\"id\":\"...\",\"front\":\"...\",\"back\":\"...\",\"hint\":\"...\"?,\"example\":\"...\"?}]}.",
308
+ "`hidden` (the default) shows one card at a time and asks the person to say how it went before showing the answer — use it to revise. `immediate` shows both sides of every card at once and grades nothing — use it when they are meeting these for the first time.",
309
+ "`front` is what is being asked and `back` is what it is checked against; a `hint` is shown with the front and must never give the answer away. `id` on each card is what a grade is reported against, so use the host's own ids.",
310
+ "gradeAction must name an application-registered Action. Never emit URLs, scripts, HTML or handlers."
311
+ ].join("\n");
312
+ }
313
+ const flashcardCss = `
314
+ [data-aigui-flashcards] { margin: 14px 0; padding: 14px; border: 1px solid #c8d6ce; border-radius: 12px; background: #f7f9f6; }
315
+ [data-aigui-deck-header] { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; font-size: 12px; color: #5d6f65; }
316
+ [data-aigui-card] { display: grid; gap: 6px; padding: 22px 16px; text-align: center; border: 1px solid #c4d1ca; border-radius: 10px; background: #fff; cursor: pointer; }
317
+ [data-aigui-card]:focus-visible { outline: 2px solid #2f6b4f; outline-offset: 2px; }
318
+ [data-aigui-card-front] { font-size: 20px; font-weight: 700; }
319
+ [data-aigui-card-hint] { font-size: 12px; color: #7b8b81; }
320
+ [data-aigui-card-back] { padding-top: 8px; border-top: 1px dashed #d3ded7; font-size: 16px; }
321
+ [data-aigui-card-example] { margin: 0; font-size: 13px; color: #55655c; }
322
+ [data-aigui-card-reveal] { width: 100%; margin-top: 10px; padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }
323
+ [data-aigui-card-grades] { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px; }
324
+ [data-aigui-card-grades] button { padding: 9px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; cursor: pointer; }
325
+ [data-aigui-card-grades] button[data-aigui-card-grade="again"] { color: #8a2b20; border-color: #e3b4ad; }
326
+ [data-aigui-card-grades] button[data-aigui-card-grade="good"] { color: #23543c; border-color: #a9c9b6; }
327
+ [data-aigui-card-list] { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
328
+ [data-aigui-card-list] li { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid #c4d1ca; border-radius: 9px; background: #fff; }
329
+ [data-aigui-deck-done] { margin: 10px 0 0; text-align: center; font-size: 13px; color: #23543c; }
330
+ `;
331
+ /**
332
+ * Cards to revise from.
333
+ *
334
+ * @example
335
+ * ```ts
336
+ * const plugin = flashcards({ actionRuntime })
337
+ * ```
338
+ */
339
+ function flashcards(options) {
340
+ if (!options?.actionRuntime) throw new TypeError("flashcards() requires an actionRuntime");
341
+ return {
342
+ name: "flashcards",
343
+ nodeRenderers: { flashcards: (node) => {
344
+ const parsed = parseFlashcards(node.content ?? "", options);
345
+ if (!parsed.valid) return {
346
+ kind: "html",
347
+ html: `<div data-aigui-flashcards-error>${escapeHtml(parsed.issues[0] ?? "Invalid deck.")}</div>`,
348
+ trusted: true
349
+ };
350
+ const deck = parsed.data;
351
+ if (deck.gradeAction && !options.actionRuntime.hasAction(deck.gradeAction)) return {
352
+ kind: "html",
353
+ html: `<div data-aigui-flashcards-error>${escapeHtml(`Unknown action: ${deck.gradeAction}`)}</div>`,
354
+ trusted: true
355
+ };
356
+ if (deck.reveal === "immediate") return {
357
+ kind: "html",
358
+ html: renderOpenDeck(deck),
359
+ trusted: true
360
+ };
361
+ return {
362
+ kind: "mount",
363
+ mount: (host) => mountDeck(host, deck, options)
364
+ };
365
+ } },
366
+ promptSpec: flashcardPromptSpec(),
367
+ css: flashcardCss
368
+ };
369
+ }
370
+
371
+ //#endregion
372
+ export { flashcardCss, flashcardPromptSpec, flashcards, parseFlashcards };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@ai-gui/plugin-flashcard",
3
+ "version": "0.18.0",
4
+ "description": "Flashcards to revise from: one card at a time, hidden answer, self-graded.",
5
+ "keywords": [
6
+ "llm",
7
+ "ai",
8
+ "flashcard",
9
+ "srs",
10
+ "revision",
11
+ "vocabulary",
12
+ "learning",
13
+ "plugin",
14
+ "aigui"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Liang Li <ll_faw@hotmail.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/liliang-cn/aigui.git",
21
+ "directory": "packages/plugin-flashcard"
22
+ },
23
+ "homepage": "https://github.com/liliang-cn/aigui#readme",
24
+ "bugs": "https://github.com/liliang-cn/aigui/issues",
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
43
+ }
44
+ },
45
+ "files": [
46
+ "dist",
47
+ "README.md",
48
+ "LICENSE",
49
+ "CHANGELOG.md"
50
+ ],
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "dependencies": {
55
+ "@ai-gui/core": "0.18.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsdown",
59
+ "test": "pnpm --dir ../.. exec vitest run --project plugin-flashcard",
60
+ "typecheck": "tsc --noEmit"
61
+ }
62
+ }