@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.
- package/CHANGELOG.md +65 -0
- package/dist/cli.mjs +67 -4
- package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/AGENTS.md +49 -0
- package/templates/scaffold/Dockerfile +10 -6
- package/templates/scaffold/README.md +3 -2
- package/templates/scaffold/dockerignore +5 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
- package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
- package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
- package/templates/scaffold/system/site/components/governance.tsx +17 -25
- package/templates/scaffold/system/site/components/record-views.tsx +241 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
- package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
- package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
- package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
- package/templates/scaffold/system/site/lib/attachments.ts +105 -0
- package/templates/scaffold/system/site/lib/deck.ts +59 -0
- package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
- package/templates/scaffold/system/site/lib/srs.ts +219 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
- package/templates/scaffold/system/site/source.config.ts +54 -1
- 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>←</Key> and{" "}
|
|
485
|
+
<Key>→</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
|
+
}
|