@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.
@@ -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