@panaversity/ksor 0.0.28 → 0.0.30

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.
@@ -0,0 +1,90 @@
1
+ # A quiz for what-is-a-ksor.md.
2
+ #
3
+ # Every question and every answer states only what its parent document states —
4
+ # a quiz is a way of checking the record, never a second source. Ask your coding
5
+ # agent to write one from a document, and to check each answer back against it.
6
+ #
7
+ # Two habits worth copying. The options are kept close in LENGTH, because if the
8
+ # correct answer is reliably the longest one a reader passes by looking rather
9
+ # than by reading. And the correct answer moves POSITION between questions: a
10
+ # quiz whose answer is usually B is a quiz you can pass without the document.
11
+ # `pnpm check` and `pnpm build` both refuse a quiz where either is true — the
12
+ # first draft of this one was refused for putting four of five answers at B.
13
+ quiz:
14
+ title: Check yourself
15
+ description: Five questions on what this record is, and what it is not.
16
+ questions:
17
+ - question: A spreadsheet and the ledger disagree about a number. Which one is authoritative?
18
+ options:
19
+ - Whichever of the two was most recently edited
20
+ - The spreadsheet, being closer to the daily work
21
+ - The ledger, because it is the governing copy
22
+ - Neither, until a person reconciles the two
23
+ answer: 2
24
+ explanation: >
25
+ A system of record is the copy that governs, so the ledger wins by
26
+ definition rather than by being newer or closer to the work. Recency is
27
+ not authority: an edit made this morning in a spreadsheet nobody governs
28
+ is still ungoverned. Reconciliation is something a record makes possible,
29
+ not a precondition for having an answer at all.
30
+ source: The opening definition
31
+
32
+ - question: What does a KSoR settle that a traditional system of record does not?
33
+ options:
34
+ - The state of a business, such as its balances
35
+ - Which vendor an organization has chosen to use
36
+ - How much storage the organization is paying for
37
+ - What the organization knows and how it operates
38
+ answer: 3
39
+ explanation: >
40
+ A traditional system of record settles the state of a business — what is
41
+ owed, what is owned, what was transacted. A KSoR settles the layer above
42
+ it: which policies apply, which thresholds are approved, and what a term
43
+ means inside this organization. Vendor choice and storage cost are
44
+ consequences of running one, never what it is authoritative for.
45
+ source: What this record settles
46
+
47
+ - question: Why can an ordinary assistant not tell you which of its sentences were checked?
48
+ options:
49
+ - It answers from everything it has ever read
50
+ - It was not given enough context to work with
51
+ - Its answers are checked, but only in summary
52
+ - It lacks permission to reveal its own sources
53
+ answer: 0
54
+ explanation: >
55
+ It answers from everything it has ever read, and nothing in that process
56
+ separates a checked claim from an unchecked one. This is not a context
57
+ problem and not a permissions problem: more context cannot create a
58
+ distinction that was never recorded in the first place. The record closes
59
+ the gap by governing what gets read.
60
+ source: Why an assistant cannot answer that question
61
+
62
+ - question: What problem does a KSoR exist to solve?
63
+ options:
64
+ - Storage costs across a growing organization
65
+ - Scatter, with no authoritative copy to trust
66
+ - The speed at which an assistant can reply
67
+ - The difficulty of writing documentation well
68
+ answer: 1
69
+ explanation: >
70
+ The problem is scatter: knowledge spread across wikis, decks, PDFs,
71
+ prompts and somebody's memory, with no authoritative answer to which of
72
+ them an agent should trust. Speed and cost are not what the record
73
+ addresses, and writing well is valuable but does not by itself tell an
74
+ agent which of two documents governs.
75
+ source: The problem it solves
76
+
77
+ - question: Abstention — answering "not in this corpus" — is best described as what?
78
+ options:
79
+ - An error state the record should minimise
80
+ - A temporary gap, pending the next ingest
81
+ - A setting an operator turns on when ready
82
+ - A correct answer the record is meant to give
83
+ answer: 3
84
+ explanation: >
85
+ Abstention is a correct answer, never an error and never a licence to
86
+ fall back on general model knowledge. A record that answers everything
87
+ has stopped being a record of anything in particular. It is not a gap
88
+ waiting to be filled, and while the gate that enforces it is configured
89
+ deliberately, the honesty it expresses is the product itself.
90
+ source: What this record settles, and what it does not
@@ -17,8 +17,9 @@ import { showGovernance } from "@/lib/shared";
17
17
  import { RecordToc, TocItems } from "@/components/record-toc";
18
18
  import { RecordViews } from "@/components/record-views";
19
19
  import { Flashcards } from "@/components/flashcards";
20
+ import { Quiz } from "@/components/quiz";
20
21
  import { StudyAids } from "@/components/study-aids";
21
- import { deckFor, summaryFor } from "@/lib/attachments";
22
+ import { deckFor, quizFor, summaryFor } from "@/lib/attachments";
22
23
  import { readingMinutes } from "@/lib/reading-time";
23
24
 
24
25
  export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
@@ -32,6 +33,7 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
32
33
  const summary = summaryFor(page.path);
33
34
  const Summary = summary?.body ?? null;
34
35
  const deck = deckFor(page.path);
36
+ const quiz = quizFor(page.path);
35
37
  // Counted at BUILD time from the document's own markdown, so the figure is in
36
38
  // the shipped HTML for a reader with a failed bundle, a crawler and an agent
37
39
  // alike. The predecessor measured the rendered DOM after paint, which put it
@@ -157,7 +159,12 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
157
159
  region, so the quiz that will sit beside the deck is a child here and
158
160
  not a new argument about where it goes. Renders nothing at all when
159
161
  the document has no study aids. */}
160
- <StudyAids>{deck === null ? null : <Flashcards deck={deck} />}</StudyAids>
162
+ {/* Recall first, then the check on it — and each renders only if the
163
+ document carries one, so a page with just a quiz shows just a quiz. */}
164
+ <StudyAids>
165
+ {deck === null ? null : <Flashcards deck={deck} />}
166
+ {quiz === null ? null : <Quiz quiz={quiz} />}
167
+ </StudyAids>
161
168
  {/* A folder's index page lists what the folder holds. Without it the
162
169
  page ended at its own sentence and the documents below it were
163
170
  reachable only from the sidebar (research/site-design.md F5). Empty
@@ -274,6 +274,74 @@ html > body[data-scroll-locked] {
274
274
  --ksor-caution: #e0906e;
275
275
  }
276
276
 
277
+ /* Right and wrong, for the quiz — the ONE place this record spends green and
278
+ red, and it earns them: a reader checking an answer is asking a true/false
279
+ question, and the accent cannot say "this is correct" while also saying
280
+ "this is what you picked". Both are needed at once when you answer wrongly.
281
+
282
+ Colour is never the only channel: the correct option also carries a check,
283
+ the wrong one a cross, and the line above states the verdict in words — so
284
+ the pair works for a reader who cannot distinguish them. Declared as
285
+ light/dark pairs on the root for the reason `--ksor-caution` above records.
286
+
287
+ Deliberately NOT `--ksor-caution`, which means "the record withdrew this".
288
+ A wrong answer is not a governance state. */
289
+ :root {
290
+ --ksor-correct: #1a7f4b;
291
+ --ksor-incorrect: #b3261e;
292
+ }
293
+
294
+ .dark {
295
+ --ksor-correct: #6edaa0;
296
+ --ksor-incorrect: #f2897f;
297
+ }
298
+
299
+ /* The three states an answered question shows at once. Written as CLASSES,
300
+ the way `.ksor-caution` below is, rather than as Tailwind arbitrary values:
301
+ `border-[color:var(--x)]` emits a rule whose `var()` did not resolve on the
302
+ element in a real build (found live 2026-08-23 — the class was present, the
303
+ rule was in the stylesheet, the token computed correctly on the element, and
304
+ the border still painted the default hairline). A plain class has none of
305
+ that ambiguity, and this file is already where this record's semantic colour
306
+ lives. */
307
+ .ksor-answer-correct {
308
+ border-color: var(--ksor-correct);
309
+ background-color: color-mix(in oklab, var(--ksor-correct) 10%, transparent);
310
+ }
311
+
312
+ .ksor-answer-wrong {
313
+ border-color: var(--ksor-incorrect);
314
+ background-color: color-mix(in oklab, var(--ksor-incorrect) 10%, transparent);
315
+ }
316
+
317
+ /* "This is the one you picked" — kept in the accent, and kept SEPARATE from
318
+ right/wrong, because after a wrong answer a reader needs all three facts. */
319
+ .ksor-answer-mine {
320
+ outline: 2px solid var(--color-fd-primary);
321
+ outline-offset: 1px;
322
+ }
323
+
324
+ .ksor-answer-correct-text {
325
+ color: var(--ksor-correct);
326
+ }
327
+
328
+ .ksor-answer-wrong-text {
329
+ color: var(--ksor-incorrect);
330
+ }
331
+
332
+ .dark .ksor-answer-correct,
333
+ .dark .ksor-answer-wrong {
334
+ background-color: color-mix(in oklab, currentColor 0%, transparent);
335
+ }
336
+
337
+ .dark .ksor-answer-correct {
338
+ background-color: color-mix(in oklab, var(--ksor-correct) 14%, transparent);
339
+ }
340
+
341
+ .dark .ksor-answer-wrong {
342
+ background-color: color-mix(in oklab, var(--ksor-incorrect) 14%, transparent);
343
+ }
344
+
277
345
  .ksor-caution {
278
346
  border-color: color-mix(in oklab, var(--ksor-caution) 32%, transparent);
279
347
  border-left-color: var(--ksor-caution);
@@ -0,0 +1,321 @@
1
+ "use client";
2
+
3
+ import { Check, RotateCcw, X } from "lucide-react";
4
+ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
5
+
6
+ import { StudyAidHeader } from "@/components/study-aids";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Card, CardContent } from "@/components/ui/card";
9
+ import { Progress } from "@/components/ui/progress";
10
+ import type { QuizEntry, QuizQuestion } from "@/lib/attachments";
11
+ import { hasMoreRounds, roundOf } from "@/lib/quiz-round";
12
+
13
+ /**
14
+ * Taking a quiz on the document you just read.
15
+ *
16
+ * The interaction model is the predecessor's and is the good part of it:
17
+ * answer, see IMMEDIATELY whether you were right, and read the explanation
18
+ * before moving on. Its own usage guide is explicit that this teaches through
19
+ * the mistake, which has more effect than a score revealed at the end — so the
20
+ * explanation is the point of the component and the score is a footnote.
21
+ *
22
+ * What is deliberately absent is everything the predecessor's `GatedQuiz`
23
+ * wrapper added: a sign-in gate, an XP modal, and a POST of the score to a
24
+ * progress API. The site is a static export with no backend, and decision 7
25
+ * fixes it as preview and review rather than an editor. A score here is the
26
+ * reader's, stays in their browser, and is sent nowhere.
27
+ */
28
+
29
+ const STORAGE_VERSION = 1;
30
+
31
+ interface Persisted {
32
+ readonly version: number;
33
+ /** questionHash -> the option index this reader chose. */
34
+ readonly answers: Record<string, number>;
35
+ }
36
+
37
+ function storageKey(quizPath: string): string {
38
+ return `ksor:quiz:${quizPath}`;
39
+ }
40
+
41
+ function readPersisted(quizPath: string): Record<string, number> {
42
+ try {
43
+ const raw = window.localStorage.getItem(storageKey(quizPath));
44
+ if (raw === null) return {};
45
+ const record = JSON.parse(raw) as Persisted;
46
+ // A version bump discards rather than migrates: the only thing lost is
47
+ // which options a reader clicked, and guessing at an old shape is how a
48
+ // reader ends up with someone else's answers against their questions.
49
+ if (record.version !== STORAGE_VERSION) return {};
50
+ return record.answers ?? {};
51
+ } catch {
52
+ // A private window, cleared site data, or storage disabled entirely. An
53
+ // unanswered quiz is a correct starting state, so this is not an error.
54
+ return {};
55
+ }
56
+ }
57
+
58
+ function writePersisted(quizPath: string, answers: Record<string, number>): void {
59
+ try {
60
+ window.localStorage.setItem(
61
+ storageKey(quizPath),
62
+ JSON.stringify({ version: STORAGE_VERSION, answers } satisfies Persisted),
63
+ );
64
+ } catch {
65
+ // Storage is a convenience here, never the record. Losing it costs the
66
+ // reader their place and nothing else.
67
+ }
68
+ }
69
+
70
+ /** The letter an author and a reader both use for an option index. */
71
+ function letterOf(index: number): string {
72
+ return String.fromCharCode(65 + index);
73
+ }
74
+
75
+ export function Quiz({ quiz }: { quiz: QuizEntry }): ReactElement {
76
+ const size = quiz.questionsPerRound;
77
+ const banked = quiz.questions.length;
78
+ const canRedraw = hasMoreRounds(banked, size);
79
+
80
+ // Round zero is the authored order, chosen on the server AND the client so
81
+ // the first paint matches: `roundOf` returns the bank untouched when it fits
82
+ // in one round, and for a larger bank the shuffle happens only after mount
83
+ // (see the effect below). Sampling during render would differ between the
84
+ // server HTML and the first client render and would hydrate mismatched.
85
+ const [round, setRound] = useState<readonly QuizQuestion[]>(() =>
86
+ roundOf(quiz.questions, size, () => 0),
87
+ );
88
+ const [answers, setAnswers] = useState<Record<string, number>>({});
89
+ const [hydrated, setHydrated] = useState(false);
90
+ const [index, setIndex] = useState(0);
91
+ const [done, setDone] = useState(false);
92
+
93
+ useEffect(() => {
94
+ setAnswers(readPersisted(quiz.path));
95
+ if (canRedraw) setRound(roundOf(quiz.questions, size, Math.random));
96
+ setHydrated(true);
97
+ }, [quiz.path, quiz.questions, size, canRedraw]);
98
+
99
+ const current = round[index];
100
+ const chosen = current === undefined ? undefined : answers[current.hash];
101
+ const answered = chosen !== undefined;
102
+
103
+ const answeredCount = useMemo(
104
+ () => round.filter((q) => answers[q.hash] !== undefined).length,
105
+ [round, answers],
106
+ );
107
+ const correctCount = useMemo(
108
+ () => round.filter((q) => answers[q.hash] === q.answer).length,
109
+ [round, answers],
110
+ );
111
+
112
+ const choose = useCallback(
113
+ (option: number) => {
114
+ if (current === undefined || answers[current.hash] !== undefined) return;
115
+ const next = { ...answers, [current.hash]: option };
116
+ setAnswers(next);
117
+ writePersisted(quiz.path, next);
118
+ },
119
+ [answers, current, quiz.path],
120
+ );
121
+
122
+ const newRound = useCallback(() => {
123
+ setRound(roundOf(quiz.questions, size, Math.random));
124
+ setAnswers({});
125
+ writePersisted(quiz.path, {});
126
+ setIndex(0);
127
+ setDone(false);
128
+ }, [quiz.questions, size, quiz.path]);
129
+
130
+ const retry = useCallback(() => {
131
+ setAnswers({});
132
+ writePersisted(quiz.path, {});
133
+ setIndex(0);
134
+ setDone(false);
135
+ }, [quiz.path]);
136
+
137
+ if (current === undefined) return <></>;
138
+
139
+ if (done) {
140
+ return (
141
+ <section aria-label="Quiz results">
142
+ <StudyAidHeader title={quiz.title} description={quiz.description} />
143
+ <Card className="mx-auto max-w-2xl">
144
+ <CardContent className="flex flex-col items-center gap-6 py-12 text-center">
145
+ <p className="font-(family-name:--font-display) text-5xl font-semibold tabular-nums text-fd-foreground">
146
+ {correctCount}
147
+ <span className="text-fd-muted-foreground">/{round.length}</span>
148
+ </p>
149
+ <p className="max-w-sm text-sm text-fd-muted-foreground">
150
+ {/* No pass mark, deliberately: this checks understanding of a
151
+ document, it does not certify anybody. */}
152
+ Answers are kept in this browser only.
153
+ </p>
154
+ <div className="flex flex-wrap justify-center gap-3">
155
+ <Button variant="outline" onClick={() => setIndex(0)}>
156
+ Review answers
157
+ </Button>
158
+ {canRedraw ? (
159
+ <Button onClick={newRound}>
160
+ <RotateCcw aria-hidden className="size-4" />
161
+ Another round
162
+ </Button>
163
+ ) : (
164
+ <Button onClick={retry}>
165
+ <RotateCcw aria-hidden className="size-4" />
166
+ Start again
167
+ </Button>
168
+ )}
169
+ </div>
170
+ </CardContent>
171
+ </Card>
172
+ </section>
173
+ );
174
+ }
175
+
176
+ return (
177
+ <section aria-label="Quiz">
178
+ <StudyAidHeader title={quiz.title} description={quiz.description} />
179
+
180
+ <div className="mx-auto max-w-2xl">
181
+ <div className="mb-4 flex items-baseline justify-between gap-4">
182
+ <p className="font-mono text-xs tracking-wide text-fd-muted-foreground uppercase">
183
+ Question {index + 1} / {round.length}
184
+ {canRedraw ? <span> · drawn from {banked}</span> : null}
185
+ </p>
186
+ <p className="font-mono text-xs tabular-nums text-fd-muted-foreground">
187
+ {answeredCount} answered
188
+ </p>
189
+ </div>
190
+ <Progress value={(answeredCount / round.length) * 100} className="mb-8 h-1" />
191
+
192
+ <Card>
193
+ <CardContent className="flex flex-col gap-6 py-8">
194
+ <h3 className="text-lg leading-snug font-medium text-balance text-fd-foreground">
195
+ {current.question}
196
+ </h3>
197
+
198
+ <ul className="flex flex-col gap-2">
199
+ {current.options.map((option, i) => {
200
+ const isAnswer = i === current.answer;
201
+ const isChoice = i === chosen;
202
+ // Three things have to be legible at once after a wrong
203
+ // answer, and they are three different facts: which option is
204
+ // RIGHT (green), which one is WRONG (red), and which one YOU
205
+ // picked (the accent ring). The accent alone cannot carry two
206
+ // of those, which is what it was doing — the correct option
207
+ // wore the same colour as a selection, so choosing wrongly
208
+ // looked like the page had answered for you.
209
+ //
210
+ // Colour is never the only channel: the check and cross icons
211
+ // and the verdict line below say the same thing in shape and
212
+ // in words.
213
+ //
214
+ // Plain CSS classes, not Tailwind arbitrary values: a
215
+ // `border-[color:var(--x)]` utility did not paint in a real
216
+ // build even with the rule in the stylesheet and the token
217
+ // resolving on the element. `app/global.css` is where this
218
+ // record's semantic colour already lives.
219
+ const tone = !answered
220
+ ? "border-fd-border hover:border-fd-primary/60 hover:bg-fd-accent"
221
+ : isAnswer
222
+ ? "ksor-answer-correct"
223
+ : isChoice
224
+ ? "ksor-answer-wrong"
225
+ : "border-fd-border opacity-60";
226
+ // Your own pick keeps the accent, whether it was right or
227
+ // wrong, so "what I chose" is never in doubt.
228
+ const mine = answered && isChoice ? " ksor-answer-mine" : "";
229
+ return (
230
+ <li key={option}>
231
+ <button
232
+ type="button"
233
+ disabled={answered}
234
+ onClick={() => choose(i)}
235
+ aria-pressed={isChoice}
236
+ className={`flex w-full items-start gap-3 rounded-lg border px-4 py-3 text-left text-sm transition-colors motion-safe:duration-150 ${tone}${mine} ${answered ? "cursor-default" : "cursor-pointer"}`}
237
+ >
238
+ <span className="mt-px font-mono text-xs text-fd-muted-foreground">
239
+ {letterOf(i)}
240
+ </span>
241
+ <span className="flex-1 text-fd-foreground">{option}</span>
242
+ {answered && isAnswer ? (
243
+ <Check
244
+ aria-label="correct answer"
245
+ className="ksor-answer-correct-text size-4 shrink-0"
246
+ />
247
+ ) : null}
248
+ {answered && isChoice && !isAnswer ? (
249
+ <X
250
+ aria-label="your answer, which is wrong"
251
+ className="ksor-answer-wrong-text size-4 shrink-0"
252
+ />
253
+ ) : null}
254
+ </button>
255
+ </li>
256
+ );
257
+ })}
258
+ </ul>
259
+
260
+ {answered ? (
261
+ <div
262
+ // Announced, because the whole value of the immediate-feedback
263
+ // model is this text, and a reader using a screen reader gets
264
+ // it only if the region says it changed.
265
+ role="status"
266
+ className="motion-safe:animate-in motion-safe:fade-in flex flex-col gap-3 border-t border-fd-border pt-5 text-sm"
267
+ >
268
+ <p className="font-mono text-xs tracking-wide uppercase">
269
+ {chosen === current.answer ? (
270
+ <span className="ksor-answer-correct-text">Correct</span>
271
+ ) : (
272
+ <span className="ksor-answer-wrong-text">
273
+ Not quite — the answer is {letterOf(current.answer)}
274
+ </span>
275
+ )}
276
+ </p>
277
+ <p className="leading-relaxed text-fd-muted-foreground">{current.explanation}</p>
278
+ {current.source === undefined ? null : (
279
+ // "In the document", never "Source": a citation in this
280
+ // product carries a generation, and an attachment has no id
281
+ // to pin — calling this a source would sell provenance that
282
+ // is not here (spec §3).
283
+ <p className="font-mono text-xs text-fd-muted-foreground">
284
+ In the document: {current.source}
285
+ </p>
286
+ )}
287
+ </div>
288
+ ) : null}
289
+ </CardContent>
290
+ </Card>
291
+
292
+ <div className="mt-6 flex items-center justify-between gap-4">
293
+ <Button
294
+ variant="ghost"
295
+ disabled={index === 0}
296
+ onClick={() => setIndex((i) => Math.max(0, i - 1))}
297
+ >
298
+ Back
299
+ </Button>
300
+ {index === round.length - 1 ? (
301
+ <Button disabled={!answered} onClick={() => setDone(true)}>
302
+ Finish
303
+ </Button>
304
+ ) : (
305
+ <Button disabled={!answered} onClick={() => setIndex((i) => i + 1)}>
306
+ Next
307
+ </Button>
308
+ )}
309
+ </div>
310
+
311
+ {/* Rendered only once the reader's own answers are in, so the server
312
+ HTML never claims a state this reader is not in. */}
313
+ {hydrated && answeredCount > 0 && !done ? (
314
+ <p className="mt-4 text-center font-mono text-xs text-fd-muted-foreground">
315
+ {correctCount} of {answeredCount} correct so far
316
+ </p>
317
+ ) : null}
318
+ </div>
319
+ </section>
320
+ );
321
+ }
@@ -4,7 +4,7 @@ import type { ReactElement, ReactNode } from "react";
4
4
  * The end-of-document region: what a reader DOES with a document once they
5
5
  * have read it.
6
6
  *
7
- * The deck lives here, and the quiz will sit beside it. Deliberately not a tab:
7
+ * The deck and the quiz live here, in that order. Deliberately not a tab:
8
8
  * a study aid is used AFTER the document, and a tab would hide the document
9
9
  * while you used it. Deliberately one region rather than each aid finding its
10
10
  * own spot on the page, so a second aid is a child here and not a new layout
@@ -21,6 +21,7 @@ export const ATTACHMENT_SUFFIXES = [
21
21
  { suffix: ".summary.md", kind: "summary" },
22
22
  { suffix: ".summary.mdx", kind: "summary" },
23
23
  { suffix: ".flashcards.yaml", kind: "deck" },
24
+ { suffix: ".quiz.yaml", kind: "quiz" },
24
25
  ] as const;
25
26
 
26
27
  export type AttachmentKind = (typeof ATTACHMENT_SUFFIXES)[number]["kind"];
@@ -37,6 +38,8 @@ export const ATTACHMENT_NEAR_MISSES = [
37
38
  { suffix: ".flashcards.yml", want: ".flashcards.yaml" },
38
39
  { suffix: ".flashcards.json", want: ".flashcards.yaml" },
39
40
  { suffix: ".summary.markdown", want: ".summary.md" },
41
+ { suffix: ".quiz.yml", want: ".quiz.yaml" },
42
+ { suffix: ".quiz.json", want: ".quiz.yaml" },
40
43
  ] as const;
41
44
 
42
45
  /**
@@ -102,6 +105,7 @@ export function nearMissOf(
102
105
  export const ATTACHMENT_CASES = [
103
106
  { name: "returns.summary.md", kind: "summary", parent: "returns.md" },
104
107
  { name: "returns.flashcards.yaml", kind: "deck", parent: "returns.md" },
108
+ { name: "returns.quiz.yaml", kind: "quiz", parent: "returns.md" },
105
109
  { name: "index.summary.md", kind: "summary", parent: "index.md" },
106
110
  // A stem containing dots keeps every one of them: the parent is the same
107
111
  // name with the attachment suffix removed, never "up to the first dot".
@@ -110,15 +114,18 @@ export const ATTACHMENT_CASES = [
110
114
  { name: "returns.md", kind: null, parent: null },
111
115
  { name: "summary.md", kind: null, parent: null },
112
116
  { name: "flashcards.yaml", kind: null, parent: null },
117
+ { name: "quiz.yaml", kind: null, parent: null },
113
118
  { name: "my-summary.md", kind: null, parent: null },
114
119
  // A dotfile with no stem attaches to nothing — refused as an attachment so
115
120
  // it is refused as an unexpected file instead, which is the honest error.
116
121
  { name: ".summary.md", kind: null, parent: null },
117
122
  { name: ".flashcards.yaml", kind: null, parent: null },
123
+ { name: ".quiz.yaml", kind: null, parent: null },
118
124
  // Case matters: the record already refuses two names differing only in case,
119
125
  // so an uppercase suffix is a different file, not the same rule.
120
126
  { name: "returns.SUMMARY.md", kind: null, parent: null },
121
127
  // Not attachments — near misses, which get their own refusal.
122
128
  { name: "returns.flashcards.yml", kind: null, parent: null },
123
129
  { name: "returns.flashcards.json", kind: null, parent: null },
130
+ { name: "returns.quiz.yml", kind: null, parent: null },
124
131
  ] as const;
@@ -1,8 +1,11 @@
1
- import { decks, summaries } from "collections/server";
1
+ import { decks, quizzes, summaries } from "collections/server";
2
2
 
3
3
  import { ATTACHMENT_SUFFIXES } from "./attachment-rule";
4
4
  import { cardHash, type Card, type Deck } from "./deck";
5
5
  import { newCard, type CardSchedule } from "./srs";
6
+ import { type Question, type Quiz } from "./quiz";
7
+ import { DEFAULT_QUESTIONS_PER_ROUND } from "./quiz-round";
8
+ import { questionHash } from "./identity";
6
9
 
7
10
  /**
8
11
  * Finding a document's study attachments.
@@ -83,9 +86,44 @@ export function deckFor(documentPath: string): DeckEntry | null {
83
86
  };
84
87
  }
85
88
 
86
- /** True when a document has either attachment the presence gate for the UI. */
89
+ /** One question as the quiz UI consumes it: authored text plus its identity. */
90
+ export interface QuizQuestion extends Question {
91
+ /** Identity: a hash of the question's own text, so an edit resets only this one. */
92
+ readonly hash: string;
93
+ }
94
+
95
+ export interface QuizEntry {
96
+ readonly title: string;
97
+ readonly description?: string;
98
+ readonly questionsPerRound: number;
99
+ readonly questions: readonly QuizQuestion[];
100
+ /** The record-relative path — the quiz's identity, used to key saved answers. */
101
+ readonly path: string;
102
+ }
103
+
104
+ /** The quiz for a document, or null when it has none. */
105
+ export function quizFor(documentPath: string): QuizEntry | null {
106
+ const wanted = attachmentPath(documentPath, ".quiz.yaml");
107
+ const hit = quizzes.find((entry) => entry.info.path === wanted);
108
+ if (hit === undefined) return null;
109
+
110
+ const parsed = hit as unknown as Quiz & { readonly info: { readonly path: string } };
111
+ return {
112
+ title: parsed.quiz.title,
113
+ description: parsed.quiz.description,
114
+ questionsPerRound: parsed.quiz.questionsPerRound ?? DEFAULT_QUESTIONS_PER_ROUND,
115
+ path: parsed.info.path,
116
+ questions: parsed.questions.map((q) => ({ ...q, hash: questionHash(q) })),
117
+ };
118
+ }
119
+
120
+ /** True when a document has ANY attachment — the presence gate for the UI. */
87
121
  export function hasAttachments(documentPath: string): boolean {
88
- return summaryFor(documentPath) !== null || deckFor(documentPath) !== null;
122
+ return (
123
+ summaryFor(documentPath) !== null ||
124
+ deckFor(documentPath) !== null ||
125
+ quizFor(documentPath) !== null
126
+ );
89
127
  }
90
128
 
91
129
  /**
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import { textHash } from "./identity";
4
+
3
5
  /**
4
6
  * The shape of a `<doc>.flashcards.yaml` deck.
5
7
  *
@@ -44,16 +46,5 @@ export type Card = z.infer<typeof CardSchema>;
44
46
  * and rewording it should not throw away a learner's history with the card.
45
47
  */
46
48
  export function cardHash(card: Card): string {
47
- // NUL as the separator, written as an escape rather than embedded: a raw
48
- // NUL in the source makes git treat this file as binary. It has to be a
49
- // separator of some kind — without one, front "ab"/back "c" hashes the same
50
- // as front "a"/back "bc" — and NUL is the one character authored card text
51
- // cannot contain.
52
- const text = `${card.front}\u0000${card.back}`;
53
- let hash = 0x811c9dc5;
54
- for (let i = 0; i < text.length; i += 1) {
55
- hash ^= text.charCodeAt(i);
56
- hash = Math.imul(hash, 0x01000193) >>> 0;
57
- }
58
- return hash.toString(16).padStart(8, "0");
49
+ return textHash([card.front, card.back]);
59
50
  }