@panaversity/ksor 0.0.26 → 0.0.28

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.mjs +67 -4
  3. package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
  4. package/dist/gateway.mjs +1 -1
  5. package/package.json +1 -1
  6. package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
  7. package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
  8. package/templates/scaffold/AGENTS.md +49 -0
  9. package/templates/scaffold/Dockerfile +10 -6
  10. package/templates/scaffold/README.md +3 -2
  11. package/templates/scaffold/dockerignore +5 -0
  12. package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
  13. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
  14. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
  15. package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
  16. package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
  17. package/templates/scaffold/system/site/components/governance.tsx +17 -25
  18. package/templates/scaffold/system/site/components/record-views.tsx +241 -0
  19. package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
  20. package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
  21. package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
  22. package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
  23. package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
  24. package/templates/scaffold/system/site/lib/attachments.ts +105 -0
  25. package/templates/scaffold/system/site/lib/deck.ts +59 -0
  26. package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
  27. package/templates/scaffold/system/site/lib/srs.ts +219 -0
  28. package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
  29. package/templates/scaffold/system/site/source.config.ts +54 -1
  30. package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
@@ -0,0 +1,743 @@
1
+ "use client";
2
+
3
+ import {
4
+ Check,
5
+ ChevronLeft,
6
+ ClipboardCheck,
7
+ Copy,
8
+ ChevronRight,
9
+ Download,
10
+ Info,
11
+ RotateCcw,
12
+ Shuffle,
13
+ X,
14
+ } from "lucide-react";
15
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
16
+
17
+ import { StudyAidHeader } from "@/components/study-aids";
18
+ import { Button } from "@/components/ui/button";
19
+ import { Card, CardContent } from "@/components/ui/card";
20
+ import { Progress } from "@/components/ui/progress";
21
+ import type { DeckCard, DeckEntry } from "@/lib/attachments";
22
+ import {
23
+ SCHEDULER_POLICY,
24
+ dueOrder,
25
+ newCard,
26
+ progressPercent,
27
+ schedule,
28
+ type CardSchedule,
29
+ } from "@/lib/srs";
30
+
31
+ /**
32
+ * A document's recall deck: one large card at a time, flipped to reveal.
33
+ *
34
+ * At the END of the document, never behind a tab — a study aid is used AFTER
35
+ * reading, and a tab would hide the document while you used it. It shares the
36
+ * end-of-document region with the quiz that will sit beside it
37
+ * (components/study-aids.tsx).
38
+ *
39
+ * Two behaviours here are deliberately NOT what the predecessor shipped, and
40
+ * both were defects rather than choices:
41
+ *
42
+ * - **The schedule decides the order.** The predecessor computed a due queue
43
+ * and then rendered `deck.cards` directly, so its spaced repetition
44
+ * persisted state that influenced nothing a learner ever saw. Here
45
+ * `dueOrder` is what the session walks.
46
+ * - **The reset message is true.** The predecessor's toast said progress "was
47
+ * reset due to a deck update" and fired only from its JSON.parse catch —
48
+ * i.e. on storage corruption, the one cause it was never about. Here a card
49
+ * is identified by a hash of its own text, so an edited card resets ALONE
50
+ * and the notice says exactly how many and why.
51
+ */
52
+
53
+ const STORAGE_VERSION = 1;
54
+
55
+ interface Persisted {
56
+ readonly policy: string;
57
+ readonly version: number;
58
+ readonly cards: Record<string, CardSchedule>;
59
+ }
60
+
61
+ function storageKey(deckPath: string): string {
62
+ return `ksor:flashcards:${deckPath}`;
63
+ }
64
+
65
+ /**
66
+ * Read persisted review state, or null when there is none to read.
67
+ *
68
+ * Every failure path returns null rather than throwing: a browser with storage
69
+ * disabled, a private window, a corrupted value and a record written by a
70
+ * different scheduler are all "no history yet", which degrades this to an
71
+ * unscheduled walk through the deck instead of an error where a deck should be.
72
+ */
73
+ function readPersisted(deckPath: string): Persisted | null {
74
+ try {
75
+ const raw = window.localStorage.getItem(storageKey(deckPath));
76
+ if (raw === null) return null;
77
+ const parsed: unknown = JSON.parse(raw);
78
+ if (typeof parsed !== "object" || parsed === null) return null;
79
+ const record = parsed as Partial<Persisted>;
80
+ // A record written by a different policy is not migrated and not trusted —
81
+ // the numbers in it mean something else. Starting fresh is honest; silently
82
+ // reinterpreting another scheduler's stability as our ease is not.
83
+ if (record.policy !== SCHEDULER_POLICY || record.version !== STORAGE_VERSION) return null;
84
+ if (typeof record.cards !== "object" || record.cards === null) return null;
85
+ return record as Persisted;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function writePersisted(deckPath: string, cards: Record<string, CardSchedule>): void {
92
+ try {
93
+ window.localStorage.setItem(
94
+ storageKey(deckPath),
95
+ JSON.stringify({ policy: SCHEDULER_POLICY, version: STORAGE_VERSION, cards }),
96
+ );
97
+ } catch {
98
+ // Storage refused (quota, private mode, disabled). The session still works
99
+ // for as long as the page is open; it simply will not be remembered.
100
+ }
101
+ }
102
+
103
+ /** How long until a card is due again, in the coarsest honest unit. */
104
+ function untilDue(ms: number): string {
105
+ if (ms <= 0) return "now";
106
+ const minutes = Math.round(ms / 60_000);
107
+ if (minutes < 60) return `${Math.max(1, minutes)} min`;
108
+ const hours = Math.round(minutes / 60);
109
+ if (hours < 24) return `${hours} h`;
110
+ const days = Math.round(hours / 24);
111
+ return days === 1 ? "1 day" : `${days} days`;
112
+ }
113
+
114
+ export function Flashcards({ deck }: { deck: DeckEntry }): ReactElement {
115
+ // Server render and first client render must agree, so the deck starts in
116
+ // authored order with everything new; the persisted schedule is applied on
117
+ // mount. Reading localStorage during render is the classic hydration
118
+ // mismatch, and it would flash the wrong card.
119
+ const [schedules, setSchedules] = useState<Record<string, CardSchedule>>(() =>
120
+ Object.fromEntries(deck.cards.map((card) => [card.hash, newCard(card.hash, 0)])),
121
+ );
122
+ const [hydrated, setHydrated] = useState(false);
123
+ const [changed, setChanged] = useState(0);
124
+ const [index, setIndex] = useState(0);
125
+ const [revealed, setRevealed] = useState(false);
126
+ const [now, setNow] = useState(0);
127
+ const [reviewAll, setReviewAll] = useState(false);
128
+ const [shuffled, setShuffled] = useState<readonly string[] | null>(null);
129
+ const [guideOpen, setGuideOpen] = useState(false);
130
+ const [copied, setCopied] = useState(false);
131
+ /** Which way the deck last moved, so a card enters from the side it came. */
132
+ const [dir, setDir] = useState<1 | -1>(1);
133
+ const containerRef = useRef<HTMLDivElement>(null);
134
+ const cardRef = useRef<HTMLButtonElement>(null);
135
+ /**
136
+ * A changing card REMOUNTS, which is how its entrance animation restarts —
137
+ * and a remount drops focus to the body. The keyboard handler only fires
138
+ * while the deck holds focus, so without this the shortcuts died silently
139
+ * the first time a reader graded a card with the keyboard.
140
+ */
141
+ const restoreFocus = useRef(false);
142
+
143
+ useEffect(() => {
144
+ const at = Date.now();
145
+ const stored = readPersisted(deck.path);
146
+ const known = stored?.cards ?? {};
147
+ // A card is identified by its text. A hash present in the deck but not in
148
+ // storage is new OR edited — indistinguishable, and it does not matter:
149
+ // either way it has no history that belongs to this text.
150
+ const next: Record<string, CardSchedule> = {};
151
+ let fresh = 0;
152
+ for (const card of deck.cards) {
153
+ const existing = known[card.hash];
154
+ if (existing === undefined) {
155
+ next[card.hash] = newCard(card.hash, at);
156
+ if (stored !== null) fresh += 1;
157
+ } else {
158
+ next[card.hash] = existing;
159
+ }
160
+ }
161
+ setSchedules(next);
162
+ setChanged(fresh);
163
+ setNow(at);
164
+ setHydrated(true);
165
+ }, [deck.path, deck.cards]);
166
+
167
+ // Persist whatever the schedule currently is, once it has been hydrated.
168
+ // Before hydration `schedules` is the all-new placeholder the server and the
169
+ // first client render agree on, and writing THAT would erase real history.
170
+ useEffect(() => {
171
+ if (!hydrated) return;
172
+ writePersisted(deck.path, schedules);
173
+ }, [hydrated, schedules, deck.path]);
174
+
175
+ useEffect(() => {
176
+ if (!restoreFocus.current) return;
177
+ restoreFocus.current = false;
178
+ cardRef.current?.focus();
179
+ }, [index]);
180
+
181
+ /** What this session walks: due first, never-seen ahead of overdue. */
182
+ const queue = useMemo(() => {
183
+ const base =
184
+ !hydrated || reviewAll
185
+ ? deck.cards
186
+ : dueOrder(deck.cards, (c) => schedules[c.hash] ?? newCard(c.hash, now), now);
187
+ if (shuffled === null) return base;
188
+ const order = new Map(shuffled.map((hash, i) => [hash, i] as const));
189
+ return [...base].sort((a, b) => (order.get(a.hash) ?? 0) - (order.get(b.hash) ?? 0));
190
+ // `schedules` is deliberately absent: re-ordering the queue under the
191
+ // reader's hand as they grade would move the next card mid-session.
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
+ }, [hydrated, reviewAll, shuffled, deck.cards, now]);
194
+
195
+ const card: DeckCard | undefined = queue[index];
196
+ const done = hydrated && index >= queue.length;
197
+ const total = queue.length;
198
+
199
+ const grade = useCallback(
200
+ (rating: "again" | "good") => {
201
+ if (card === undefined) return;
202
+ restoreFocus.current = containerRef.current?.contains(document.activeElement) ?? false;
203
+ setDir(1);
204
+ const at = Date.now();
205
+ // The updater stays PURE — no write in here. React may invoke an updater
206
+ // more than once, and a side effect in one is a write that fires a
207
+ // number of times nobody controls. The persist runs in the effect above.
208
+ setSchedules((current) => {
209
+ const existing = current[card.hash] ?? newCard(card.hash, at);
210
+ return { ...current, [card.hash]: schedule(existing, rating, at) };
211
+ });
212
+ setRevealed(false);
213
+ setIndex((i) => i + 1);
214
+ },
215
+ [card],
216
+ );
217
+
218
+ /** Both sides of the card, for pasting somewhere the reader is working. */
219
+ const copyCard = useCallback((c: DeckCard) => {
220
+ void navigator.clipboard
221
+ ?.writeText(`${c.front}\n\n${c.back}`)
222
+ .then(() => {
223
+ setCopied(true);
224
+ window.setTimeout(() => setCopied(false), 1600);
225
+ })
226
+ .catch(() => {
227
+ // A clipboard the browser refuses is not worth an error where a deck
228
+ // should be; the card is on screen either way.
229
+ });
230
+ }, []);
231
+
232
+ const move = useCallback(
233
+ (delta: number) => {
234
+ restoreFocus.current = containerRef.current?.contains(document.activeElement) ?? false;
235
+ setDir(delta < 0 ? -1 : 1);
236
+ setRevealed(false);
237
+ setIndex((i) => Math.min(Math.max(0, i + delta), total));
238
+ },
239
+ [total],
240
+ );
241
+
242
+ const restart = useCallback(() => {
243
+ setIndex(0);
244
+ setRevealed(false);
245
+ setNow(Date.now());
246
+ setReviewAll(true);
247
+ }, []);
248
+
249
+ const doShuffle = useCallback(() => {
250
+ const hashes = deck.cards.map((c) => c.hash);
251
+ for (let i = hashes.length - 1; i > 0; i -= 1) {
252
+ const j = Math.floor(Math.random() * (i + 1));
253
+ const a = hashes[i] as string;
254
+ hashes[i] = hashes[j] as string;
255
+ hashes[j] = a;
256
+ }
257
+ setShuffled(hashes);
258
+ setIndex(0);
259
+ setRevealed(false);
260
+ setReviewAll(true);
261
+ }, [deck.cards]);
262
+
263
+ /**
264
+ * The deck as tab-separated front/back — the shape Anki and most other
265
+ * spaced-repetition tools import. Built in the browser from the deck already
266
+ * on the page: a second copy on disk would be a second thing to keep in step
267
+ * with the record.
268
+ */
269
+ const doDownload = useCallback(() => {
270
+ const tsv = deck.cards
271
+ .map((c) => `${c.front.replaceAll("\t", " ")}\t${c.back.replaceAll("\t", " ")}`)
272
+ .join("\n");
273
+ const url = URL.createObjectURL(new Blob([tsv], { type: "text/tab-separated-values" }));
274
+ const anchor = document.createElement("a");
275
+ anchor.href = url;
276
+ anchor.download = `${deck.path.replace(/\.flashcards\.yaml$/, "").replaceAll("/", "-")}.tsv`;
277
+ anchor.click();
278
+ URL.revokeObjectURL(url);
279
+ }, [deck]);
280
+
281
+ useEffect(() => {
282
+ const node = containerRef.current;
283
+ if (node === null) return;
284
+ const onKey = (event: KeyboardEvent) => {
285
+ // Only when the deck owns the focus: these are single-letter shortcuts,
286
+ // and stealing "1" from the search box would be a bug.
287
+ if (!node.contains(document.activeElement)) return;
288
+ if (event.key === " " || event.key === "Enter") {
289
+ if (card !== undefined) {
290
+ event.preventDefault();
291
+ setRevealed((r) => !r);
292
+ }
293
+ return;
294
+ }
295
+ if (event.key === "ArrowLeft") {
296
+ event.preventDefault();
297
+ move(-1);
298
+ return;
299
+ }
300
+ if (event.key === "ArrowRight") {
301
+ event.preventDefault();
302
+ move(1);
303
+ return;
304
+ }
305
+ if (!revealed) return;
306
+ if (event.key === "1") {
307
+ event.preventDefault();
308
+ grade("again");
309
+ } else if (event.key === "2") {
310
+ event.preventDefault();
311
+ grade("good");
312
+ }
313
+ };
314
+ window.addEventListener("keydown", onKey);
315
+ return () => window.removeEventListener("keydown", onKey);
316
+ }, [revealed, card, grade, move]);
317
+
318
+ const position = Math.min(index, Math.max(0, total - 1));
319
+ // One number for the bar and its caption, so they cannot disagree (lib/deck).
320
+ const progress = progressPercent(position, total);
321
+
322
+ return (
323
+ <div ref={containerRef}>
324
+ <StudyAidHeader title={deck.title} description={deck.description} />
325
+
326
+ {/* Honest, and only when there is something to be honest about. Counts
327
+ cards whose TEXT changed, which is the only thing that resets. */}
328
+ {changed > 0 ? (
329
+ <p
330
+ role="status"
331
+ className="mx-auto mb-6 max-w-2xl rounded-md border border-fd-border bg-fd-muted px-3 py-2 text-center font-mono text-xs text-fd-muted-foreground"
332
+ >
333
+ {changed === 1 ? "1 card has" : `${changed} cards have`} changed since you last reviewed
334
+ this deck — {changed === 1 ? "its" : "their"} progress starts again. The rest is
335
+ untouched.
336
+ </p>
337
+ ) : null}
338
+
339
+ {done || total === 0 ? (
340
+ <DeckDone deck={deck} schedules={schedules} reviewed={total} onRestart={restart} />
341
+ ) : card === undefined ? null : (
342
+ <>
343
+ {/* The movement controls sit OUTSIDE the card: the whole card is the
344
+ flip target, and a chevron inside it would compete for the click. */}
345
+ <div className="relative flex items-stretch justify-center gap-3 sm:gap-4">
346
+ <StepButton
347
+ onClick={() => move(-1)}
348
+ disabled={index === 0}
349
+ label="Previous card"
350
+ className="hidden sm:flex"
351
+ >
352
+ <ChevronLeft className="size-5" />
353
+ </StepButton>
354
+
355
+ {/* The flip is a real rotation about the card's vertical axis, so
356
+ the two faces are ONE object rather than two panels swapping. Both
357
+ are in the DOM the whole time — which is what keeps the answer in
358
+ the shipped HTML for an agent and for a failed bundle — and the
359
+ hidden one is taken out of the accessibility tree rather than left
360
+ for a screen reader to read out of turn. */}
361
+ {/* A distant vanishing point. At 1600px the near edge of a card
362
+ this wide swelled far enough mid-rotation to overflow the section
363
+ above it; at 3200px the turn still reads as depth without the
364
+ card lunging at the reader. */}
365
+ <div
366
+ className="relative w-full max-w-2xl"
367
+ style={{ perspective: "3200px", perspectiveOrigin: "50% 50%" }}
368
+ >
369
+ <button
370
+ key={card.hash}
371
+ ref={cardRef}
372
+ type="button"
373
+ onClick={() => setRevealed((r) => !r)}
374
+ aria-expanded={revealed}
375
+ aria-label={revealed ? "Hide the answer" : "Reveal the answer"}
376
+ // The focus ring is drawn on the FACES, not here: an outline on
377
+ // a preserve-3d element rotates with it and renders as a
378
+ // sheared rectangle mid-flip.
379
+ // Both literals are written out because Tailwind scans source
380
+ // for whole class names; a template string would generate
381
+ // neither. `motion-safe:` means a reader who asked for less
382
+ // motion gets the new card with no travel at all.
383
+ className={`group relative block w-full outline-none transition-transform duration-500 ease-out motion-reduce:duration-0 ${
384
+ dir === 1
385
+ ? "motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-right-6 motion-safe:duration-300"
386
+ : "motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-left-6 motion-safe:duration-300"
387
+ }`}
388
+ style={{
389
+ transformStyle: "preserve-3d",
390
+ transform: revealed ? "rotateY(180deg)" : "rotateY(0deg)",
391
+ }}
392
+ >
393
+ <CardFace
394
+ hidden={revealed}
395
+ text={card.front}
396
+ why={card.why}
397
+ hint="Click to flip"
398
+ onCopy={() => copyCard(card)}
399
+ copied={copied}
400
+ />
401
+ <CardFace
402
+ back
403
+ hidden={!revealed}
404
+ text={card.back}
405
+ hint="Click to flip back"
406
+ onCopy={() => copyCard(card)}
407
+ copied={copied}
408
+ />
409
+ </button>
410
+ </div>
411
+
412
+ <StepButton
413
+ onClick={() => move(1)}
414
+ disabled={index >= total - 1}
415
+ label="Next card"
416
+ className="hidden sm:flex"
417
+ >
418
+ <ChevronRight className="size-5" />
419
+ </StepButton>
420
+ </div>
421
+
422
+ {/* Below the card on a narrow screen. Flanking, the two controls took
423
+ 96px of a 375px viewport and left the card 263px — measured, and far
424
+ too narrow for the question to set well. */}
425
+ <div className="mt-2 flex justify-center gap-8 sm:hidden">
426
+ <StepButton onClick={() => move(-1)} disabled={index === 0} label="Previous card">
427
+ <ChevronLeft className="size-5" />
428
+ </StepButton>
429
+ <StepButton onClick={() => move(1)} disabled={index >= total - 1} label="Next card">
430
+ <ChevronRight className="size-5" />
431
+ </StepButton>
432
+ </div>
433
+
434
+ {/* Reserved height, so revealing an answer does not push the progress
435
+ bar and the action row down the page under the reader's cursor. */}
436
+ <div className="mx-auto mt-5 flex min-h-[2.75rem] max-w-2xl items-start justify-center gap-3">
437
+ {revealed ? (
438
+ <>
439
+ <GradeButton onClick={() => grade("again")} tone="again" hint="1">
440
+ <X className="size-4" /> Missed it
441
+ </GradeButton>
442
+ <GradeButton onClick={() => grade("good")} tone="good" hint="2">
443
+ <Check className="size-4" /> Got it
444
+ </GradeButton>
445
+ </>
446
+ ) : null}
447
+ </div>
448
+
449
+ <div className="mx-auto mt-7 flex max-w-2xl items-center gap-5">
450
+ <Progress
451
+ value={progress}
452
+ aria-label={`Card ${position + 1} of ${total}`}
453
+ className="h-1 bg-border/60"
454
+ />
455
+ <span className="shrink-0 font-mono text-[0.6875rem] tracking-wide text-muted-foreground">
456
+ {position + 1} / {total}
457
+ </span>
458
+ </div>
459
+ </>
460
+ )}
461
+
462
+ <div className="mt-10 border-t border-border/70 pt-6">
463
+ <div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
464
+ <FooterAction onClick={doShuffle} icon={<Shuffle className="size-3.5" />}>
465
+ Shuffle
466
+ </FooterAction>
467
+ <FooterAction
468
+ onClick={() => setGuideOpen((g) => !g)}
469
+ active={guideOpen}
470
+ expanded={guideOpen}
471
+ icon={<Info className="size-3.5" />}
472
+ >
473
+ Guide
474
+ </FooterAction>
475
+ <FooterAction onClick={doDownload} icon={<Download className="size-3.5" />}>
476
+ Download
477
+ </FooterAction>
478
+ </div>
479
+
480
+ {guideOpen ? (
481
+ <div className="mx-auto mt-5 max-w-2xl rounded-lg border border-fd-border bg-fd-muted px-5 py-4 text-sm leading-relaxed text-fd-muted-foreground">
482
+ <p>
483
+ Click the card, or press <Key>space</Key>, to flip it. Then say whether you recalled
484
+ it: <Key>1</Key> for missed, <Key>2</Key> for got it. <Key>&larr;</Key> and{" "}
485
+ <Key>&rarr;</Key> step between cards without grading.
486
+ </p>
487
+ <p className="mt-3">
488
+ Cards you miss come back within about a minute; cards you know return at growing
489
+ intervals, so a deck gets shorter as you learn it. The schedule is a simple interval
490
+ ladder — it is not FSRS and makes no retention guarantee. Progress is kept in this
491
+ browser only, so it belongs to you and to this device, and is not part of the record.
492
+ </p>
493
+ <p className="mt-3">
494
+ <strong className="font-medium text-fd-foreground">Download</strong> gives you the
495
+ deck as tab-separated front/back, the shape Anki and most other tools import.
496
+ </p>
497
+ </div>
498
+ ) : null}
499
+ </div>
500
+ </div>
501
+ );
502
+ }
503
+
504
+ /**
505
+ * One side of the card.
506
+ *
507
+ * Built to the reference the owner supplied: a tall, quiet card carrying the
508
+ * question in the interface's own sans, a copy action in the top-left corner,
509
+ * and the flip hint centred at the foot. Nothing else on the face — the tab and
510
+ * the catalogue number an earlier pass added were mine, not the brief's.
511
+ *
512
+ * The front sits in flow and therefore SETS the height; the back is absolutely
513
+ * positioned over it, pre-rotated so it reads the right way round once the card
514
+ * turns. Both hide their own backface, so only the side facing the reader is
515
+ * ever painted.
516
+ */
517
+ function CardFace({
518
+ back = false,
519
+ hidden,
520
+ text,
521
+ why,
522
+ hint,
523
+ onCopy,
524
+ copied,
525
+ }: {
526
+ readonly back?: boolean;
527
+ readonly hidden: boolean;
528
+ readonly text: string;
529
+ readonly why?: string | undefined;
530
+ readonly hint: string;
531
+ readonly onCopy: () => void;
532
+ readonly copied: boolean;
533
+ }): ReactElement {
534
+ return (
535
+ <Card
536
+ aria-hidden={hidden}
537
+ className={[
538
+ "min-h-[22rem] gap-0 rounded-xl py-0",
539
+ // The lift is gated on motion-safe rather than overridden under
540
+ // motion-reduce: a reader who asked for less motion gets no travel at
541
+ // all, instead of travel plus a rule trying to cancel it.
542
+ "transition-[border-color,transform] duration-200 group-hover:border-fd-primary/40 motion-safe:group-hover:-translate-y-0.5",
543
+ "group-focus-visible:border-fd-primary group-focus-visible:ring-2 group-focus-visible:ring-fd-primary/30",
544
+ back ? "absolute inset-0" : "relative",
545
+ ].join(" ")}
546
+ style={{
547
+ backfaceVisibility: "hidden",
548
+ WebkitBackfaceVisibility: "hidden",
549
+ ...(back ? { transform: "rotateY(180deg)" } : {}),
550
+ }}
551
+ >
552
+ {/* Nested inside the card's own button, so it stops the click that would
553
+ otherwise flip the card out from under the reader. */}
554
+ <span
555
+ role="button"
556
+ tabIndex={0}
557
+ aria-label={copied ? "Copied" : "Copy this card"}
558
+ onClick={(event) => {
559
+ event.stopPropagation();
560
+ onCopy();
561
+ }}
562
+ onKeyDown={(event) => {
563
+ if (event.key === "Enter" || event.key === " ") {
564
+ event.stopPropagation();
565
+ event.preventDefault();
566
+ onCopy();
567
+ }
568
+ }}
569
+ className="absolute left-5 top-5 rounded p-1 text-muted-foreground/40 transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-primary"
570
+ >
571
+ {copied ? <ClipboardCheck className="size-4" /> : <Copy className="size-4" />}
572
+ </span>
573
+
574
+ <CardContent className="flex flex-1 flex-col items-center justify-center px-8 py-12 text-center sm:px-12">
575
+ {/* Tracking tightened a hair and leading opened: at display sizes the
576
+ interface face sets loose, and the default 1.5 leading pulls a
577
+ two-line question apart. */}
578
+ <span className="mx-auto block max-w-[32rem] text-balance text-[1.35rem] font-medium leading-[1.4] tracking-[-0.011em] text-foreground sm:text-[1.5rem]">
579
+ {text}
580
+ </span>
581
+ {why === undefined ? null : (
582
+ <>
583
+ {/* A short rule rather than more space: the prompt is a different
584
+ KIND of sentence from the question, and a gap alone did not say
585
+ so. */}
586
+ <span aria-hidden className="mt-7 block h-px w-10 bg-border" />
587
+ <span className="mt-5 block max-w-sm text-[0.9375rem] leading-relaxed text-muted-foreground">
588
+ {why}
589
+ </span>
590
+ </>
591
+ )}
592
+ </CardContent>
593
+
594
+ <span className="absolute inset-x-0 bottom-6 text-[0.8125rem] tracking-wide text-muted-foreground/80">
595
+ {hint}
596
+ </span>
597
+ </Card>
598
+ );
599
+ }
600
+
601
+ function Key({ children }: { readonly children: React.ReactNode }): ReactElement {
602
+ return (
603
+ <kbd className="mx-0.5 rounded border border-fd-border bg-fd-background px-1.5 py-0.5 font-mono text-[11px] text-fd-foreground">
604
+ {children}
605
+ </kbd>
606
+ );
607
+ }
608
+
609
+ function StepButton({
610
+ onClick,
611
+ disabled,
612
+ label,
613
+ className = "",
614
+ children,
615
+ }: {
616
+ readonly onClick: () => void;
617
+ readonly disabled: boolean;
618
+ readonly label: string;
619
+ readonly className?: string;
620
+ readonly children: React.ReactNode;
621
+ }): ReactElement {
622
+ return (
623
+ <Button
624
+ type="button"
625
+ variant="outline"
626
+ onClick={onClick}
627
+ disabled={disabled}
628
+ aria-label={label}
629
+ className={`my-auto h-16 w-11 shrink-0 rounded-lg border-border/70 text-muted-foreground/70 transition-colors hover:text-foreground disabled:opacity-25 ${className}`}
630
+ >
631
+ {children}
632
+ </Button>
633
+ );
634
+ }
635
+
636
+ function GradeButton({
637
+ onClick,
638
+ tone,
639
+ hint,
640
+ children,
641
+ }: {
642
+ readonly onClick: () => void;
643
+ readonly tone: "again" | "good";
644
+ readonly hint: string;
645
+ readonly children: React.ReactNode;
646
+ }): ReactElement {
647
+ return (
648
+ <Button
649
+ type="button"
650
+ variant="outline"
651
+ onClick={onClick}
652
+ className={[
653
+ "h-11 flex-1",
654
+ // The accent means "the thing you probably want" and is spent once.
655
+ // Missed-it is deliberately NOT destructive: getting a card wrong is
656
+ // the mechanism working, not an error.
657
+ tone === "good"
658
+ ? "border-fd-primary/50 hover:bg-fd-primary/10"
659
+ : "text-muted-foreground hover:text-foreground",
660
+ ].join(" ")}
661
+ >
662
+ {children}
663
+ <kbd className="ml-1 font-mono text-[10px] opacity-60">{hint}</kbd>
664
+ </Button>
665
+ );
666
+ }
667
+
668
+ function FooterAction({
669
+ onClick,
670
+ icon,
671
+ active = false,
672
+ expanded,
673
+ children,
674
+ }: {
675
+ readonly onClick: () => void;
676
+ readonly icon: ReactElement;
677
+ readonly active?: boolean;
678
+ readonly expanded?: boolean | undefined;
679
+ readonly children: React.ReactNode;
680
+ }): ReactElement {
681
+ return (
682
+ <Button
683
+ type="button"
684
+ variant="ghost"
685
+ size="sm"
686
+ onClick={onClick}
687
+ {...(expanded === undefined ? {} : { "aria-expanded": expanded })}
688
+ className={active ? "text-fd-primary underline underline-offset-8" : "text-muted-foreground"}
689
+ >
690
+ {icon}
691
+ {children}
692
+ </Button>
693
+ );
694
+ }
695
+
696
+ function DeckDone({
697
+ deck,
698
+ schedules,
699
+ reviewed,
700
+ onRestart,
701
+ }: {
702
+ readonly deck: DeckEntry;
703
+ readonly schedules: Record<string, CardSchedule>;
704
+ readonly reviewed: number;
705
+ readonly onRestart: () => void;
706
+ }): ReactElement {
707
+ /**
708
+ * Measured from NOW, not from when the page was opened.
709
+ *
710
+ * The session's `now` is stamped at mount so the queue does not re-order
711
+ * under the reader's hand — correct for the queue, wrong for a countdown:
712
+ * every second spent reviewing was being added to "next card due in", so a
713
+ * card scheduled a minute out reported two (found live, three-card session).
714
+ * This component only ever mounts after hydration and after a review, so
715
+ * reading the clock here cannot mismatch the server.
716
+ */
717
+ const [at] = useState(() => Date.now());
718
+ const nextDue = deck.cards
719
+ .map((c) => schedules[c.hash]?.dueMs ?? at)
720
+ .reduce((soonest, due) => (due < soonest ? due : soonest), Number.POSITIVE_INFINITY);
721
+
722
+ return (
723
+ <Card className="mx-auto min-h-[22rem] max-w-2xl items-center justify-center px-8 py-12 text-center">
724
+ <p className="font-(family-name:--font-display) text-xl text-fd-foreground">
725
+ {reviewed === 0 ? "Nothing due right now." : "Session complete."}
726
+ </p>
727
+ <p className="mt-3 font-mono text-xs text-fd-muted-foreground">
728
+ {reviewed > 0 ? `${reviewed} reviewed · ` : ""}
729
+ next card due in {untilDue(nextDue - at)}
730
+ </p>
731
+ <Button
732
+ type="button"
733
+ variant="outline"
734
+ size="sm"
735
+ onClick={onRestart}
736
+ className="mt-6 font-mono text-xs text-muted-foreground"
737
+ >
738
+ <RotateCcw className="size-3.5" />
739
+ Review anyway
740
+ </Button>
741
+ </Card>
742
+ );
743
+ }