@panaversity/ksor 0.0.37 → 0.0.38

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 CHANGED
@@ -1,5 +1,75 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.38
4
+
5
+ ### Patch Changes
6
+
7
+ - ba15879: The scaffolded site says where a document sits on **every** page, and the trail
8
+ names the document itself.
9
+
10
+ The shell's breadcrumb renders the folders above a page and nothing else, so it
11
+ appeared on `/docs/surfaces/for-agents` and was absent on `/docs/installing` —
12
+ the block above the title came and went as a reader moved through the record,
13
+ and it was missing on exactly the documents at the top of it. A page now reads
14
+ `⌂ › Surfaces › The agent surface`: a home link, the folders, and the document
15
+ itself.
16
+
17
+ The home link goes to the record's front door at `/`. The record's name became
18
+ the page tree's root, replacing fumadocs' "Docs" default, so a screen reader
19
+ hears it as the home link's label.
20
+
21
+ - 7eeb74d: New skill: **`make-summary`**. Ask your coding agent to summarise a document
22
+ and it writes `<doc>.summary.md` from the document, which the site renders as a
23
+ Summary tab beside the document's own words.
24
+
25
+ It is `make-slides`' discipline applied to prose: read the document whole,
26
+ write the summary, check every claim and every number back against it, and
27
+ report what it left out because the document did not support it.
28
+
29
+ With one rule of its own — **every `##` section must be represented**. A
30
+ summary that covers the opening and trails off is worse than none: a reader who
31
+ used it believes they have the whole document. It also declines to summarise a
32
+ document too short to compress, and says so, rather than writing a Summary tab
33
+ that restates the page.
34
+
35
+ Slides had a generator; summaries did not, which is why records tend to have
36
+ one summary and forty documents.
37
+
38
+ - cb263d5: The eyebrow that names a region of a page — Teaching aid, In this section,
39
+ Sources — is one class now, and carries the record's accent.
40
+
41
+ It was the same string of utility classes typed out in three components, which
42
+ is how "Teaching aid" ended up accent-coloured and the other two grey: nothing
43
+ tied them together, so they drifted apart one edit at a time.
44
+
45
+ - b8de20a: The scaffolded site no longer logs a React key warning on every page in
46
+ `pnpm dev`.
47
+
48
+ The shell renders the sidebar footer as one child of an array, so the element
49
+ needs a `key`. Without it React logged "Each child in a list should have a
50
+ unique key prop" naming `RecordShell`, on every route. A production build
51
+ strips the warning, which is why it survived — it only appears in the dev
52
+ server, which is where an adopter meets the site first.
53
+
54
+ - 0f17283: A document's teaching aid now renders **after its introduction**, not above it.
55
+
56
+ The deck used to sit between the governance row and the first word of prose,
57
+ which reads as a slot in the page's furniture rather than as part of the
58
+ document — and on a long lesson it put a fourteen-slide deck in front of the
59
+ paragraph that says what the lesson is.
60
+
61
+ The placement comes from the document's own shape: the introduction is
62
+ everything before the first `##` section, so the aid goes immediately before
63
+ that heading, and a document with no sections gets it after its prose. No
64
+ marker in the record and no frontmatter key — the headings the author already
65
+ wrote are the structure.
66
+
67
+ The recall aids (flashcards, quiz) are unchanged and stay at the end, because
68
+ those are used after reading.
69
+
70
+ The aid is placed in documents only — a `<doc>.summary.md` goes through the
71
+ same pipeline and is rendered without one.
72
+
3
73
  ## 0.0.37
4
74
 
5
75
  ### Patch Changes
package/README.md CHANGED
@@ -40,14 +40,17 @@ that document's page and nowhere else:
40
40
  | File | What it is |
41
41
  | ----------------------- | ------------------------------------------------------ |
42
42
  | `<doc>.summary.md` | a précis, shown as a second tab beside the document |
43
- | `<doc>.slides.yaml` | a presentation, at the top of the page |
43
+ | `<doc>.slides.yaml` | a presentation, after the document's introduction |
44
44
  | `<doc>.flashcards.yaml` | a recall deck, at the end |
45
45
  | `<doc>.quiz.yaml` | a multiple-choice check, at the end |
46
46
 
47
- Ask your coding agent — `make slides for knowledge/expenses/approvals.md`
48
- and the `make-slides` skill writes the deck from the document, checks every
49
- claim and number back against it, and reports what it left out because the
50
- document did not support it.
47
+ Ask your coding agent — `make slides for knowledge/expenses/approvals.md`, or
48
+ `summarise knowledge/expenses/approvals.md` — and the `make-slides` and
49
+ `make-summary` skills write the attachment from the document, check every claim
50
+ and number back against it, and report what they left out because the document
51
+ did not support it. A summary is checked section by section, because one that
52
+ covers the opening and trails off leaves a reader believing they have the whole
53
+ document.
51
54
 
52
55
  An attachment is **part of its document**: no URL, no sidebar row, no
53
56
  `llms.txt` line, and no id an agent can cite. It takes its `visibility:` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
5
5
  "keywords": [
6
6
  "abstention",
@@ -71,8 +71,8 @@
71
71
  "tsdown": "0.22.14",
72
72
  "typescript": "7.0.2",
73
73
  "vitest": "^4.1.10",
74
- "@panaversity/ksor-content": "0.0.0",
75
- "@panaversity/ksor-content-gateway": "0.0.0"
74
+ "@panaversity/ksor-content-gateway": "0.0.0",
75
+ "@panaversity/ksor-content": "0.0.0"
76
76
  },
77
77
  "engines": {
78
78
  "node": ">=24"
@@ -0,0 +1,146 @@
1
+ ---
2
+ name: make-summary
3
+ description: Write the summary of a document and attach it, so it renders as a second tab on that document's page. Use when the owner says "summarise X", "make a summary for this", "add summaries to the record", "give me the short version", or asks for a TL;DR, an abstract or a précis of a governed document.
4
+ metadata:
5
+ version: "1.0.0"
6
+ ---
7
+
8
+ # Writing the summary of a document
9
+
10
+ You write the summary. Not an outline for somebody else to finish — the actual
11
+ file, `<doc>.summary.md`, which the site renders as a **Summary** tab beside
12
+ the document's own words.
13
+
14
+ Run it end to end: read the document, write the summary, check every line back
15
+ against the document, verify it builds. **The check is not optional** — it is
16
+ what keeps a summary from becoming a second, unreviewed copy of the record.
17
+
18
+ ## The one rule everything else serves
19
+
20
+ **A summary may only say what the document says.**
21
+
22
+ It is a compression of the record, never a second source. A summary asserting a
23
+ threshold the document does not contain is a claim nothing governs — and
24
+ because the summary is an attachment, the record now stands behind it. Every
25
+ number, date, name and rule is copied exactly, units included.
26
+
27
+ If the document does not say something the summary seems to need, there are two
28
+ honest options: leave it out, or tell the owner the document is missing it.
29
+ Never a third.
30
+
31
+ ## The second rule: cover every section
32
+
33
+ **Every `##` section of the document is represented in the summary.**
34
+
35
+ A summary that covers the opening and trails off is worse than none: a reader
36
+ who used it believes they have the whole document. Walk the headings in order
37
+ and check each one has landed somewhere in the summary before you call it done.
38
+
39
+ A `###` subsection does not need its own line — fold it into its parent's,
40
+ unless it carries a rule or a number of its own, in which case it does.
41
+
42
+ ## 1 · Read the document whole, first
43
+
44
+ Read `<doc>.md` completely before writing anything. Note as you go:
45
+
46
+ - **the decision it settles** — the reason it exists, usually one sentence
47
+ - **the rule, in its own words**
48
+ - **the numbers** — thresholds, deadlines, limits, and their units
49
+ - **each `##` section** — and the one thing it is there to say
50
+ - **the boundary** — what the document explicitly does NOT cover
51
+ - **its governance** — `owner`, `effective`, `status` from the frontmatter
52
+
53
+ If the document already carries `<doc>.slides.yaml`, read it: it is a reviewed
54
+ compression of the same thing, and the two must not disagree.
55
+
56
+ ## 2 · Write the summary
57
+
58
+ Write `<doc>.summary.md` beside the document. **No frontmatter** — an
59
+ attachment carries none, and the checker refuses one:
60
+
61
+ ```markdown
62
+ The lead: what this document settles, in one or two sentences, in the
63
+ document's own words. **Bold the thing a reader must not misremember.**
64
+
65
+ - One line per `##` section, in the document's order.
66
+ - Numbers exactly as the document states them, units included.
67
+ - What the document explicitly does **not** cover.
68
+ ```
69
+
70
+ **Shape:**
71
+
72
+ | Part | Use |
73
+ | --------- | -------------------------------------------------------- |
74
+ | lead | one or two sentences — the decision the document settles |
75
+ | bullets | one per `##` section, in the document's own order |
76
+ | last line | the boundary: what this document does not settle |
77
+
78
+ **Length:** aim for a fifth of the document, and never more than a quarter. If
79
+ the summary approaches the document's length, it has stopped being a summary
80
+ — cut the elaboration, keep the rules.
81
+
82
+ **Habits that decide whether it is any good:**
83
+
84
+ - **Lead with the decision, not the definition.** A reader opening the Summary
85
+ tab wants what this settles, not what the topic is.
86
+ - **Keep the document's own words for anything load-bearing.** Paraphrase the
87
+ explanation; copy the rule.
88
+ - **A bullet is one thought.** If it needs a comma splice, it is two bullets.
89
+ - **Say what is excluded.** The boundary is the half a compression loses first
90
+ and the half a reader is most likely to get wrong.
91
+ - **Do not add.** No context, no advice, no "note that" — the document is one
92
+ click away.
93
+
94
+ ## 3 · Check every line against the document
95
+
96
+ Go back through the summary with the document open. For each line:
97
+
98
+ - Is the claim in the document? Name where.
99
+ - Is every number identical, same units, same rounding?
100
+ - Does any line imply a rule the document does not state?
101
+ - Walk the `##` headings in order: is each one represented?
102
+ - Would a reader who read ONLY this be wrong about anything?
103
+
104
+ That last question is the one that matters. A summary is used instead of the
105
+ document, not before it.
106
+
107
+ ## 4 · Verify it
108
+
109
+ ```sh
110
+ pnpm check # refuses an orphan or an attachment carrying frontmatter
111
+ pnpm dev # open the page — a Summary tab appears beside Document
112
+ ```
113
+
114
+ The build refuses:
115
+
116
+ - `ksor-attachment-orphan` — no `<doc>.md` beside it
117
+ - `ksor-attachment-frontmatter` — an attachment carries none of its own
118
+
119
+ If no Summary tab appears, the file name is wrong: it must be exactly
120
+ `<doc>.summary.md`, matching the document's own name.
121
+
122
+ ## 5 · Tell the owner what you did
123
+
124
+ Which document, how long the summary is against the document, and **anything
125
+ you left out because the document did not support it**. That last part is the
126
+ useful half: it is how an owner finds out their document has a gap.
127
+
128
+ Summarising several documents at once? Report them as a list with the same
129
+ three facts each, and name any document you did NOT summarise and why — a
130
+ document too short to compress does not need one, and saying so is the answer.
131
+
132
+ ## What NOT to do
133
+
134
+ - **Do not summarise a document you have not read whole.** A summary written
135
+ from the first screen is confidently wrong about the rest.
136
+ - **Do not write a line the document cannot support**, even a true one. If it
137
+ is not in the record, the record cannot stand behind it.
138
+ - **Do not give a short document a summary.** Under roughly two screens there
139
+ is nothing to compress, and a Summary tab that restates the page teaches a
140
+ reader that the tab is not worth opening.
141
+ - **Do not make one summary for several documents.** A summary belongs to one
142
+ document. One spanning five policies has no document to be governed by and
143
+ nothing to be withdrawn with.
144
+ - **Do not patch a stale summary.** When the document changes materially,
145
+ rewrite from it. Patching is how a summary and its document drift, and a
146
+ reader on the Summary tab has no way to see that it happened.
@@ -0,0 +1,146 @@
1
+ ---
2
+ name: make-summary
3
+ description: Write the summary of a document and attach it, so it renders as a second tab on that document's page. Use when the owner says "summarise X", "make a summary for this", "add summaries to the record", "give me the short version", or asks for a TL;DR, an abstract or a précis of a governed document.
4
+ metadata:
5
+ version: "1.0.0"
6
+ ---
7
+
8
+ # Writing the summary of a document
9
+
10
+ You write the summary. Not an outline for somebody else to finish — the actual
11
+ file, `<doc>.summary.md`, which the site renders as a **Summary** tab beside
12
+ the document's own words.
13
+
14
+ Run it end to end: read the document, write the summary, check every line back
15
+ against the document, verify it builds. **The check is not optional** — it is
16
+ what keeps a summary from becoming a second, unreviewed copy of the record.
17
+
18
+ ## The one rule everything else serves
19
+
20
+ **A summary may only say what the document says.**
21
+
22
+ It is a compression of the record, never a second source. A summary asserting a
23
+ threshold the document does not contain is a claim nothing governs — and
24
+ because the summary is an attachment, the record now stands behind it. Every
25
+ number, date, name and rule is copied exactly, units included.
26
+
27
+ If the document does not say something the summary seems to need, there are two
28
+ honest options: leave it out, or tell the owner the document is missing it.
29
+ Never a third.
30
+
31
+ ## The second rule: cover every section
32
+
33
+ **Every `##` section of the document is represented in the summary.**
34
+
35
+ A summary that covers the opening and trails off is worse than none: a reader
36
+ who used it believes they have the whole document. Walk the headings in order
37
+ and check each one has landed somewhere in the summary before you call it done.
38
+
39
+ A `###` subsection does not need its own line — fold it into its parent's,
40
+ unless it carries a rule or a number of its own, in which case it does.
41
+
42
+ ## 1 · Read the document whole, first
43
+
44
+ Read `<doc>.md` completely before writing anything. Note as you go:
45
+
46
+ - **the decision it settles** — the reason it exists, usually one sentence
47
+ - **the rule, in its own words**
48
+ - **the numbers** — thresholds, deadlines, limits, and their units
49
+ - **each `##` section** — and the one thing it is there to say
50
+ - **the boundary** — what the document explicitly does NOT cover
51
+ - **its governance** — `owner`, `effective`, `status` from the frontmatter
52
+
53
+ If the document already carries `<doc>.slides.yaml`, read it: it is a reviewed
54
+ compression of the same thing, and the two must not disagree.
55
+
56
+ ## 2 · Write the summary
57
+
58
+ Write `<doc>.summary.md` beside the document. **No frontmatter** — an
59
+ attachment carries none, and the checker refuses one:
60
+
61
+ ```markdown
62
+ The lead: what this document settles, in one or two sentences, in the
63
+ document's own words. **Bold the thing a reader must not misremember.**
64
+
65
+ - One line per `##` section, in the document's order.
66
+ - Numbers exactly as the document states them, units included.
67
+ - What the document explicitly does **not** cover.
68
+ ```
69
+
70
+ **Shape:**
71
+
72
+ | Part | Use |
73
+ | --------- | -------------------------------------------------------- |
74
+ | lead | one or two sentences — the decision the document settles |
75
+ | bullets | one per `##` section, in the document's own order |
76
+ | last line | the boundary: what this document does not settle |
77
+
78
+ **Length:** aim for a fifth of the document, and never more than a quarter. If
79
+ the summary approaches the document's length, it has stopped being a summary
80
+ — cut the elaboration, keep the rules.
81
+
82
+ **Habits that decide whether it is any good:**
83
+
84
+ - **Lead with the decision, not the definition.** A reader opening the Summary
85
+ tab wants what this settles, not what the topic is.
86
+ - **Keep the document's own words for anything load-bearing.** Paraphrase the
87
+ explanation; copy the rule.
88
+ - **A bullet is one thought.** If it needs a comma splice, it is two bullets.
89
+ - **Say what is excluded.** The boundary is the half a compression loses first
90
+ and the half a reader is most likely to get wrong.
91
+ - **Do not add.** No context, no advice, no "note that" — the document is one
92
+ click away.
93
+
94
+ ## 3 · Check every line against the document
95
+
96
+ Go back through the summary with the document open. For each line:
97
+
98
+ - Is the claim in the document? Name where.
99
+ - Is every number identical, same units, same rounding?
100
+ - Does any line imply a rule the document does not state?
101
+ - Walk the `##` headings in order: is each one represented?
102
+ - Would a reader who read ONLY this be wrong about anything?
103
+
104
+ That last question is the one that matters. A summary is used instead of the
105
+ document, not before it.
106
+
107
+ ## 4 · Verify it
108
+
109
+ ```sh
110
+ pnpm check # refuses an orphan or an attachment carrying frontmatter
111
+ pnpm dev # open the page — a Summary tab appears beside Document
112
+ ```
113
+
114
+ The build refuses:
115
+
116
+ - `ksor-attachment-orphan` — no `<doc>.md` beside it
117
+ - `ksor-attachment-frontmatter` — an attachment carries none of its own
118
+
119
+ If no Summary tab appears, the file name is wrong: it must be exactly
120
+ `<doc>.summary.md`, matching the document's own name.
121
+
122
+ ## 5 · Tell the owner what you did
123
+
124
+ Which document, how long the summary is against the document, and **anything
125
+ you left out because the document did not support it**. That last part is the
126
+ useful half: it is how an owner finds out their document has a gap.
127
+
128
+ Summarising several documents at once? Report them as a list with the same
129
+ three facts each, and name any document you did NOT summarise and why — a
130
+ document too short to compress does not need one, and saying so is the answer.
131
+
132
+ ## What NOT to do
133
+
134
+ - **Do not summarise a document you have not read whole.** A summary written
135
+ from the first screen is confidently wrong about the rest.
136
+ - **Do not write a line the document cannot support**, even a true one. If it
137
+ is not in the record, the record cannot stand behind it.
138
+ - **Do not give a short document a summary.** Under roughly two screens there
139
+ is nothing to compress, and a Summary tab that restates the page teaches a
140
+ reader that the tab is not worth opening.
141
+ - **Do not make one summary for several documents.** A summary belongs to one
142
+ document. One spanning five policies has no document to be governed by and
143
+ nothing to be withdrawn with.
144
+ - **Do not patch a stale summary.** When the document changes materially,
145
+ rewrite from it. Patching is how a summary and its document drift, and a
146
+ reader on the Summary tab has no way to see that it happened.
@@ -511,10 +511,11 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
511
511
  after it, in the same folder: `<doc>.summary.md` (a short précis),
512
512
  `<doc>.slides.yaml` (a presentation), `<doc>.flashcards.yaml` (a recall deck)
513
513
  and `<doc>.quiz.yaml` (a multiple-choice check). The summary appears as a
514
- second tab beside the document's own words; the presentation appears at the
515
- TOP of the page, before the document, because a deck is the shape of the
516
- thing and gives the detail somewhere to land; the deck and the quiz appear at
517
- the END, because those are used after reading. None of them appears anywhere
514
+ second tab beside the document's own words; the presentation appears after
515
+ the document's INTRODUCTION everything before its first `##` section
516
+ because a deck is the shape of the thing and belongs where the reader has
517
+ just been told what the thing is; the deck and the quiz appear at the END,
518
+ because those are used after reading. None of them appears anywhere
518
519
  else in the site.
519
520
 
520
521
  An attachment is **part of its document**, not a document. It has no URL of
@@ -686,6 +687,7 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
686
687
  - `.agents/skills/add-sources/` — turn source material (documents, pages,
687
688
  notes) into governed knowledge.
688
689
  - `.agents/skills/make-slides/` — generate a presentation from one document
690
+ - `.agents/skills/make-summary/` — write a document's summary and attach it
689
691
  and attach it, so it renders on that document's page.
690
692
  - `.agents/skills/format-checker/` — the rules above, as a program;
691
693
  `pnpm check` runs it and its errors explain how to fix themselves.
@@ -39,8 +39,9 @@ The `make-slides` skill reads the document whole, writes the deck into
39
39
  `knowledge/expenses/approvals.slides.yaml`, checks every claim and every
40
40
  number back against the document, and tells you what it left out because the
41
41
  document did not support it — which is usually how you find out a document has
42
- a gap. The deck then renders at the top of that document's page: click through
43
- it inline, or **Present** for fullscreen. Presenter notes stay off the screen.
42
+ a gap. The deck then renders on that document's page, straight after its
43
+ introduction: click through it inline, or **Present** for fullscreen.
44
+ Presenter notes stay off the screen.
44
45
 
45
46
  The slides live in the record, so they are reviewed in the same pull request
46
47
  as the document, versioned with it, and withdrawn when it is withdrawn. There
@@ -49,6 +50,28 @@ Slides, Canva or SlideShare you can point at it instead — `slides.url:` rather
49
50
  than `deck:` — and the page will offer it as a link with a frame the reader
50
51
  loads on click, so nothing is requested from the host until somebody asks.
51
52
 
53
+ ### Summarising a document
54
+
55
+ Long documents get a **Summary** tab beside their own words, and your agent
56
+ writes it the same way:
57
+
58
+ ```
59
+ summarise knowledge/expenses/approvals.md
60
+ ```
61
+
62
+ The `make-summary` skill reads the document whole, writes
63
+ `knowledge/expenses/approvals.summary.md`, and checks every line back against
64
+ the document — every number, every rule, and every `##` section, because a
65
+ summary that covers the opening and trails off is worse than none: a reader who
66
+ used it believes they have the whole document. It reports what it left out
67
+ because the document did not support it.
68
+
69
+ The summary is part of its document, not a document of its own: no route, no
70
+ sidebar row, no line in `llms.txt`, and it takes its governance from its
71
+ parent. Ask for one only where there is something to compress — under about two
72
+ screens, a summary that restates the page teaches readers the tab is not worth
73
+ opening, and the skill will say so rather than write one.
74
+
52
75
  ### Serving to agents
53
76
 
54
77
  The record's other surface is an MCP server for AI agents — the same
@@ -183,7 +206,7 @@ different coding agent's way of finding the same working contract.
183
206
  | `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. |
184
207
  | `AGENTS.md` | the working contract every coding agent reads first — the rules for writing knowledge here. |
185
208
  | `CLAUDE.md` | one line, pointing at `AGENTS.md`. Claude Code looks for this filename, not that one. |
186
- | `.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). |
209
+ | `.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), `make-summary` (write a document's summary and attach it), `format-checker` (the rules, as a program). |
187
210
  | `.claude/skills/` | byte-identical copies of the kit — Claude Code discovers skills only here. The checker enforces the mirror, so the two cannot drift. |
188
211
  | `.gemini/settings.json` | points Gemini CLI at `AGENTS.md`; Gemini does not read that filename on its own. |
189
212
  | `.github/workflows/validate.yml` | your CI: runs the same checker on every pull request and push to main. |
@@ -14,6 +14,7 @@ import {
14
14
  } from "@/components/governance";
15
15
  import { predecessorsOf, readGovernance, resolveSuccessorUrl } from "@/lib/governance";
16
16
  import { showGovernance } from "@/lib/shared";
17
+ import { RecordBreadcrumb } from "@/components/record-breadcrumb";
17
18
  import { RecordToc, TocItems } from "@/components/record-toc";
18
19
  import { RecordViews } from "@/components/record-views";
19
20
  import { Flashcards } from "@/components/flashcards";
@@ -96,7 +97,12 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
96
97
  // headings AHEAD of the reader. The observer's options are not
97
98
  // configurable and the observer is not exported, so the selection could
98
99
  // only be replaced — `slots.toc.main` is the seam for that.
100
+ // The breadcrumb is ours for one reason, recorded in the component:
101
+ // the shell's renders nothing at all on a top-level document, so the
102
+ // block above the title came and went as a reader moved through the
103
+ // record.
99
104
  slots={{
105
+ breadcrumb: RecordBreadcrumb,
100
106
  toc: {
101
107
  provider: TOCProvider,
102
108
  main: RecordToc,
@@ -126,12 +132,6 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
126
132
  {showGovernance ? (
127
133
  <GovernanceMeta governance={governance} replaces={replaces} markdownUrl={markdownUrl} />
128
134
  ) : 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} />}
135
135
  {/* grow-0, against the shell's own `flex-1`: the article is a flex column
136
136
  stretched to the viewport, so the body inflated from ~150px of text to
137
137
  402px and pushed Sources and everything after it to the bottom of the
@@ -159,6 +159,14 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
159
159
  // relative links between documents in knowledge/ resolve to
160
160
  // their rendered pages
161
161
  a: createRelativeLink(source, page),
162
+ // The deck, rendered where the record's own shape puts it:
163
+ // after the introduction, before the first section. The
164
+ // rehype plugin marks the place on every document and this
165
+ // decides whether there is anything to put there — so "does
166
+ // this document have a teaching aid" stays one question,
167
+ // answered by the attachment, not two.
168
+ TeachingAid: () =>
169
+ presentation === null ? null : <Slides slides={presentation} />,
162
170
  })}
163
171
  />
164
172
  </RecordViews>
@@ -771,3 +771,70 @@ html > body[data-scroll-locked] {
771
771
  #nd-page > [class*="@container"]:last-child {
772
772
  margin-top: auto;
773
773
  }
774
+
775
+ /* The breadcrumb.
776
+ Sentence case in the body face, not the mono uppercase of the OWNER row
777
+ below it: this is a path made of document TITLES, and uppercasing a title
778
+ makes a long one unreadable and a short one shout. The row beneath is
779
+ labels, which is a different job.
780
+
781
+ Home icon, chevron separators, and the document itself carried by weight
782
+ rather than by colour — the shape the owner asked for, 2026-08-24. */
783
+ .ksor-breadcrumb {
784
+ display: flex;
785
+ flex-wrap: wrap;
786
+ align-items: center;
787
+ gap: 0.5rem;
788
+ margin-bottom: 0.75rem;
789
+ font-size: 0.875rem;
790
+ line-height: 1.4;
791
+ }
792
+
793
+ .ksor-breadcrumb-home,
794
+ .ksor-breadcrumb-step {
795
+ color: var(--color-fd-muted-foreground);
796
+ transition: color 0.15s ease;
797
+ }
798
+
799
+ .ksor-breadcrumb-home {
800
+ display: inline-flex;
801
+ align-items: center;
802
+ }
803
+
804
+ .ksor-breadcrumb-home:hover,
805
+ .ksor-breadcrumb-step:hover {
806
+ color: var(--color-fd-foreground);
807
+ }
808
+
809
+ .ksor-breadcrumb-sep {
810
+ color: var(--color-fd-muted-foreground);
811
+ opacity: 0.5;
812
+ }
813
+
814
+ /* You-are-here: full-strength text where the path is muted. Weight and
815
+ contrast do the work, not colour — an accent here competes with the title
816
+ directly beneath it, which is the thing the reader is meant to land on. */
817
+ .ksor-breadcrumb-here {
818
+ color: var(--color-fd-foreground);
819
+ font-weight: 500;
820
+ }
821
+
822
+ /* A SECTION LABEL: the eyebrow that names a region of the page — Teaching aid,
823
+ In this section, Sources.
824
+
825
+ One class, three readers. It was the same Tailwind string typed out in each
826
+ component, which is how "Teaching aid" ended up accent-coloured and the
827
+ other two grey: nothing tied them together, so they drifted apart one edit
828
+ at a time. The accent is what makes these read as the page's own structure
829
+ rather than as small grey text (owner, 2026-08-24).
830
+
831
+ Not for the sidebar or the table-of-contents rail — those label the chrome
832
+ around the document, and this labels the document. */
833
+ .ksor-section-label {
834
+ font-family: var(--font-mono, ui-monospace, monospace);
835
+ font-size: 0.75rem;
836
+ font-weight: 500;
837
+ letter-spacing: 0.12em;
838
+ text-transform: uppercase;
839
+ color: var(--color-fd-primary);
840
+ }
@@ -215,9 +215,7 @@ export function Provenance({ entries }: { entries: readonly string[] }): ReactEl
215
215
 
216
216
  return (
217
217
  <section className="mt-10 border-t border-fd-border pt-5 text-sm">
218
- <h2 className="mb-2 font-mono text-xs tracking-[0.18em] text-fd-muted-foreground uppercase">
219
- Sources
220
- </h2>
218
+ <h2 className="ksor-section-label mb-2">Sources</h2>
221
219
  {/* break-words, because a citation is often a long unbroken URL: on a
222
220
  phone it overflowed its row by 175px under an ancestor with
223
221
  `overflow-x: clip`, so the middle of the source was clipped away with
@@ -26,6 +26,12 @@ function BrandedTabsTrigger({
26
26
  export function getMDXComponents(components?: MDXComponents) {
27
27
  return {
28
28
  ...defaultMdxComponents,
29
+ // The teaching-aid marker, rendering NOTHING unless a caller supplies a
30
+ // deck. The rehype plugin inserts the marker into every document, and MDX
31
+ // throws on a component it was not given — so the default has to exist
32
+ // here or a document whose page forgot to pass one serves a 500 rather
33
+ // than a page without an aid.
34
+ TeachingAid: () => null,
29
35
  // `remarkCodeTab` (source.config.ts) rewrites consecutive fenced blocks
30
36
  // that declare `tab="…"` into these, so they have to be in the map or the
31
37
  // build fails on an unknown component rather than at authoring time.
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { getBreadcrumbItemsFromPath } from "fumadocs-core/breadcrumb";
4
+ import Link from "fumadocs-core/link";
5
+ import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree";
6
+ import type { BreadcrumbProps } from "fumadocs-ui/layouts/docs/page/slots/breadcrumb";
7
+ import { ChevronRight, Home } from "lucide-react";
8
+ import { Fragment, type ReactElement } from "react";
9
+
10
+ /**
11
+ * Where this document sits — on EVERY document, including the top-level ones.
12
+ *
13
+ * The shell's own breadcrumb renders the folders above a page and nothing
14
+ * else, so it appeared on `/docs/surfaces/for-agents` and was absent on
15
+ * `/docs/installing` (measured on the built pages: two of three blank). A
16
+ * reader clicking between them watched the block above the title appear and
17
+ * disappear, and the pages with no trail were exactly the ones at the top of
18
+ * the record.
19
+ *
20
+ * The shell has an `includeRoot` option that looks like the fix and is not:
21
+ * it only fires for a folder marked `root: true` in the page tree, which a
22
+ * plain `knowledge/` tree has none of (fumadocs-core 16.14.5, breadcrumb.js —
23
+ * the `item.root` branch). So the root is prepended here instead.
24
+ *
25
+ * The trail ENDS IN THE PAGE (owner's call, 2026-08-24). The first cut left it
26
+ * out — the title is the h1 directly beneath, so ending the trail in the
27
+ * heading you are already reading looked like a duplicate. It reads the other
28
+ * way round on a real record: without the page the trail is folders only, so a
29
+ * top-level document got a single word and no sense of place at all.
30
+ *
31
+ * The root is a HOME ICON, not the record's name (owner's call, 2026-08-24).
32
+ * The name is a project slug — `quiz-demo` — which is an identifier rather
33
+ * than a place, and the record's real name is already at the top of the
34
+ * sidebar. The name stays as the icon's accessible label, so a screen reader
35
+ * hears where the link goes.
36
+ */
37
+ export function RecordBreadcrumb({
38
+ includeRoot: _includeRoot,
39
+ includePage: _includePage,
40
+ includeSeparator,
41
+ ...props
42
+ }: BreadcrumbProps): ReactElement {
43
+ const path = useTreePath();
44
+ const { root } = useTreeContext();
45
+ const trail = getBreadcrumbItemsFromPath(root, path, { includePage: true, includeSeparator });
46
+
47
+ return (
48
+ <nav {...props} aria-label="Breadcrumb" className={`ksor-breadcrumb ${props.className ?? ""}`}>
49
+ {/* The record's front door is `/`, not `/docs` — the home page wears the
50
+ same shell (components/record-shell.tsx) and there is no `/docs`
51
+ route at all. Linking there served a 404 from the first item of every
52
+ page's breadcrumb; caught by walking the built routes. */}
53
+ <Link className="ksor-breadcrumb-home" href="/" aria-label={`${root.name} home`}>
54
+ <Home className="size-4" aria-hidden />
55
+ </Link>
56
+ {trail.map((item, index) => (
57
+ <Fragment key={index}>
58
+ <ChevronRight className="ksor-breadcrumb-sep size-3.5 shrink-0" aria-hidden />
59
+ {item.url && index < trail.length - 1 ? (
60
+ <Link className="ksor-breadcrumb-step" href={item.url}>
61
+ {item.name}
62
+ </Link>
63
+ ) : (
64
+ // The document itself: never a link to the page you are on.
65
+ <span className="ksor-breadcrumb-here" aria-current="page">
66
+ {item.name}
67
+ </span>
68
+ )}
69
+ </Fragment>
70
+ ))}
71
+ </nav>
72
+ );
73
+ }
@@ -47,9 +47,7 @@ export function RecordIndex({
47
47
  <section className="mt-14">
48
48
  {/* The head of the list stays a register head: this is a label, and a
49
49
  label is machine-facing furniture whatever the rows below it are. */}
50
- <h2 className="mb-3 font-mono text-xs tracking-[0.18em] text-fd-muted-foreground uppercase">
51
- {heading}
52
- </h2>
50
+ <h2 className="ksor-section-label mb-3">{heading}</h2>
53
51
 
54
52
  <ul className="grid gap-3">
55
53
  {entries.map((entry) => {
@@ -36,8 +36,15 @@ export function RecordShell({ children }: { children: ReactNode }): ReactElement
36
36
  // After the spread: a future sidebar key in baseOptions must not
37
37
  // silently swallow the attribution (review finding, 2026-08-18).
38
38
  sidebar={{
39
+ // `key`, because the shell renders this footer as one child of an
40
+ // ARRAY (fumadocs-ui 16.14.5, layouts/docs/slots/sidebar.js — the
41
+ // branch that also holds the language select, icon links and theme
42
+ // switch). Without it React logs "Each child in a list should have a
43
+ // unique key prop" naming RecordShell, on every page. Invisible in a
44
+ // production build, which is why it survived: it only shows in the
45
+ // dev server, where the adopter meets it first.
39
46
  footer: (
40
- <div className="mt-3 flex flex-col gap-2">
47
+ <div key="record-footer" className="mt-3 flex flex-col gap-2">
41
48
  {/* The record's own identity, on every page rather than only the
42
49
  home page: the slug is what citations carry and llms.txt is the
43
50
  door an agent is told to read. The sidebar had three links and
@@ -48,9 +48,7 @@ export function Slides({ slides }: { slides: SlidesEntry }): ReactElement {
48
48
  weaker one — the label carries the accent so it reads as a marker,
49
49
  and the title sits one step below the document's. */}
50
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>
51
+ <p className="ksor-section-label">Teaching aid</p>
54
52
  <h2 className="mt-2 font-(family-name:--font-display) text-2xl font-semibold tracking-tight text-fd-foreground">
55
53
  {slides.title}
56
54
  </h2>
@@ -36,6 +36,14 @@ export const source = loader({
36
36
  ],
37
37
  });
38
38
 
39
+ // The page tree's root is named "Docs" by default — fumadocs' fallback for a
40
+ // directory carrying no meta.json. It names the software, not the thing a
41
+ // reader is inside, and it is what the breadcrumb's first item says. The
42
+ // record already has a name in instance.md, so it says that instead. Set here
43
+ // rather than passed to the breadcrumb, because `slots` crosses a client
44
+ // boundary and a server value cannot ride along with it — the tree can.
45
+ source.pageTree.name = appName;
46
+
39
47
  export type KnowledgePage = (typeof source)["$inferPage"];
40
48
 
41
49
  // Sub-path hosting prefix, for URLs we WRITE INTO TEXT (llms.txt,
@@ -0,0 +1,92 @@
1
+ /**
2
+ * WHERE the teaching aid sits in a document: after its introduction.
3
+ *
4
+ * The deck used to render above the document, between the governance row and
5
+ * the first word of prose. That reads as a slot in the page's furniture rather
6
+ * than as part of the document, and on a long lesson it puts a fourteen-slide
7
+ * deck in front of the paragraph that says what the lesson is.
8
+ *
9
+ * The rule is the document's own shape: a document's INTRODUCTION is
10
+ * everything before its first `##` section, so the aid goes immediately before
11
+ * that heading. It needs no marker in the record and no frontmatter key — the
12
+ * headings the author already wrote are the structure. A document with no
13
+ * sections has no such seam, and the aid follows its prose instead.
14
+ *
15
+ * This is where the imported course put its own `## Teaching Aid` by hand:
16
+ * after the intro material, immediately before Part 1.
17
+ *
18
+ * The marker goes into DOCUMENTS only. `<doc>.summary.md` goes through the
19
+ * same MDX pipeline (source.config.ts, the `summaries` collection) and is
20
+ * rendered with the page's own component map, which does not carry a teaching
21
+ * aid — so marking one threw "Expected component `TeachingAid` to be defined"
22
+ * and served a 500 for every document that has a summary. Found live in the
23
+ * dev server, on the first page with one.
24
+ *
25
+ * What counts as an attachment is HANDED IN rather than imported: this file
26
+ * has to stay import-free so the repo's own tests can take it on its own (a
27
+ * relative import here needs a `.js` extension for tsc that Next's resolver
28
+ * then rejects). Handing it in keeps lib/attachment-rule.ts the one place the
29
+ * suffix list lives — a second copy here is exactly the drift the attachment
30
+ * rule is written to prevent.
31
+ */
32
+
33
+ /** The heading level that starts a section. `##` — `#` is the page title. */
34
+ export const SECTION_HEADING = "h2";
35
+
36
+ /** The component the site swaps in for the marker. */
37
+ export const TEACHING_AID_ELEMENT = "TeachingAid";
38
+
39
+ export interface TeachingAidOptions {
40
+ /** lib/attachment-rule.ts's `isAttachment`, handed in — see above. */
41
+ readonly isAttachment: (baseName: string) => boolean;
42
+ }
43
+
44
+ interface AidNode {
45
+ type: string;
46
+ tagName?: string;
47
+ children?: AidNode[];
48
+ name?: string;
49
+ attributes?: unknown[];
50
+ }
51
+
52
+ /**
53
+ * The index in `children` the aid belongs at: before the first `h2`, or after
54
+ * everything when the document has no sections.
55
+ */
56
+ export function teachingAidIndex(children: readonly AidNode[]): number {
57
+ const first = children.findIndex(
58
+ (child) => child.type === "element" && child.tagName === SECTION_HEADING,
59
+ );
60
+ return first === -1 ? children.length : first;
61
+ }
62
+
63
+ /**
64
+ * Rehype plugin: put a `<TeachingAid />` marker where the aid belongs.
65
+ *
66
+ * The marker is ALWAYS inserted; the component the page supplies renders null
67
+ * when the document carries no deck. That keeps the decision about whether
68
+ * there is an aid in one place (`slidesFor`, on the server) instead of
69
+ * splitting it between a plugin and a component.
70
+ *
71
+ * Rehype rather than remark for the reason lib/alert-rule.ts records: the
72
+ * record's markdown is serialized from the mdast, so a marker inserted there
73
+ * would appear in `/md/` and `llms-full.txt`, publishing a component of this
74
+ * site to the agent surface.
75
+ */
76
+ export function rehypeTeachingAid(
77
+ options: TeachingAidOptions,
78
+ ): (tree: AidNode, file: { path?: string }) => void {
79
+ return (tree: AidNode, file: { path?: string }): void => {
80
+ const name = (file.path ?? "").split(/[/\\]/).pop() ?? "";
81
+ if (options.isAttachment(name)) return;
82
+
83
+ const children = tree.children;
84
+ if (!children) return;
85
+ children.splice(teachingAidIndex(children), 0, {
86
+ type: "mdxJsxFlowElement",
87
+ name: TEACHING_AID_ELEMENT,
88
+ attributes: [],
89
+ children: [],
90
+ });
91
+ };
92
+ }
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import { DeckSchema } from "./lib/deck";
6
6
  import { QuizSchema } from "./lib/quiz";
7
7
  import { SlidesSchema } from "./lib/slides";
8
+ import { isAttachment } from "./lib/attachment-rule";
9
+ import { rehypeTeachingAid } from "./lib/teaching-aid-rule";
8
10
  import { knowledgeSourceDir } from "./lib/stage-knowledge";
9
11
 
10
12
  // The record lives at <repo>/knowledge — two levels up from this site.
@@ -115,6 +117,13 @@ export const slides = defineCollections({
115
117
 
116
118
  export default defineConfig({
117
119
  mdxOptions: {
120
+ /**
121
+ * WHERE the teaching aid sits: after the document's introduction, which
122
+ * is everything before its first `##` section. The plugin only marks the
123
+ * place; the page decides whether there is a deck to put there. See
124
+ * lib/teaching-aid-rule.ts.
125
+ */
126
+ rehypePlugins: [[rehypeTeachingAid, { isAttachment }]],
118
127
  /**
119
128
  * Alternative versions of the same instruction, as TABS.
120
129
  *