@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.
- package/CHANGELOG.md +84 -0
- package/dist/cli.mjs +48 -13
- package/dist/{gateway-api-8lNruq9e-CuohjtoK.mjs → gateway-api-CbFkHZiU-HvlJRjRB.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/docs/authorization.md +1 -1
- package/docs/deploying.md +71 -20
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +3 -1
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +3 -1
- package/templates/scaffold/AGENTS.md +56 -10
- package/templates/scaffold/README.md +3 -3
- package/templates/scaffold/env.example +24 -8
- package/templates/scaffold/knowledge/what-is-a-ksor.quiz.yaml +90 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +9 -2
- package/templates/scaffold/system/site/app/global.css +68 -0
- package/templates/scaffold/system/site/components/quiz.tsx +321 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +1 -1
- package/templates/scaffold/system/site/lib/attachment-rule.ts +7 -0
- package/templates/scaffold/system/site/lib/attachments.ts +41 -3
- package/templates/scaffold/system/site/lib/deck.ts +3 -12
- package/templates/scaffold/system/site/lib/identity.ts +55 -0
- package/templates/scaffold/system/site/lib/quiz-audit.ts +306 -0
- package/templates/scaffold/system/site/lib/quiz-round.ts +57 -0
- package/templates/scaffold/system/site/lib/quiz.ts +84 -0
- package/templates/scaffold/system/site/source.config.ts +16 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
}
|