@panaversity/ksor 0.0.29 → 0.0.31
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 +108 -0
- package/README.md +24 -0
- package/dist/cli.mjs +11 -3
- package/dist/{gateway-api-8lNruq9e-CuohjtoK.mjs → gateway-api-6nC9x54K-BWFTI_6U.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +11 -1
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +160 -0
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +11 -1
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +160 -0
- package/templates/scaffold/AGENTS.md +91 -5
- package/templates/scaffold/README.md +24 -1
- package/templates/scaffold/knowledge/what-is-a-ksor.quiz.yaml +90 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.slides.yaml +65 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +17 -2
- package/templates/scaffold/system/site/app/global.css +113 -0
- package/templates/scaffold/system/site/components/deck-viewer.tsx +195 -0
- package/templates/scaffold/system/site/components/quiz.tsx +321 -0
- package/templates/scaffold/system/site/components/slides.tsx +128 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +1 -1
- package/templates/scaffold/system/site/lib/attachment-rule.ts +14 -0
- package/templates/scaffold/system/site/lib/attachments.ts +88 -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/lib/slides-embed.ts +93 -0
- package/templates/scaffold/system/site/lib/slides.ts +123 -0
- package/templates/scaffold/system/site/source.config.ts +25 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
|
|
4
|
+
import { useCallback, useRef, useState, type ReactElement } from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import type { DeckSlide } from "@/lib/attachments";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A presentation the RECORD owns, rendered in the page.
|
|
11
|
+
*
|
|
12
|
+
* This is the mode that makes the workflow complete. An agent writes the
|
|
13
|
+
* slides from the document — no browser, no third party, no human step in the
|
|
14
|
+
* middle — and this draws them. Which means the deck is governed like every
|
|
15
|
+
* other attachment: reviewed in a PR, versioned with its document, withdrawn
|
|
16
|
+
* with it. An embedded deck is none of those things, and can rot into a dead
|
|
17
|
+
* link with nothing going red.
|
|
18
|
+
*
|
|
19
|
+
* Every slide is in the SERVER-RENDERED HTML, not fetched and not built on
|
|
20
|
+
* mount: a crawler, a reader with JavaScript off, and an agent parsing the page
|
|
21
|
+
* all get the whole deck. Only the *navigation* is client-side, so what the
|
|
22
|
+
* bytes carry never depends on a script running.
|
|
23
|
+
*
|
|
24
|
+
* The stage is dark in both themes (`.ksor-stage`, app/global.css). A slide is
|
|
25
|
+
* a PROJECTION and the page around it is a document; looking like the first
|
|
26
|
+
* thing while sitting inside the second is most of what makes a deck legible
|
|
27
|
+
* at a glance. The first version painted it `--muted` and it read as an empty
|
|
28
|
+
* placeholder — pale grey on a pale page, saying nothing.
|
|
29
|
+
*/
|
|
30
|
+
export function DeckViewer({
|
|
31
|
+
slides,
|
|
32
|
+
title,
|
|
33
|
+
}: {
|
|
34
|
+
readonly slides: readonly DeckSlide[];
|
|
35
|
+
readonly title: string;
|
|
36
|
+
}): ReactElement {
|
|
37
|
+
const [index, setIndex] = useState(0);
|
|
38
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const total = slides.length;
|
|
40
|
+
|
|
41
|
+
const go = useCallback(
|
|
42
|
+
(delta: number) => setIndex((i) => Math.min(total - 1, Math.max(0, i + delta))),
|
|
43
|
+
[total],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Arrow keys, but ONLY while the deck has focus — a page-wide listener would
|
|
47
|
+
// hijack arrows from the reader scrolling the document.
|
|
48
|
+
const onKeyDown = useCallback(
|
|
49
|
+
(event: React.KeyboardEvent) => {
|
|
50
|
+
if (event.key === "ArrowRight" || event.key === "PageDown") {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
go(1);
|
|
53
|
+
} else if (event.key === "ArrowLeft" || event.key === "PageUp") {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
go(-1);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
[go],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const present = useCallback(() => {
|
|
62
|
+
void frameRef.current?.requestFullscreen?.().catch(() => {
|
|
63
|
+
// Fullscreen is a nicety and is refused in plenty of ordinary contexts —
|
|
64
|
+
// an iframe without the permission, a browser that requires a gesture it
|
|
65
|
+
// did not see. The deck stays usable inline, so this is not an error.
|
|
66
|
+
});
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const current = slides[index];
|
|
70
|
+
if (current === undefined) return <></>;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex flex-col gap-3">
|
|
74
|
+
<div
|
|
75
|
+
ref={frameRef}
|
|
76
|
+
tabIndex={0}
|
|
77
|
+
role="group"
|
|
78
|
+
aria-roledescription="presentation"
|
|
79
|
+
aria-label={`${title}, slide ${index + 1} of ${total}`}
|
|
80
|
+
onKeyDown={onKeyDown}
|
|
81
|
+
className="ksor-stage relative aspect-video w-full overflow-hidden rounded-xl border border-[var(--ksor-stage-rule)] shadow-[0_1px_2px_rgba(0,0,0,0.08),0_12px_28px_-12px_rgba(0,0,0,0.35)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
82
|
+
>
|
|
83
|
+
{/* The deck's own rule across the top: a slide theme in one line, and
|
|
84
|
+
the thing that stops the stage reading as a plain dark rectangle. */}
|
|
85
|
+
<div aria-hidden className="absolute inset-x-0 top-0 h-[3px] bg-fd-primary" />
|
|
86
|
+
|
|
87
|
+
{slides.map((slide, i) => (
|
|
88
|
+
<article
|
|
89
|
+
key={slide.heading + String(i)}
|
|
90
|
+
// Every slide is rendered; the inactive ones are hidden rather
|
|
91
|
+
// than absent, so the whole deck is in the shipped HTML.
|
|
92
|
+
hidden={i !== index}
|
|
93
|
+
aria-hidden={i !== index}
|
|
94
|
+
className="absolute inset-0 flex flex-col justify-center gap-6 px-[8%] pt-[8%] pb-[13%]"
|
|
95
|
+
>
|
|
96
|
+
<h3 className="font-(family-name:--font-display) text-[clamp(1.35rem,3.1vw,2.1rem)] leading-[1.15] font-semibold tracking-tight text-balance">
|
|
97
|
+
{slide.heading}
|
|
98
|
+
</h3>
|
|
99
|
+
{slide.lead === undefined ? null : (
|
|
100
|
+
<p className="ksor-stage-dim max-w-[42ch] text-[clamp(0.9rem,1.5vw,1.1rem)] leading-relaxed">
|
|
101
|
+
{slide.lead}
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
{slide.bullets === undefined || slide.bullets.length === 0 ? null : (
|
|
105
|
+
<ul className="flex max-w-[48ch] flex-col gap-3">
|
|
106
|
+
{slide.bullets.map((bullet, k) => (
|
|
107
|
+
<li
|
|
108
|
+
key={`${bullet}-${k}`}
|
|
109
|
+
className="flex gap-3 text-[clamp(0.85rem,1.4vw,1rem)] leading-snug"
|
|
110
|
+
>
|
|
111
|
+
{/* A square tick in the accent rather than a disc: it reads
|
|
112
|
+
at projection distance, where a bullet dot disappears. */}
|
|
113
|
+
<span
|
|
114
|
+
aria-hidden
|
|
115
|
+
className="mt-[0.45em] size-[0.42em] shrink-0 rounded-[1px] bg-fd-primary"
|
|
116
|
+
/>
|
|
117
|
+
<span>{bullet}</span>
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
)}
|
|
122
|
+
</article>
|
|
123
|
+
))}
|
|
124
|
+
|
|
125
|
+
{/* The stage's own footer: the deck's name and the position, in mono,
|
|
126
|
+
the way a real deck carries its identity on every slide. */}
|
|
127
|
+
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-4 px-[8%] pb-[4%]">
|
|
128
|
+
<p className="ksor-stage-dim truncate font-mono text-[0.68rem] tracking-wide uppercase">
|
|
129
|
+
{title}
|
|
130
|
+
</p>
|
|
131
|
+
<p className="ksor-stage-dim shrink-0 font-mono text-[0.68rem] tabular-nums">
|
|
132
|
+
{index + 1} / {total}
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* How far through the deck, drawn on the stage itself so it survives
|
|
137
|
+
fullscreen — where the controls below are not on screen at all. */}
|
|
138
|
+
<div aria-hidden className="ksor-stage-rule absolute inset-x-0 bottom-0 h-[2px]">
|
|
139
|
+
<div
|
|
140
|
+
className="h-full bg-fd-primary transition-[width] duration-200 motion-reduce:transition-none"
|
|
141
|
+
style={{ width: `${((index + 1) / total) * 100}%` }}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div className="flex items-center justify-between gap-3">
|
|
147
|
+
<div className="flex items-center gap-1">
|
|
148
|
+
<Button variant="ghost" size="sm" onClick={() => go(-1)} disabled={index === 0}>
|
|
149
|
+
<ChevronLeft aria-hidden className="size-4" />
|
|
150
|
+
<span className="sr-only sm:not-sr-only">Back</span>
|
|
151
|
+
</Button>
|
|
152
|
+
<Button variant="ghost" size="sm" onClick={() => go(1)} disabled={index === total - 1}>
|
|
153
|
+
<span className="sr-only sm:not-sr-only">Next</span>
|
|
154
|
+
<ChevronRight aria-hidden className="size-4" />
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Jump to any slide. Dots rather than a list, because a deck this
|
|
159
|
+
size is scanned rather than read, and they double as the shape of
|
|
160
|
+
how much is left. */}
|
|
161
|
+
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
|
162
|
+
{slides.map((slide, i) => (
|
|
163
|
+
<button
|
|
164
|
+
key={`dot-${slide.heading}-${i}`}
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={() => setIndex(i)}
|
|
167
|
+
aria-label={`Slide ${i + 1}: ${slide.heading}`}
|
|
168
|
+
aria-current={i === index ? "true" : undefined}
|
|
169
|
+
className={`h-1.5 rounded-full transition-all motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring ${
|
|
170
|
+
i === index
|
|
171
|
+
? "w-5 bg-fd-primary"
|
|
172
|
+
: "w-1.5 bg-fd-border hover:bg-fd-muted-foreground"
|
|
173
|
+
}`}
|
|
174
|
+
/>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<Button variant="ghost" size="sm" onClick={present}>
|
|
179
|
+
<Maximize2 aria-hidden className="size-3.5" />
|
|
180
|
+
<span className="font-mono text-xs tracking-wide uppercase">Present</span>
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{current.note === undefined ? null : (
|
|
185
|
+
// The presenter's note: what to SAY, never what the slide shows. Kept
|
|
186
|
+
// outside the stage so it is not projected when the deck is
|
|
187
|
+
// fullscreened, which is the whole point of a note.
|
|
188
|
+
<p className="border-s-2 border-fd-border ps-3 text-sm text-fd-muted-foreground">
|
|
189
|
+
<span className="font-mono text-xs tracking-wide uppercase">Say: </span>
|
|
190
|
+
{current.note}
|
|
191
|
+
</p>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, Presentation } from "lucide-react";
|
|
4
|
+
import { useState, type ReactElement } from "react";
|
|
5
|
+
|
|
6
|
+
import { DeckViewer } from "@/components/deck-viewer";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import type { SlidesEntry } from "@/lib/attachments";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The presentation that teaches this document.
|
|
12
|
+
*
|
|
13
|
+
* The predecessor embeds the deck directly — an always-on `<iframe>` to Google
|
|
14
|
+
* Slides, authored as raw JSX in the lesson's MDX. Two things stop that here,
|
|
15
|
+
* and the second one changed the design rather than just the authoring:
|
|
16
|
+
*
|
|
17
|
+
* 1. `knowledge/` is CommonMark (critical rule 2), so the frame cannot be
|
|
18
|
+
* authored in the document. It is an attachment instead.
|
|
19
|
+
*
|
|
20
|
+
* 2. The scaffold's browser test asserts **zero external requests** on a
|
|
21
|
+
* built page. An always-on frame breaks that on every page carrying a
|
|
22
|
+
* deck — and the guarantee is worth keeping, because it is what makes the
|
|
23
|
+
* site work offline, behind a firewall, and without telling a third party
|
|
24
|
+
* which of your policies someone is reading.
|
|
25
|
+
*
|
|
26
|
+
* So the frame is CLICK-TO-LOAD. Nothing reaches the provider until a reader
|
|
27
|
+
* asks for it: the page ships a placeholder, and the `<iframe>` is created on
|
|
28
|
+
* click. The link out is always available and costs nothing, because a plain
|
|
29
|
+
* `<a>` is not a request.
|
|
30
|
+
*
|
|
31
|
+
* That is a real divergence from the predecessor and it is an improvement
|
|
32
|
+
* rather than a compromise — the reader who only wanted the policy never
|
|
33
|
+
* announces themselves to a slide host.
|
|
34
|
+
*/
|
|
35
|
+
export function Slides({ slides }: { slides: SlidesEntry }): ReactElement {
|
|
36
|
+
const [loaded, setLoaded] = useState(false);
|
|
37
|
+
const provider = slides.provider ?? slides.derivedProvider;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<section aria-label="Teaching aid" className="not-prose mt-8 mb-12">
|
|
41
|
+
{/* A section heading, in the record's own language for one.
|
|
42
|
+
|
|
43
|
+
An earlier version dropped the accent bar and greyed the label, on
|
|
44
|
+
the theory that anything stronger would compete with the document
|
|
45
|
+
title directly above. That went too far: with no marker and no colour
|
|
46
|
+
the block read as loose text rather than as a section (owner, seen
|
|
47
|
+
live). The fix is the established marker at a smaller SIZE, not a
|
|
48
|
+
weaker one — the label carries the accent so it reads as a marker,
|
|
49
|
+
and the title sits one step below the document's. */}
|
|
50
|
+
<header className="mb-6">
|
|
51
|
+
<p className="font-mono text-xs font-medium tracking-[0.12em] text-fd-primary uppercase">
|
|
52
|
+
Teaching aid
|
|
53
|
+
</p>
|
|
54
|
+
<h2 className="mt-2 font-(family-name:--font-display) text-2xl font-semibold tracking-tight text-fd-foreground">
|
|
55
|
+
{slides.title}
|
|
56
|
+
</h2>
|
|
57
|
+
{/* The record's own marker for "a new region starts here": a short
|
|
58
|
+
accent bar riding a full-width hairline. Every study-aid header
|
|
59
|
+
uses it, so a reader has met it before. */}
|
|
60
|
+
<div className="mt-3 h-px w-full bg-fd-border">
|
|
61
|
+
<div className="h-[3px] w-24 -translate-y-px bg-fd-primary" />
|
|
62
|
+
</div>
|
|
63
|
+
{slides.description === undefined ? null : (
|
|
64
|
+
<p className="mt-4 text-sm text-fd-muted-foreground">{slides.description}</p>
|
|
65
|
+
)}
|
|
66
|
+
</header>
|
|
67
|
+
|
|
68
|
+
<div className="flex flex-col gap-4">
|
|
69
|
+
{/* A deck the record owns needs no link and no permission: it IS the
|
|
70
|
+
presentation. The linked mode below is for an adopter who already
|
|
71
|
+
has one somewhere else. */}
|
|
72
|
+
{slides.deck !== undefined && slides.deck.length > 0 ? (
|
|
73
|
+
<DeckViewer slides={slides.deck} title={slides.title} />
|
|
74
|
+
) : null}
|
|
75
|
+
|
|
76
|
+
{slides.url === undefined ? null : (
|
|
77
|
+
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
78
|
+
<a
|
|
79
|
+
href={slides.url}
|
|
80
|
+
target="_blank"
|
|
81
|
+
rel="noreferrer"
|
|
82
|
+
className="inline-flex items-center gap-1.5 text-fd-primary underline underline-offset-4 transition-colors hover:text-fd-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
83
|
+
>
|
|
84
|
+
Open the full presentation
|
|
85
|
+
<ExternalLink aria-hidden className="size-3.5" />
|
|
86
|
+
</a>
|
|
87
|
+
{provider === undefined ? null : (
|
|
88
|
+
<span className="font-mono text-xs text-fd-muted-foreground">{provider}</span>
|
|
89
|
+
)}
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{slides.embed === undefined ? null : (
|
|
94
|
+
<div
|
|
95
|
+
// 16:9, the aspect every deck host serves. A ratio box rather than
|
|
96
|
+
// a fixed height, so the frame scales with the measure instead of
|
|
97
|
+
// letterboxing on a narrow window.
|
|
98
|
+
className="relative w-full overflow-hidden rounded-lg border border-fd-border bg-fd-muted"
|
|
99
|
+
style={{ paddingBottom: "56.25%" }}
|
|
100
|
+
>
|
|
101
|
+
{loaded ? (
|
|
102
|
+
<iframe
|
|
103
|
+
src={slides.embed}
|
|
104
|
+
title={slides.title}
|
|
105
|
+
allowFullScreen
|
|
106
|
+
// No referrer: the provider learns that a deck was opened, not
|
|
107
|
+
// which document of this record it was opened from.
|
|
108
|
+
referrerPolicy="no-referrer"
|
|
109
|
+
loading="lazy"
|
|
110
|
+
className="absolute inset-0 size-full"
|
|
111
|
+
/>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
|
114
|
+
<Presentation aria-hidden className="size-8 text-fd-muted-foreground" />
|
|
115
|
+
<Button onClick={() => setLoaded(true)}>Load the slides</Button>
|
|
116
|
+
<p className="max-w-sm text-xs text-fd-muted-foreground">
|
|
117
|
+
{/* Said plainly, because it is the reason for the click. */}
|
|
118
|
+
The deck is hosted{provider === undefined ? " elsewhere" : ` on ${provider}`}.
|
|
119
|
+
Nothing is requested from there until you load it.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</section>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -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
|