@panaversity/ksor 0.0.30 → 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 +67 -0
- package/README.md +24 -0
- package/dist/cli.mjs +7 -3
- package/dist/{gateway-api-CbFkHZiU-HvlJRjRB.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 +9 -1
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +160 -0
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +9 -1
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +160 -0
- package/templates/scaffold/AGENTS.md +45 -5
- package/templates/scaffold/README.md +24 -1
- package/templates/scaffold/knowledge/what-is-a-ksor.slides.yaml +65 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +9 -1
- package/templates/scaffold/system/site/app/global.css +45 -0
- package/templates/scaffold/system/site/components/deck-viewer.tsx +195 -0
- package/templates/scaffold/system/site/components/slides.tsx +128 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +7 -0
- package/templates/scaffold/system/site/lib/attachments.ts +49 -2
- 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 +9 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# The presentation that teaches what-is-a-ksor.md.
|
|
2
|
+
#
|
|
3
|
+
# These slides live IN the record: reviewed in a pull request, versioned with
|
|
4
|
+
# the document, withdrawn when it is withdrawn, and rendered by the site — so
|
|
5
|
+
# there is no third party, no dead link, and nothing to keep in step by hand.
|
|
6
|
+
# Your coding agent writes them from the document; `.agents/skills/make-slides/`
|
|
7
|
+
# is the procedure.
|
|
8
|
+
#
|
|
9
|
+
# A slide may only say what the document says. A deck is a way of presenting
|
|
10
|
+
# the record, never a second source.
|
|
11
|
+
slides:
|
|
12
|
+
title: Introducing the record
|
|
13
|
+
description: The 15-minute version, for a room.
|
|
14
|
+
deck:
|
|
15
|
+
# The opening slide does NOT repeat the page title. It sits directly under
|
|
16
|
+
# it, so restating it wastes the one slide everyone actually looks at.
|
|
17
|
+
- heading: Which copy should an agent trust?
|
|
18
|
+
lead: That question is what this record exists to answer, and nothing in an ordinary assistant can.
|
|
19
|
+
note: Open with the question, not the definition. The definition lands after they feel the problem.
|
|
20
|
+
|
|
21
|
+
- heading: The problem it solves
|
|
22
|
+
bullets:
|
|
23
|
+
- Knowledge is scattered across wikis, decks, PDFs, prompts and memory
|
|
24
|
+
- No authoritative answer to the question an agent has to ask
|
|
25
|
+
- Which of these copies should I trust?
|
|
26
|
+
note: Ask the room where their real answer lives today. Wait for the disagreement — it always comes.
|
|
27
|
+
|
|
28
|
+
- heading: When two copies disagree, one wins
|
|
29
|
+
bullets:
|
|
30
|
+
- A traditional system of record settles the state of a business
|
|
31
|
+
- The ledger is authoritative; the spreadsheet is not
|
|
32
|
+
- Recency is not authority — a fresh edit to an ungoverned copy is still ungoverned
|
|
33
|
+
note: This is the whole idea. If they take one slide away, it is this one.
|
|
34
|
+
|
|
35
|
+
- heading: AI never had one
|
|
36
|
+
bullets:
|
|
37
|
+
- An assistant answers from everything it has ever read
|
|
38
|
+
- Nothing in that separates a checked claim from an unchecked one
|
|
39
|
+
- Which is exactly why it cannot tell you which of its sentences were verified
|
|
40
|
+
note: Not a criticism of the models. It is a missing layer, and that layer is what we are building.
|
|
41
|
+
|
|
42
|
+
- heading: What this record settles
|
|
43
|
+
bullets:
|
|
44
|
+
- Which policies apply, and which thresholds are approved
|
|
45
|
+
- What a term means inside this organization
|
|
46
|
+
- What to do when the answer is not known
|
|
47
|
+
note: Point at the third one. It is the one people do not expect.
|
|
48
|
+
|
|
49
|
+
- heading: Abstention is a feature
|
|
50
|
+
lead: '"Not in this corpus" is a correct answer — never an error, and never a licence to fall back on model knowledge.'
|
|
51
|
+
note: A record that answers everything has stopped being a record of anything in particular.
|
|
52
|
+
|
|
53
|
+
- heading: One source, two surfaces
|
|
54
|
+
bullets:
|
|
55
|
+
- The website is for people
|
|
56
|
+
- The MCP server is for agents
|
|
57
|
+
- Both render the same build — they can never disagree
|
|
58
|
+
note: Open llms.txt beside the page here. Seeing one source render twice lands better than describing it.
|
|
59
|
+
|
|
60
|
+
- heading: Where the edges are
|
|
61
|
+
bullets:
|
|
62
|
+
- Provenance proves who said what, and when
|
|
63
|
+
- It does not prove the source was right — that is a separate judgment
|
|
64
|
+
- Governance is a ladder, not a gate; level 0 works on day one
|
|
65
|
+
note: Say the second bullet out loud. Overselling provenance is how these systems lose trust.
|
|
@@ -18,8 +18,9 @@ import { RecordToc, TocItems } from "@/components/record-toc";
|
|
|
18
18
|
import { RecordViews } from "@/components/record-views";
|
|
19
19
|
import { Flashcards } from "@/components/flashcards";
|
|
20
20
|
import { Quiz } from "@/components/quiz";
|
|
21
|
+
import { Slides } from "@/components/slides";
|
|
21
22
|
import { StudyAids } from "@/components/study-aids";
|
|
22
|
-
import { deckFor, quizFor, summaryFor } from "@/lib/attachments";
|
|
23
|
+
import { deckFor, quizFor, slidesFor, summaryFor } from "@/lib/attachments";
|
|
23
24
|
import { readingMinutes } from "@/lib/reading-time";
|
|
24
25
|
|
|
25
26
|
export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
|
|
@@ -34,6 +35,7 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
|
|
|
34
35
|
const Summary = summary?.body ?? null;
|
|
35
36
|
const deck = deckFor(page.path);
|
|
36
37
|
const quiz = quizFor(page.path);
|
|
38
|
+
const presentation = slidesFor(page.path);
|
|
37
39
|
// Counted at BUILD time from the document's own markdown, so the figure is in
|
|
38
40
|
// the shipped HTML for a reader with a failed bundle, a crawler and an agent
|
|
39
41
|
// alike. The predecessor measured the rendered DOM after paint, which put it
|
|
@@ -124,6 +126,12 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
|
|
|
124
126
|
{showGovernance ? (
|
|
125
127
|
<GovernanceMeta governance={governance} replaces={replaces} markdownUrl={markdownUrl} />
|
|
126
128
|
) : null}
|
|
129
|
+
{/* BEFORE the document, not after it. The deck is the shape of the
|
|
130
|
+
thing — five minutes of slides gives the detail somewhere to land —
|
|
131
|
+
so it belongs where a reader meets it first, which is also where the
|
|
132
|
+
predecessor puts its own. The recall aids stay at the end, because
|
|
133
|
+
those are used AFTER reading. */}
|
|
134
|
+
{presentation === null ? null : <Slides slides={presentation} />}
|
|
127
135
|
{/* grow-0, against the shell's own `flex-1`: the article is a flex column
|
|
128
136
|
stretched to the viewport, so the body inflated from ~150px of text to
|
|
129
137
|
402px and pushed Sources and everything after it to the bottom of the
|
|
@@ -274,6 +274,51 @@ html > body[data-scroll-locked] {
|
|
|
274
274
|
--ksor-caution: #e0906e;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/* The slide STAGE.
|
|
278
|
+
|
|
279
|
+
A slide is a projected surface, and reading as one at a glance is most of
|
|
280
|
+
what makes a deck legible on a page full of prose. The first version painted
|
|
281
|
+
it `--muted` and it read as an empty placeholder rather than as a slide —
|
|
282
|
+
pale grey on a pale page, with nothing saying "this is a different medium".
|
|
283
|
+
|
|
284
|
+
So the stage is dark in BOTH themes, deliberately. That is not a palette
|
|
285
|
+
inconsistency: the page is a document and the stage is a projection, and the
|
|
286
|
+
whole job of this block is to look like the second thing while sitting
|
|
287
|
+
inside the first. Its ink is warm-neutral rather than pure white, which is
|
|
288
|
+
what stops a dark panel from glaring on a light page.
|
|
289
|
+
|
|
290
|
+
Light and dark differ only in how far the stage sits from its surround. */
|
|
291
|
+
:root {
|
|
292
|
+
--ksor-stage: oklch(0.21 0.018 258);
|
|
293
|
+
--ksor-stage-ink: oklch(0.96 0.005 90);
|
|
294
|
+
--ksor-stage-dim: oklch(0.74 0.012 258);
|
|
295
|
+
--ksor-stage-rule: oklch(0.32 0.02 258);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* In dark the stage must RISE off the page, not sink into it. A stage darker
|
|
299
|
+
than its surround measured L 4.4 against the page's 3.3 — near-identical,
|
|
300
|
+
so the slide lost its edges entirely and read as text floating on the page
|
|
301
|
+
(found live). On a dark ground, elevation reads as lighter. */
|
|
302
|
+
.dark {
|
|
303
|
+
--ksor-stage: oklch(0.26 0.019 258);
|
|
304
|
+
--ksor-stage-ink: oklch(0.96 0.005 90);
|
|
305
|
+
--ksor-stage-dim: oklch(0.76 0.012 258);
|
|
306
|
+
--ksor-stage-rule: oklch(0.38 0.02 258);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.ksor-stage {
|
|
310
|
+
background-color: var(--ksor-stage);
|
|
311
|
+
color: var(--ksor-stage-ink);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.ksor-stage-dim {
|
|
315
|
+
color: var(--ksor-stage-dim);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.ksor-stage-rule {
|
|
319
|
+
background-color: var(--ksor-stage-rule);
|
|
320
|
+
}
|
|
321
|
+
|
|
277
322
|
/* Right and wrong, for the quiz — the ONE place this record spends green and
|
|
278
323
|
red, and it earns them: a reader checking an answer is asking a true/false
|
|
279
324
|
question, and the accent cannot say "this is correct" while also saying
|
|
@@ -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,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
|
+
}
|
|
@@ -22,6 +22,7 @@ export const ATTACHMENT_SUFFIXES = [
|
|
|
22
22
|
{ suffix: ".summary.mdx", kind: "summary" },
|
|
23
23
|
{ suffix: ".flashcards.yaml", kind: "deck" },
|
|
24
24
|
{ suffix: ".quiz.yaml", kind: "quiz" },
|
|
25
|
+
{ suffix: ".slides.yaml", kind: "slides" },
|
|
25
26
|
] as const;
|
|
26
27
|
|
|
27
28
|
export type AttachmentKind = (typeof ATTACHMENT_SUFFIXES)[number]["kind"];
|
|
@@ -40,6 +41,8 @@ export const ATTACHMENT_NEAR_MISSES = [
|
|
|
40
41
|
{ suffix: ".summary.markdown", want: ".summary.md" },
|
|
41
42
|
{ suffix: ".quiz.yml", want: ".quiz.yaml" },
|
|
42
43
|
{ suffix: ".quiz.json", want: ".quiz.yaml" },
|
|
44
|
+
{ suffix: ".slides.yml", want: ".slides.yaml" },
|
|
45
|
+
{ suffix: ".slides.json", want: ".slides.yaml" },
|
|
43
46
|
] as const;
|
|
44
47
|
|
|
45
48
|
/**
|
|
@@ -106,6 +109,7 @@ export const ATTACHMENT_CASES = [
|
|
|
106
109
|
{ name: "returns.summary.md", kind: "summary", parent: "returns.md" },
|
|
107
110
|
{ name: "returns.flashcards.yaml", kind: "deck", parent: "returns.md" },
|
|
108
111
|
{ name: "returns.quiz.yaml", kind: "quiz", parent: "returns.md" },
|
|
112
|
+
{ name: "returns.slides.yaml", kind: "slides", parent: "returns.md" },
|
|
109
113
|
{ name: "index.summary.md", kind: "summary", parent: "index.md" },
|
|
110
114
|
// A stem containing dots keeps every one of them: the parent is the same
|
|
111
115
|
// name with the attachment suffix removed, never "up to the first dot".
|
|
@@ -115,12 +119,14 @@ export const ATTACHMENT_CASES = [
|
|
|
115
119
|
{ name: "summary.md", kind: null, parent: null },
|
|
116
120
|
{ name: "flashcards.yaml", kind: null, parent: null },
|
|
117
121
|
{ name: "quiz.yaml", kind: null, parent: null },
|
|
122
|
+
{ name: "slides.yaml", kind: null, parent: null },
|
|
118
123
|
{ name: "my-summary.md", kind: null, parent: null },
|
|
119
124
|
// A dotfile with no stem attaches to nothing — refused as an attachment so
|
|
120
125
|
// it is refused as an unexpected file instead, which is the honest error.
|
|
121
126
|
{ name: ".summary.md", kind: null, parent: null },
|
|
122
127
|
{ name: ".flashcards.yaml", kind: null, parent: null },
|
|
123
128
|
{ name: ".quiz.yaml", kind: null, parent: null },
|
|
129
|
+
{ name: ".slides.yaml", kind: null, parent: null },
|
|
124
130
|
// Case matters: the record already refuses two names differing only in case,
|
|
125
131
|
// so an uppercase suffix is a different file, not the same rule.
|
|
126
132
|
{ name: "returns.SUMMARY.md", kind: null, parent: null },
|
|
@@ -128,4 +134,5 @@ export const ATTACHMENT_CASES = [
|
|
|
128
134
|
{ name: "returns.flashcards.yml", kind: null, parent: null },
|
|
129
135
|
{ name: "returns.flashcards.json", kind: null, parent: null },
|
|
130
136
|
{ name: "returns.quiz.yml", kind: null, parent: null },
|
|
137
|
+
{ name: "returns.slides.yml", kind: null, parent: null },
|
|
131
138
|
] as const;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { decks, quizzes, summaries } from "collections/server";
|
|
1
|
+
import { decks, quizzes, slides, 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
6
|
import { type Question, type Quiz } from "./quiz";
|
|
7
|
+
import { type Slide, type Slides } from "./slides";
|
|
8
|
+
import { embedUrlFor, providerOf } from "./slides-embed";
|
|
7
9
|
import { DEFAULT_QUESTIONS_PER_ROUND } from "./quiz-round";
|
|
8
10
|
import { questionHash } from "./identity";
|
|
9
11
|
|
|
@@ -117,12 +119,57 @@ export function quizFor(documentPath: string): QuizEntry | null {
|
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/** One slide the record carries. */
|
|
123
|
+
export type DeckSlide = Slide;
|
|
124
|
+
|
|
125
|
+
export interface SlidesEntry {
|
|
126
|
+
readonly title: string;
|
|
127
|
+
/** Absent when the record owns the deck — see `deck` below. */
|
|
128
|
+
readonly url?: string;
|
|
129
|
+
readonly description?: string;
|
|
130
|
+
/** Explicit `provider:`, when the author named one. */
|
|
131
|
+
readonly provider?: string;
|
|
132
|
+
/** Derived from the host when they did not — never guessed beyond the table. */
|
|
133
|
+
readonly derivedProvider?: string;
|
|
134
|
+
/** The framable url: the author's `embed:`, or one derived from `url`. */
|
|
135
|
+
readonly embed?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Slides the record owns. When present this is the presentation, and there
|
|
138
|
+
* is no url — the schema refuses both, because two decks have no answer to
|
|
139
|
+
* which one governs.
|
|
140
|
+
*/
|
|
141
|
+
readonly deck?: readonly DeckSlide[];
|
|
142
|
+
readonly path: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The presentation for a document, or null. */
|
|
146
|
+
export function slidesFor(documentPath: string): SlidesEntry | null {
|
|
147
|
+
const wanted = attachmentPath(documentPath, ".slides.yaml");
|
|
148
|
+
const hit = slides.find((entry) => entry.info.path === wanted);
|
|
149
|
+
if (hit === undefined) return null;
|
|
150
|
+
const parsed = hit as unknown as Slides & { readonly info: { readonly path: string } };
|
|
151
|
+
const d = parsed.slides;
|
|
152
|
+
return {
|
|
153
|
+
title: d.title,
|
|
154
|
+
url: d.url,
|
|
155
|
+
deck: parsed.deck,
|
|
156
|
+
description: d.description,
|
|
157
|
+
provider: d.provider,
|
|
158
|
+
derivedProvider: d.url === undefined ? undefined : (providerOf(d.url) ?? undefined),
|
|
159
|
+
// An author's explicit embed wins; otherwise derive one, and a provider we
|
|
160
|
+
// do not know simply renders as a link.
|
|
161
|
+
embed: d.url === undefined ? undefined : (d.embed ?? embedUrlFor(d.url) ?? undefined),
|
|
162
|
+
path: parsed.info.path,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
120
166
|
/** True when a document has ANY attachment — the presence gate for the UI. */
|
|
121
167
|
export function hasAttachments(documentPath: string): boolean {
|
|
122
168
|
return (
|
|
123
169
|
summaryFor(documentPath) !== null ||
|
|
124
170
|
deckFor(documentPath) !== null ||
|
|
125
|
-
quizFor(documentPath) !== null
|
|
171
|
+
quizFor(documentPath) !== null ||
|
|
172
|
+
slidesFor(documentPath) !== null
|
|
126
173
|
);
|
|
127
174
|
}
|
|
128
175
|
|