@panaversity/ksor 0.0.30 → 0.0.32

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.
@@ -506,12 +506,15 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
506
506
  refused.
507
507
  - Images and assets live in `knowledge/` beside the document that uses them,
508
508
  referenced by relative links. A relative link must never leave `knowledge/`.
509
- - **Study attachments.** A document may carry three optional companions named
509
+ - **Study attachments.** A document may carry four optional companions named
510
510
  after it, in the same folder: `<doc>.summary.md` (a short précis),
511
- `<doc>.flashcards.yaml` (a recall deck) and `<doc>.quiz.yaml` (a
512
- multiple-choice check). The summary appears as a second tab beside the
513
- document's own words; the deck and the quiz appear at the END of that
514
- document's page. None of them appears anywhere else in the site.
511
+ `<doc>.slides.yaml` (a presentation), `<doc>.flashcards.yaml` (a recall deck)
512
+ and `<doc>.quiz.yaml` (a multiple-choice check). The summary appears as a
513
+ second tab beside the document's own words; the presentation appears at the
514
+ TOP of the page, before the document, because a deck is the shape of the
515
+ thing and gives the detail somewhere to land; the deck and the quiz appear at
516
+ the END, because those are used after reading. None of them appears anywhere
517
+ else in the site.
515
518
 
516
519
  An attachment is **part of its document**, not a document. It has no URL of
517
520
  its own, no sidebar row, no line in `llms.txt`, and no identity an agent can
@@ -595,6 +598,70 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
595
598
  predecessor, where the same mistakes shipped and were found by readers — one
596
599
  quiz had every correct answer in the same position across 451 questions.
597
600
 
601
+ A **presentation** is slides the record owns. Ask your coding agent for one
602
+ rather than writing it by hand — `make slides for knowledge/<path>.md` runs
603
+ the `make-slides` skill, which reads the document, writes the deck, checks
604
+ every claim back against it, and tells you what it left out:
605
+
606
+ ```yaml
607
+ slides:
608
+ title: Expense approvals
609
+ description: The 15-minute version, for a room.
610
+ deck:
611
+ - heading: When two copies disagree, one wins
612
+ bullets:
613
+ - Two approvers above the threshold, always
614
+ - The threshold is per invoice, including tax
615
+ note: Spoken, never shown. Pause here — people remember this one wrong.
616
+ ```
617
+
618
+ `heading` is required and should be a claim rather than a label; `lead` is
619
+ one sentence for a single-point slide; `bullets` caps at six because a slide
620
+ someone reads aloud is a slide nobody listens to; `note` is what the
621
+ presenter SAYS and never appears on the slide, including in fullscreen.
622
+
623
+ The slides render in the page — no third party, no request to anyone, and
624
+ every slide is in the shipped HTML, so a reader without JavaScript still gets
625
+ the whole deck. Because they live in the record they are reviewed in the same
626
+ pull request as the document, versioned with it, and withdrawn with it.
627
+
628
+ **A deck you keep elsewhere** can be pointed at instead — `slides.url:` and
629
+ no `deck:`. The embed url is derived for Google Slides, Canva and SlideShare;
630
+ anything else needs an explicit `embed:` or renders as a link. The url must
631
+ be `https` (a browser blocks a mixed-content frame silently), and the frame
632
+ loads on CLICK, so nothing is requested from the host until a reader asks.
633
+ Declaring both `deck:` and `slides.url:` is refused — two presentations with
634
+ nothing to say which one governs.
635
+
636
+ - **One instruction per tool, as tabs.** When a document has to say the same
637
+ thing two ways — one command for one agent, another for another — put each in
638
+ its own fenced block and give the fence a `tab`:
639
+
640
+ ````markdown
641
+ ```bash tab="Claude Code" tab-group="agent"
642
+ curl -fsSL https://claude.ai/install.sh | bash
643
+ ```
644
+
645
+ ```bash tab="OpenCode" tab-group="agent"
646
+ curl -fsSL https://opencode.ai/install | bash
647
+ ```
648
+ ````
649
+
650
+ Consecutive blocks that declare a `tab` become one tab group. This is still
651
+ CommonMark: a fence's info string is free text, so any other markdown reader
652
+ shows both blocks one after another — correct, just without the picker.
653
+
654
+ **`tab-group` is what makes the choice stick.** Blocks sharing a group name
655
+ switch together across the whole page and the choice is remembered for the
656
+ reader's next visit, so a document with ten tabbed sections is one decision
657
+ rather than ten. Leave it off and each group is independent, which is almost
658
+ never what you want. Use the same group name for the same axis throughout a
659
+ record.
660
+
661
+ A tool the site knows gets its own colour and mark on its tab; anything else
662
+ renders in the site's accent. The list lives in `system/site/app/global.css`
663
+ and is yours — adding a tool is one colour and one mark.
664
+
598
665
  - Copy load-bearing values (numbers, thresholds, dates) exactly from their
599
666
  source, and name the source in `provenance`.
600
667
 
@@ -617,6 +684,8 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
617
684
  write `instance.md` together.
618
685
  - `.agents/skills/add-sources/` — turn source material (documents, pages,
619
686
  notes) into governed knowledge.
687
+ - `.agents/skills/make-slides/` — generate a presentation from one document
688
+ and attach it, so it renders on that document's page.
620
689
  - `.agents/skills/format-checker/` — the rules above, as a program;
621
690
  `pnpm check` runs it and its errors explain how to fix themselves.
622
691
 
@@ -23,6 +23,29 @@ versions that bundle corepack. The first `pnpm install` also fetches the
23
23
  `ksor` tool (pinned in `package.json`) and writes it into your lockfile —
24
24
  commit the updated lockfile.
25
25
 
26
+ ### Presenting a document
27
+
28
+ Ask your coding agent for slides and it writes them, from the document, into
29
+ the record:
30
+
31
+ ```
32
+ make slides for knowledge/expenses/approvals.md
33
+ ```
34
+
35
+ The `make-slides` skill reads the document whole, writes the deck into
36
+ `knowledge/expenses/approvals.slides.yaml`, checks every claim and every
37
+ number back against the document, and tells you what it left out because the
38
+ document did not support it — which is usually how you find out a document has
39
+ a gap. The deck then renders at the top of that document's page: click through
40
+ it inline, or **Present** for fullscreen. Presenter notes stay off the screen.
41
+
42
+ The slides live in the record, so they are reviewed in the same pull request
43
+ as the document, versioned with it, and withdrawn when it is withdrawn. There
44
+ is no third party and no link to rot. If you already keep a deck in Google
45
+ Slides, Canva or SlideShare you can point at it instead — `slides.url:` rather
46
+ than `deck:` — and the page will offer it as a link with a frame the reader
47
+ loads on click, so nothing is requested from the host until somebody asks.
48
+
26
49
  ### Serving to agents
27
50
 
28
51
  The record's other surface is an MCP server for AI agents — the same
@@ -128,7 +151,7 @@ different coding agent's way of finding the same working contract.
128
151
  | `instance.md` | what this record is authoritative for; its `name:` is the identity every surface publishes (read at server/build start — restart `pnpm dev` after renaming). This prose IS the agent surface's system prompt — `ksor serve` wires it into the MCP server's instructions. |
129
152
  | `AGENTS.md` | the working contract every coding agent reads first — the rules for writing knowledge here. |
130
153
  | `CLAUDE.md` | one line, pointing at `AGENTS.md`. Claude Code looks for this filename, not that one. |
131
- | `.agents/skills/` | the agent kit: `intake-interview` (define the record with you), `add-sources` (turn source material into governed documents), `format-checker` (the rules, as a program). |
154
+ | `.agents/skills/` | the agent kit: `intake-interview` (define the record with you), `add-sources` (turn source material into governed documents), `make-slides` (generate a presentation from a document and attach it), `format-checker` (the rules, as a program). |
132
155
  | `.claude/skills/` | byte-identical copies of the kit — Claude Code discovers skills only here. The checker enforces the mirror, so the two cannot drift. |
133
156
  | `.gemini/settings.json` | points Gemini CLI at `AGENTS.md`; Gemini does not read that filename on its own. |
134
157
  | `.github/workflows/validate.yml` | your CI: runs the same checker on every pull request and push to main. |
@@ -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,142 @@ html > body[data-scroll-locked] {
274
274
  --ksor-caution: #e0906e;
275
275
  }
276
276
 
277
+ /* A tool's own colour, across the whole block.
278
+
279
+ When a document gives one instruction per tool, the tab is the moment a
280
+ reader picks theirs, so the choice is worth being unmistakable. The colour
281
+ is not confined to the tab: the strip and the panel take a wash of it too,
282
+ in two different strengths, so the block says which tool you are reading at
283
+ any scroll position and the strip still reads as a distinct band.
284
+
285
+ Each tool declares ONE value and shared rules do the rest, so adding a tool
286
+ is a line rather than a set. `:has()` lifts the active tab's colour onto the
287
+ container, which is what lets the panel follow the tab.
288
+
289
+ Keyed on `data-tab-value`, which `components/mdx.tsx` puts on the trigger.
290
+ Radix also encodes the value in its generated id, but that is a private
291
+ format and keying on it would drop the branding silently if it changed.
292
+
293
+ THIS BLOCK IS YOURS. These are the tools this scaffold happened to know
294
+ about; a tab value matching nothing renders in the site's own accent, which
295
+ is the right default for a record whose tabs are npm/pnpm or US/EU. Add
296
+ your own, or delete the block — nothing else depends on it. Values are the
297
+ vendors' own. */
298
+ .prose [data-orientation]:has([data-tab-value="Claude Code"][data-state="active"]) {
299
+ --ksor-tool: #da7756;
300
+ }
301
+
302
+ .prose [data-orientation]:has([data-tab-value="OpenCode"][data-state="active"]) {
303
+ --ksor-tool: #6366f1;
304
+ }
305
+
306
+ /* A decorative mark per tool. Purely visual — the label beside it is the
307
+ accessible name — so it is CSS content rather than markup an author would
308
+ have to write. */
309
+ .prose [role="tab"][data-tab-value="Claude Code"]::before {
310
+ content: "\2726";
311
+ }
312
+
313
+ .prose [role="tab"][data-tab-value="OpenCode"]::before {
314
+ content: "</>";
315
+ font-size: 0.85em;
316
+ letter-spacing: -0.05em;
317
+ }
318
+
319
+ /* Everything below is shared, and applies only where a tool was recognised. */
320
+ .prose [data-orientation]:has([data-tab-value][data-state="active"]) {
321
+ background-color: color-mix(in oklab, var(--ksor-tool) 7%, transparent);
322
+ border-color: color-mix(in oklab, var(--ksor-tool) 28%, transparent);
323
+ overflow: hidden;
324
+ }
325
+
326
+ /* The strip is a BAND: stronger than the panel, full width, and the tabs sit
327
+ inside it rather than floating above it. */
328
+ .prose [data-orientation]:has([data-tab-value][data-state="active"]) > [role="tablist"] {
329
+ background-color: color-mix(in oklab, var(--ksor-tool) 20%, transparent);
330
+ border-bottom: 1px solid color-mix(in oklab, var(--ksor-tool) 26%, transparent);
331
+ padding: 0.5rem;
332
+ gap: 0.25rem;
333
+ }
334
+
335
+ .prose [role="tab"][data-tab-value] {
336
+ display: inline-flex;
337
+ align-items: center;
338
+ gap: 0.5rem;
339
+ /* Big enough to be a target and to read as a block rather than a word. */
340
+ padding: 0.7rem 1.5rem;
341
+ font-size: 0.95rem;
342
+ font-weight: 600;
343
+ border-radius: 0.25rem;
344
+ }
345
+
346
+ .prose [role="tab"][data-tab-value][data-state="active"] {
347
+ background-color: var(--ksor-tool);
348
+ color: #fff;
349
+ }
350
+
351
+ .prose [role="tab"][data-tab-value][data-state="inactive"]:hover {
352
+ background-color: color-mix(in oklab, var(--ksor-tool) 14%, transparent);
353
+ }
354
+
355
+ /* The code sits INSET on the wash, with its own surface, so the panel reads as
356
+ a frame around it. A tinted block on a tinted panel is one colour with words
357
+ in it, and the code is the part somebody actually has to read. */
358
+ .prose [data-orientation]:has([data-tab-value][data-state="active"]) figure {
359
+ margin: 1.25rem;
360
+ background-color: var(--card);
361
+ border-color: color-mix(in oklab, var(--ksor-tool) 18%, transparent);
362
+ }
363
+
364
+ .dark .prose [data-orientation]:has([data-tab-value][data-state="active"]) figure {
365
+ background-color: color-mix(in oklab, var(--muted) 88%, #000);
366
+ }
367
+
368
+ /* The slide STAGE.
369
+
370
+ A slide is a projected surface, and reading as one at a glance is most of
371
+ what makes a deck legible on a page full of prose. The first version painted
372
+ it `--muted` and it read as an empty placeholder rather than as a slide —
373
+ pale grey on a pale page, with nothing saying "this is a different medium".
374
+
375
+ So the stage is dark in BOTH themes, deliberately. That is not a palette
376
+ inconsistency: the page is a document and the stage is a projection, and the
377
+ whole job of this block is to look like the second thing while sitting
378
+ inside the first. Its ink is warm-neutral rather than pure white, which is
379
+ what stops a dark panel from glaring on a light page.
380
+
381
+ Light and dark differ only in how far the stage sits from its surround. */
382
+ :root {
383
+ --ksor-stage: oklch(0.21 0.018 258);
384
+ --ksor-stage-ink: oklch(0.96 0.005 90);
385
+ --ksor-stage-dim: oklch(0.74 0.012 258);
386
+ --ksor-stage-rule: oklch(0.32 0.02 258);
387
+ }
388
+
389
+ /* In dark the stage must RISE off the page, not sink into it. A stage darker
390
+ than its surround measured L 4.4 against the page's 3.3 — near-identical,
391
+ so the slide lost its edges entirely and read as text floating on the page
392
+ (found live). On a dark ground, elevation reads as lighter. */
393
+ .dark {
394
+ --ksor-stage: oklch(0.26 0.019 258);
395
+ --ksor-stage-ink: oklch(0.96 0.005 90);
396
+ --ksor-stage-dim: oklch(0.76 0.012 258);
397
+ --ksor-stage-rule: oklch(0.38 0.02 258);
398
+ }
399
+
400
+ .ksor-stage {
401
+ background-color: var(--ksor-stage);
402
+ color: var(--ksor-stage-ink);
403
+ }
404
+
405
+ .ksor-stage-dim {
406
+ color: var(--ksor-stage-dim);
407
+ }
408
+
409
+ .ksor-stage-rule {
410
+ background-color: var(--ksor-stage-rule);
411
+ }
412
+
277
413
  /* Right and wrong, for the quiz — the ONE place this record spends green and
278
414
  red, and it earns them: a reader checking an answer is asking a true/false
279
415
  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
+ }
@@ -1,9 +1,37 @@
1
1
  import defaultMdxComponents from "fumadocs-ui/mdx";
2
+ import { CodeBlockTabsTrigger } from "fumadocs-ui/components/codeblock";
3
+ import { Tab, Tabs } from "fumadocs-ui/components/tabs";
2
4
  import type { MDXComponents } from "mdx/types";
5
+ import type * as React from "react";
6
+
7
+ /**
8
+ * A tab trigger that says WHICH tab it is, in an attribute we own.
9
+ *
10
+ * Radix already encodes the value in its generated `id`
11
+ * (`radix-…-trigger-Claude Code`), and styling could key on that — but that is
12
+ * a private format, so a change upstream would drop the branding silently and
13
+ * nothing would go red. One attribute of our own costs a few lines and cannot
14
+ * be taken away.
15
+ *
16
+ * `app/global.css` uses it to give a known tool its own colour; a tab value it
17
+ * does not recognise simply renders in the site's own accent.
18
+ */
19
+ function BrandedTabsTrigger({
20
+ value,
21
+ ...props
22
+ }: React.ComponentProps<typeof CodeBlockTabsTrigger>): React.ReactElement {
23
+ return <CodeBlockTabsTrigger data-tab-value={value} value={value} {...props} />;
24
+ }
3
25
 
4
26
  export function getMDXComponents(components?: MDXComponents) {
5
27
  return {
6
28
  ...defaultMdxComponents,
29
+ // `remarkCodeTab` (source.config.ts) rewrites consecutive fenced blocks
30
+ // that declare `tab="…"` into these, so they have to be in the map or the
31
+ // build fails on an unknown component rather than at authoring time.
32
+ Tabs,
33
+ Tab,
34
+ CodeBlockTabsTrigger: BrandedTabsTrigger,
7
35
  ...components,
8
36
  } satisfies MDXComponents;
9
37
  }