@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.
- package/CHANGELOG.md +101 -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 +74 -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 +136 -0
- package/templates/scaffold/system/site/components/deck-viewer.tsx +195 -0
- package/templates/scaffold/system/site/components/mdx.tsx +28 -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 +33 -1
|
@@ -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
|
|
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>.
|
|
512
|
-
multiple-choice check). The summary appears as a
|
|
513
|
-
document's own words; the
|
|
514
|
-
|
|
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
|
}
|