@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +24 -0
  3. package/dist/cli.mjs +11 -3
  4. package/dist/{gateway-api-8lNruq9e-CuohjtoK.mjs → gateway-api-6nC9x54K-BWFTI_6U.mjs} +1 -1
  5. package/dist/gateway.mjs +1 -1
  6. package/package.json +1 -1
  7. package/templates/scaffold/.agents/skills/format-checker/check.mjs +11 -1
  8. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +160 -0
  9. package/templates/scaffold/.claude/skills/format-checker/check.mjs +11 -1
  10. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +160 -0
  11. package/templates/scaffold/AGENTS.md +91 -5
  12. package/templates/scaffold/README.md +24 -1
  13. package/templates/scaffold/knowledge/what-is-a-ksor.quiz.yaml +90 -0
  14. package/templates/scaffold/knowledge/what-is-a-ksor.slides.yaml +65 -0
  15. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +17 -2
  16. package/templates/scaffold/system/site/app/global.css +113 -0
  17. package/templates/scaffold/system/site/components/deck-viewer.tsx +195 -0
  18. package/templates/scaffold/system/site/components/quiz.tsx +321 -0
  19. package/templates/scaffold/system/site/components/slides.tsx +128 -0
  20. package/templates/scaffold/system/site/components/study-aids.tsx +1 -1
  21. package/templates/scaffold/system/site/lib/attachment-rule.ts +14 -0
  22. package/templates/scaffold/system/site/lib/attachments.ts +88 -3
  23. package/templates/scaffold/system/site/lib/deck.ts +3 -12
  24. package/templates/scaffold/system/site/lib/identity.ts +55 -0
  25. package/templates/scaffold/system/site/lib/quiz-audit.ts +306 -0
  26. package/templates/scaffold/system/site/lib/quiz-round.ts +57 -0
  27. package/templates/scaffold/system/site/lib/quiz.ts +84 -0
  28. package/templates/scaffold/system/site/lib/slides-embed.ts +93 -0
  29. package/templates/scaffold/system/site/lib/slides.ts +123 -0
  30. 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 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