@panaversity/ksor 0.0.20 → 0.0.21

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +472 -0
  2. package/dist/cli.mjs +71 -19
  3. package/package.json +3 -3
  4. package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
  5. package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
  6. package/templates/scaffold/AGENTS.md +52 -4
  7. package/templates/scaffold/instance.md +28 -20
  8. package/templates/scaffold/knowledge/governance-ladder.md +36 -0
  9. package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
  10. package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
  11. package/templates/scaffold/knowledge/surfaces/index.md +21 -0
  12. package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
  13. package/templates/scaffold/pnpm-lock.yaml +1198 -228
  14. package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
  15. package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
  16. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
  17. package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
  18. package/templates/scaffold/system/site/app/global.css +552 -9
  19. package/templates/scaffold/system/site/app/layout.tsx +23 -4
  20. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
  21. package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
  22. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
  23. package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
  24. package/templates/scaffold/system/site/components/governance.tsx +262 -0
  25. package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
  26. package/templates/scaffold/system/site/components/record-index.tsx +120 -0
  27. package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
  28. package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
  29. package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
  30. package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
  31. package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
  32. package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
  33. package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
  34. package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
  35. package/templates/scaffold/system/site/components.json +25 -0
  36. package/templates/scaffold/system/site/lib/governance.ts +432 -0
  37. package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
  38. package/templates/scaffold/system/site/lib/shared.ts +38 -0
  39. package/templates/scaffold/system/site/lib/source.ts +221 -5
  40. package/templates/scaffold/system/site/lib/utils.ts +6 -0
  41. package/templates/scaffold/system/site/package.json +9 -3
  42. package/templates/scaffold/knowledge/example.md +0 -23
@@ -1,19 +1,247 @@
1
+ /* shadcn/ui is the component system (`components.json`, `pnpm dlx shadcn@latest
2
+ add <name>`), and Fumadocs reads its colours: the `shadcn` preset maps every
3
+ `--color-fd-*` token onto the shadcn variable of the same role, so one
4
+ palette dresses the shell and anything added from the registry. `neutral`
5
+ stood here before and painted both — the page and the sidebar it sits
6
+ against — hsl(0 0% 96%) and 94.7%, 1.6% apart, which is no separation at
7
+ all; shadcn's neutral gives the page white and the rail its own surface.
8
+
9
+ `shadcn init` also writes `@import "shadcn/tailwind.css"`, which is dropped
10
+ here: that import makes the shadcn CLI a runtime dependency of every
11
+ scaffolded project, and the CLI carries Babel, ts-morph, dotenvx and an MCP
12
+ SDK — 578 extra packages in the lockfile, measured, against 2 for the four
13
+ deps this file actually needs. `components.json` therefore names the classic
14
+ `new-york` style, whose components compile against plain Tailwind utilities
15
+ and the tokens below (verified by adding one). An adopter who wants the
16
+ preset styles adds the import and the dependency in their own repo. */
1
17
  @import "tailwindcss";
2
- @import "fumadocs-ui/css/neutral.css";
18
+ @import "fumadocs-ui/css/shadcn.css";
3
19
  @import "fumadocs-ui/css/preset.css";
20
+ @import "tw-animate-css";
21
+
22
+ /* next-themes switches the theme by class, so `dark:` must follow the class
23
+ rather than the OS setting. Nothing here uses a `dark:` utility — the theme
24
+ is tokens — but a component added from the registry will. */
25
+ @custom-variant dark (&:is(.dark *));
26
+
27
+ /* ── Ink, paper, rule ────────────────────────────────────────────────────────
28
+ shadcn's neutral palette, retuned on two axes and otherwise left alone.
4
29
 
5
- /* The KSoR default accent, drawn from the mark the one brand value here.
6
- Re-brand by changing this pair; every accented element follows. */
30
+ The ink carries a trace of blue (chroma .012 at hue 255) instead of being a
31
+ pure grey: it sits with the accent rather than beside it, and a record set in
32
+ cool ink reads as printed rather than as chrome. The rules are darker than
33
+ shadcn's default (0.9 against 0.922) because this design puts weight on
34
+ hairlines, and a rule you have to look for is not doing its job.
35
+
36
+ `--primary` and its foreground are the whole brand — re-brand by changing
37
+ that pair and every accented element follows, in the shell and in every
38
+ shadcn component alike, because Fumadocs reads `--primary` too. It is spent
39
+ ONLY on actions, links and the active state: a page where the accent is
40
+ everywhere has no accent. `--ksor-caution` (further down) is the only other
41
+ colour with a job, and its job is the opposite one. */
7
42
  :root {
8
- --color-fd-primary: #1d4ed8;
9
- --color-fd-primary-foreground: #ffffff;
10
- --color-fd-ring: #1d4ed8;
43
+ --background: oklch(1 0 0);
44
+ --foreground: oklch(0.17 0.012 255);
45
+ --card: oklch(0.985 0.002 255);
46
+ --card-foreground: oklch(0.17 0.012 255);
47
+ --popover: oklch(1 0 0);
48
+ --popover-foreground: oklch(0.17 0.012 255);
49
+ --primary: #1d4ed8;
50
+ --primary-foreground: #ffffff;
51
+ --secondary: oklch(0.97 0 0);
52
+ --secondary-foreground: oklch(0.205 0 0);
53
+ --muted: oklch(0.972 0.002 255);
54
+ --muted-foreground: oklch(0.53 0.012 255);
55
+ --accent: oklch(0.965 0.004 255);
56
+ --accent-foreground: oklch(0.17 0.012 255);
57
+ --destructive: oklch(0.577 0.245 27.325);
58
+ --border: oklch(0.9 0.004 255);
59
+ --input: oklch(0.9 0.004 255);
60
+ --ring: #1d4ed8;
61
+ --chart-1: oklch(0.87 0 0);
62
+ --chart-2: oklch(0.556 0 0);
63
+ --chart-3: oklch(0.439 0 0);
64
+ --chart-4: oklch(0.371 0 0);
65
+ --chart-5: oklch(0.269 0 0);
66
+ --radius: 0.5rem;
67
+ --sidebar: oklch(0.985 0.002 255);
68
+ --sidebar-foreground: oklch(0.17 0.012 255);
69
+ --sidebar-primary: oklch(0.205 0 0);
70
+ --sidebar-primary-foreground: oklch(0.985 0 0);
71
+ --sidebar-accent: oklch(0.965 0.004 255);
72
+ --sidebar-accent-foreground: oklch(0.17 0.012 255);
73
+ --sidebar-border: oklch(0.9 0.004 255);
74
+ --sidebar-ring: #1d4ed8;
75
+
76
+ /* The cover: the surface the front door's band is printed on. It follows the
77
+ theme like everything else — in the light it is pale stock, a shade of the
78
+ brand rather than a shade of grey (hue 258, between the text's 255 and the
79
+ accent's 264). It was ink in both themes for one afternoon, which made the
80
+ light theme a dark page with a white foot. */
81
+ --ksor-cover: oklch(0.965 0.013 258);
82
+ --ksor-cover-foreground: oklch(0.2 0.03 258);
83
+ --ksor-cover-muted: oklch(0.5 0.022 258);
84
+ --ksor-cover-rule: oklch(0.885 0.016 258);
85
+ --ksor-cover-hover: oklch(0.935 0.018 258);
86
+ --ksor-cover-panel: oklch(1 0 0);
87
+ --ksor-cover-panel-rule: oklch(0.9 0.014 258);
11
88
  }
12
89
 
13
90
  .dark {
14
- --color-fd-primary: #7fb0f9;
15
- --color-fd-primary-foreground: #081226;
16
- --color-fd-ring: #7fb0f9;
91
+ --background: oklch(0.155 0.012 255);
92
+ --foreground: oklch(0.965 0.004 255);
93
+ --card: oklch(0.195 0.012 255);
94
+ --card-foreground: oklch(0.965 0.004 255);
95
+ --popover: oklch(0.195 0.012 255);
96
+ --popover-foreground: oklch(0.965 0.004 255);
97
+ --primary: #7fb0f9;
98
+ --primary-foreground: #081226;
99
+ --secondary: oklch(0.269 0 0);
100
+ --secondary-foreground: oklch(0.985 0 0);
101
+ --muted: oklch(0.24 0.012 255);
102
+ --muted-foreground: oklch(0.7 0.014 255);
103
+ --accent: oklch(0.26 0.014 255);
104
+ --accent-foreground: oklch(0.965 0.004 255);
105
+ --destructive: oklch(0.704 0.191 22.216);
106
+ --border: oklch(1 0 0 / 12%);
107
+ --input: oklch(1 0 0 / 16%);
108
+ --ring: #7fb0f9;
109
+ --chart-1: oklch(0.87 0 0);
110
+ --chart-2: oklch(0.556 0 0);
111
+ --chart-3: oklch(0.439 0 0);
112
+ --chart-4: oklch(0.371 0 0);
113
+ --chart-5: oklch(0.269 0 0);
114
+ --sidebar: oklch(0.185 0.012 255);
115
+ --sidebar-foreground: oklch(0.965 0.004 255);
116
+ --sidebar-primary: oklch(0.488 0.243 264.376);
117
+ --sidebar-primary-foreground: oklch(0.985 0 0);
118
+ --sidebar-accent: oklch(0.26 0.014 255);
119
+ --sidebar-accent-foreground: oklch(0.965 0.004 255);
120
+ --sidebar-border: oklch(1 0 0 / 12%);
121
+ --sidebar-ring: #7fb0f9;
122
+
123
+ /* In the dark the cover rises one step ABOVE the page (0.245 against the
124
+ background's 0.155) rather than inverting: a cover is the surface that
125
+ catches the light. Same object, same lighting, darker room. */
126
+ --ksor-cover: oklch(0.245 0.038 258);
127
+ --ksor-cover-foreground: oklch(0.97 0.006 258);
128
+ --ksor-cover-muted: oklch(0.72 0.022 258);
129
+ --ksor-cover-rule: oklch(0.34 0.03 258);
130
+ --ksor-cover-hover: oklch(0.3 0.035 258);
131
+ --ksor-cover-panel: oklch(0.285 0.036 258);
132
+ --ksor-cover-panel-rule: oklch(0.36 0.03 258);
133
+ }
134
+
135
+ /* The token → utility mapping shadcn components compile against, as
136
+ `shadcn init` writes it MINUS its two font lines: those pointed at a
137
+ `--font-sans` that only exists when a next/font face is loaded in the
138
+ layout, and this scaffold deliberately loads none (see app/layout.tsx —
139
+ a Google face is fetched at BUILD time, which costs the offline build and
140
+ byte-identical rebuilds). Tailwind's own system stack stands. */
141
+ /* ── Three voices ────────────────────────────────────────────────────────────
142
+ The record speaks in a serif, the site speaks in a sans, and the machine
143
+ surface speaks in mono. Not decoration: each face marks WHO is talking, so a
144
+ reader can tell the record's own words (its title, its documents' titles)
145
+ from the site's furniture around them, and both from an address a machine
146
+ resolves.
147
+
148
+ System stacks only. A web font would be fetched at build time, which costs
149
+ the offline build and byte-identical rebuilds the scaffold pins — and the
150
+ browser smoke test asserts zero external requests. Georgia and its siblings
151
+ are on every machine this site is read on, and a serif that arrives instantly
152
+ beats a distinctive one that arrives late. */
153
+ @theme {
154
+ --font-display: ui-serif, Georgia, "Times New Roman", Times, serif;
155
+ }
156
+
157
+ @theme inline {
158
+ --color-sidebar-ring: var(--sidebar-ring);
159
+ --color-sidebar-border: var(--sidebar-border);
160
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
161
+ --color-sidebar-accent: var(--sidebar-accent);
162
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
163
+ --color-sidebar-primary: var(--sidebar-primary);
164
+ --color-sidebar-foreground: var(--sidebar-foreground);
165
+ --color-sidebar: var(--sidebar);
166
+ --color-chart-5: var(--chart-5);
167
+ --color-chart-4: var(--chart-4);
168
+ --color-chart-3: var(--chart-3);
169
+ --color-chart-2: var(--chart-2);
170
+ --color-chart-1: var(--chart-1);
171
+ --color-ring: var(--ring);
172
+ --color-input: var(--input);
173
+ --color-border: var(--border);
174
+ --color-destructive: var(--destructive);
175
+ --color-accent-foreground: var(--accent-foreground);
176
+ --color-accent: var(--accent);
177
+ --color-muted-foreground: var(--muted-foreground);
178
+ --color-muted: var(--muted);
179
+ --color-secondary-foreground: var(--secondary-foreground);
180
+ --color-secondary: var(--secondary);
181
+ --color-primary-foreground: var(--primary-foreground);
182
+ --color-primary: var(--primary);
183
+ --color-popover-foreground: var(--popover-foreground);
184
+ --color-popover: var(--popover);
185
+ --color-card-foreground: var(--card-foreground);
186
+ --color-card: var(--card);
187
+ --color-foreground: var(--foreground);
188
+ --color-background: var(--background);
189
+ --radius-sm: calc(var(--radius) * 0.6);
190
+ --radius-md: calc(var(--radius) * 0.8);
191
+ --radius-lg: var(--radius);
192
+ --radius-xl: calc(var(--radius) * 1.4);
193
+ --radius-2xl: calc(var(--radius) * 1.8);
194
+ --radius-3xl: calc(var(--radius) * 2.2);
195
+ --radius-4xl: calc(var(--radius) * 2.6);
196
+ }
197
+
198
+ @layer base {
199
+ * {
200
+ @apply border-border outline-ring/50;
201
+ }
202
+ body {
203
+ @apply bg-background text-foreground;
204
+ }
205
+ }
206
+
207
+ /* ── The left rail ───────────────────────────────────────────────────────────
208
+ The docs grid is `offset | sidebar | main | toc | offset`, and the sidebar
209
+ panel spans BOTH the offset and the sidebar column: its surface runs to the
210
+ window edge while its contents are pinned to the 268px column (`items-end`,
211
+ children `w-(--fd-sidebar-width)`). Each offset is half of whatever the
212
+ viewport has over `--fd-layout-width`, which the shell defaults to 97rem —
213
+ so on a wide display the offset is real: measured at a 1728px viewport it
214
+ was 88px, and the first nav item began 103px from the window edge with
215
+ nothing but sidebar-coloured space to its left. That reads as a panel
216
+ somebody forgot to align rather than as the window's rail.
217
+
218
+ `100%` leaves the grid definition untouched and takes both offsets to zero,
219
+ so the rail is flush and only the record's own padding stands before the
220
+ first link. The main column absorbs exactly what the offsets held, so the
221
+ prose does not move (measured at 1728px: the article starts at x=548 either
222
+ way). Below 97rem nothing changes — the offsets were already zero. Percent
223
+ rather than `100vw` on purpose: `vw` includes the scrollbar the stable
224
+ gutter below reserves, which would overflow the row by its width. The home
225
+ layout is its own shell and does not use this grid at all. */
226
+ :root {
227
+ --fd-layout-width: 100%;
228
+ }
229
+
230
+ /* ── The reading measure ─────────────────────────────────────────────────────
231
+ The shell caps the article at 900px, which at the body's 16px puts 78
232
+ characters on a line (measured) — half again the 45–75 that reading research
233
+ and every typographic manual settle on, and the reason the page read as a
234
+ slab. 46rem holds the text to 672px, about 66 characters, and the article
235
+ still centres itself in the main column.
236
+
237
+ It is also half of what stops the column moving: the other half is holding
238
+ the table-of-contents column on every page (app/docs/[[...slug]]/page.tsx).
239
+ Sidebar and rail are both 268px, so main is centred in the viewport and this
240
+ measure is centred in main — the same place on a document with headings and
241
+ one without. `data-full` is the shell's own escape hatch for a page that
242
+ asked for the whole width; it keeps it. */
243
+ #nd-page:not([data-full="true"]) {
244
+ max-width: 46rem;
17
245
  }
18
246
 
19
247
  html {
@@ -24,3 +252,318 @@ html > body[data-scroll-locked] {
24
252
  margin-right: 0px !important;
25
253
  --removed-body-scroll-bar-size: 0px !important;
26
254
  }
255
+
256
+ /* The supersession notice's own colour. It used to wear --color-fd-primary,
257
+ which is also the link colour, the primary button and the focus ring — so
258
+ "stop trusting this document" was painted in exactly the blue that means
259
+ "go here". A caution needs a role of its own; this is the only place it is
260
+ used, and the word "Superseded" still carries the meaning without it
261
+ (GOV.UK's rule: never rely on colour alone). */
262
+ /* The caution colour is a TOKEN, declared as a light/dark pair on the root
263
+ rather than inside the one class that first used it. Every surface that has
264
+ to say "the record withdrew this" now spends the same colour — the banner
265
+ over a superseded document, and the chip that marks it in the sidebar, the
266
+ listings, the front door and search. Declared in both themes deliberately:
267
+ a colour written only on `:root` leaks into dark, because `:root` and
268
+ `.dark` have equal specificity and whichever comes later wins. */
269
+ :root {
270
+ --ksor-caution: #9c3f24;
271
+ }
272
+
273
+ .dark {
274
+ --ksor-caution: #e0906e;
275
+ }
276
+
277
+ .ksor-caution {
278
+ border-color: color-mix(in oklab, var(--ksor-caution) 32%, transparent);
279
+ border-left-color: var(--ksor-caution);
280
+ background-color: color-mix(in oklab, var(--ksor-caution) 8%, transparent);
281
+ }
282
+
283
+ .dark .ksor-caution {
284
+ background-color: color-mix(in oklab, var(--ksor-caution) 12%, transparent);
285
+ }
286
+
287
+ /* A withdrawn document's status chip, wherever one renders. A `draft` chip and
288
+ a `superseded` chip were pixel-identical — the same hairline border and the
289
+ same muted text — so the two statuses that mean the most different things
290
+ looked the same at the moment a reader picks between two documents. The
291
+ colour is additive, never the whole signal: the word "superseded" is beside
292
+ it in every one of these places (GOV.UK's rule — never rely on colour alone).
293
+ Unlayered, so it beats the Tailwind utility that sets the chip's border and
294
+ text colour; the banner class above has always worked this way. */
295
+ .ksor-withdrawn {
296
+ border-color: color-mix(in oklab, var(--ksor-caution) 45%, transparent);
297
+ color: var(--ksor-caution);
298
+ }
299
+
300
+ /* A search result whose document carries a caveat status. Appended in CSS so
301
+ the row keeps the shell's own markdown rendering of the search highlights —
302
+ passing children to the item replaces that, and the <mark> tags leak into
303
+ the dialog as literal text. */
304
+ [data-ksor-status]::after {
305
+ content: attr(data-ksor-status);
306
+ margin-inline-start: auto;
307
+ align-self: center;
308
+ flex-shrink: 0;
309
+ border: 1px solid var(--color-fd-border);
310
+ border-radius: 0.25rem;
311
+ padding: 0.05rem 0.35rem;
312
+ font-size: 0.65rem;
313
+ font-weight: 500;
314
+ color: var(--color-fd-muted-foreground);
315
+ }
316
+
317
+ /* …and in search too, where a reader is choosing between results. The chip is
318
+ drawn by `content`, so the tone has to be set on the same pseudo-element. */
319
+ [data-ksor-status="superseded"]::after {
320
+ border-color: color-mix(in oklab, var(--ksor-caution) 45%, transparent);
321
+ color: var(--ksor-caution);
322
+ }
323
+
324
+ /* ── The document's type ramp ────────────────────────────────────────────────
325
+ The shell sizes the title at 1.75em — 28px against 16px body, with normal
326
+ tracking. Measured against the description at 18px, that is four shades of
327
+ grey text rather than a document with structure: the title barely leads and
328
+ the description competes with it. The title takes the lead it should have,
329
+ the description steps back under it, and the body is left alone at 16/28,
330
+ which is right for long-form reading. Scoped to the shell's own page id, so
331
+ nothing depends on which order two arbitrary Tailwind sizes land in. */
332
+ #nd-page > h1 {
333
+ font-family: var(--font-display);
334
+ font-size: 2.375rem;
335
+ font-weight: 600;
336
+ line-height: 1.1;
337
+ /* Serifs need less negative tracking than the sans did at this size — the
338
+ -0.021em that suited the old face closes the counters on this one. */
339
+ letter-spacing: -0.012em;
340
+ text-wrap: balance;
341
+ }
342
+
343
+ #nd-page > p:first-of-type {
344
+ font-size: 1.0625rem;
345
+ line-height: 1.6;
346
+ text-wrap: pretty;
347
+ }
348
+
349
+ /* The section headings inside a document. The shell styles the title and
350
+ leaves the body's headings to its prose defaults — 30 / 24 / 20 / 16px for
351
+ h1-h4 in the SANS body face, and nothing at all for h5-h6, which the prose
352
+ plugin never defines and preflight resets to inherit (read out of the built
353
+ stylesheet, 2026-08-22). Two things were wrong with that.
354
+
355
+ Voice: a section heading is the RECORD speaking, exactly like the title
356
+ above it, so it belongs in the display serif. Left in sans, a single
357
+ document spoke in two voices and the change of voice meant nothing —
358
+ which is the one thing the three faces on this site exist to carry.
359
+
360
+ Scale: h4 came out at 16px/600 — the body size — and h5/h6 at 16px/400 with
361
+ NO margins, which is a paragraph. The ramp is 38 / 32 / 28 / 22 / 18 / 16 —
362
+ the page title, then h1 down to h6 — against a 16px body. The size step runs
363
+ out at h5, so below it the serif, the weight and the air above it are what
364
+ make a heading.
365
+
366
+ A body `# heading` should not exist — the frontmatter title IS the page's
367
+ h1 (scaffolded AGENTS.md) — but nothing enforces it and this repo's own
368
+ fixture corpus opens one (workbench/example-corpus/knowledge/about.md:9).
369
+ `#nd-page > h1` is a CHILD combinator, so it reached the page title only,
370
+ and a body h1 fell through both rules to 30px sans weight 800: the loudest
371
+ thing on the page, in the site's voice, for the record's own words.
372
+
373
+ Scoped INSIDE `.prose`, which is the container Fumadocs wraps the document's
374
+ own markdown in — the record's words, and nothing else. Scoped to the page
375
+ instead, this rule reached the site's own headings too and blew up
376
+ `Provenance`'s deliberately small mono "Sources" label into a 28px serif
377
+ heading (caught by measuring the rendered page, 2026-08-22): an id beats the
378
+ Tailwind utilities a component sets on itself, so the component lost an
379
+ argument it did not know it was having.
380
+
381
+ Specificity, not luck: `#nd-page .prose` (1,1,0) beats the prose rule's
382
+ single class (0,1,0), because that selector wraps its element in `:where()`,
383
+ which contributes nothing. */
384
+ #nd-page .prose :is(h1, h2, h3, h4, h5, h6) {
385
+ font-family: var(--font-display);
386
+ font-weight: 600;
387
+ letter-spacing: -0.008em;
388
+ text-wrap: balance;
389
+ }
390
+
391
+ /* Space above a heading, not below it: a heading belongs to the text it
392
+ introduces. The prose defaults set both in `em`, so raising the sizes above
393
+ would have scaled the gaps with them and left h2 floating on 56px of air. */
394
+ #nd-page .prose h1 {
395
+ font-size: 2rem;
396
+ line-height: 1.2;
397
+ margin-top: 3rem;
398
+ margin-bottom: 1rem;
399
+ }
400
+
401
+ #nd-page .prose h2 {
402
+ font-size: 1.75rem;
403
+ line-height: 1.25;
404
+ margin-top: 2.75rem;
405
+ margin-bottom: 0.875rem;
406
+ }
407
+
408
+ #nd-page .prose h3 {
409
+ font-size: 1.375rem;
410
+ line-height: 1.35;
411
+ margin-top: 2rem;
412
+ margin-bottom: 0.625rem;
413
+ }
414
+
415
+ /* The floor of the ramp. The size step is spent here, so weight and air do
416
+ the work: at 16px/400 with no margins at all, a fifth-level heading was
417
+ byte-identical to the paragraph under it and glued to the one above. */
418
+ #nd-page .prose :is(h5, h6) {
419
+ font-size: 1rem;
420
+ line-height: 1.5;
421
+ letter-spacing: 0;
422
+ margin-top: 1.5rem;
423
+ margin-bottom: 0.375rem;
424
+ }
425
+
426
+ #nd-page .prose h4 {
427
+ font-size: 1.125rem;
428
+ line-height: 1.45;
429
+ /* No negative tracking this close to the body size — it only closes the
430
+ counters without buying any apparent tightness. */
431
+ letter-spacing: 0;
432
+ margin-top: 1.75rem;
433
+ margin-bottom: 0.5rem;
434
+ }
435
+
436
+ /* ── The document's furniture ────────────────────────────────────────────────
437
+ Everything under the headings: links, emphasis, quotes, tables, code. The
438
+ shell leaves nearly all of it at one weight of one ink, which is why a page
439
+ of real documentation read as a wall of black (owner, 2026-08-22). Measured
440
+ before touching anything: a link inside a paragraph rendered lab(4.41) at
441
+ weight 500 — the SAME colour and weight as the `<strong>` beside it, told
442
+ apart only by an underline. A reader cannot see what is clickable.
443
+
444
+ Every rule below is scoped to `.prose`, the container the record's own
445
+ markdown renders into, so the site's own furniture keeps its styling. */
446
+
447
+ /* A link is what the accent is FOR. Only in running text: a heading carries an
448
+ anchor link around its own text, and colouring those turns every heading
449
+ blue — which is the same mistake in the other direction. */
450
+ #nd-page .prose :is(p, li, td, th, blockquote) a {
451
+ color: var(--color-fd-primary);
452
+ text-decoration-color: color-mix(in oklab, var(--color-fd-primary) 30%, transparent);
453
+ text-underline-offset: 3px;
454
+ transition: text-decoration-color 150ms;
455
+ }
456
+
457
+ #nd-page .prose :is(p, li, td, th, blockquote) a:hover {
458
+ text-decoration-color: var(--color-fd-primary);
459
+ }
460
+
461
+ /* Emphasis is weight, not colour. The prose default sets 500 against a 400
462
+ body, which at 16px is not a difference a reader notices. */
463
+ #nd-page .prose strong {
464
+ font-weight: 650;
465
+ }
466
+
467
+ /* A table is a register: its head is a column label, so it speaks in the mono
468
+ voice every other label on this site uses, and the rows are separated by
469
+ hairlines rather than by nothing. The prose default left `tbody tr` with a
470
+ 0px bottom border — the cells simply floated. */
471
+ #nd-page .prose thead th {
472
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
473
+ font-size: 0.6875rem;
474
+ font-weight: 500;
475
+ letter-spacing: 0.14em;
476
+ text-transform: uppercase;
477
+ color: var(--color-fd-muted-foreground);
478
+ }
479
+
480
+ #nd-page .prose tbody tr {
481
+ border-bottom: 1px solid var(--color-fd-border);
482
+ }
483
+
484
+ #nd-page .prose tbody tr:last-child {
485
+ border-bottom: 0;
486
+ }
487
+
488
+ /* A quotation is an aside, so it steps back rather than shouting in full ink.
489
+ Italic at 16px in a system sans is a default, not a choice — and it is the
490
+ least readable setting of the three things a blockquote could be. */
491
+ #nd-page .prose blockquote {
492
+ font-style: normal;
493
+ color: var(--color-fd-muted-foreground);
494
+ }
495
+
496
+ /* Inline code is the record's machine vocabulary — a frontmatter key, a path,
497
+ a command — and it was set in the same ink as the prose around it, so the
498
+ grey chip carried the whole distinction (owner, 2026-08-22).
499
+
500
+ It takes a colour of its own rather than one already spoken for. The accent
501
+ belongs to links and actions, and inline code is neither — tinting it blue
502
+ would make every key look clickable, which is the defect one rule up in
503
+ reverse. `--ksor-caution` is the other coloured thing here and it means "the
504
+ record withdrew this". So: a third token, a deep teal, far enough from the
505
+ accent's hue to be told apart at 13px and cool enough to sit in a palette
506
+ built on blue ink. A pair, like every colour on this site. */
507
+ :root {
508
+ --ksor-code: #0f6f6a;
509
+ }
510
+
511
+ .dark {
512
+ --ksor-code: #6fd8c8;
513
+ }
514
+
515
+ #nd-page .prose :not(pre) > code {
516
+ color: var(--ksor-code);
517
+ background: color-mix(in oklab, var(--ksor-code) 7%, var(--color-fd-muted));
518
+ border-color: color-mix(in oklab, var(--ksor-code) 22%, transparent);
519
+ }
520
+
521
+ /* A code block has to look like a block. Fumadocs paints its surface with
522
+ `bg-fd-card`, and `--card` in this palette is all but the page colour — the
523
+ measured difference was under 2% lightness, so a fenced block read as loose
524
+ monospace text with a faint outline. `--muted` is the token that means
525
+ "a surface ON the page", and it is a pair, so this holds in both themes. */
526
+ #nd-page .prose figure.shiki {
527
+ background: var(--color-fd-muted);
528
+ }
529
+
530
+ /* Re-assert the shell's own reset, which the ramp above broke. The prose
531
+ plugin zeroes the first block's top margin, but writes that rule as
532
+ `.prose :where(> :first-child)`, and `:where()` contributes nothing to
533
+ specificity — so every heading rule above beat it, and a document whose body
534
+ OPENED with a heading started 44px lower than one that opened with a
535
+ sentence (measured, 2026-08-22). The top of a document must not move with
536
+ the author's choice of first block. This selector is (1,2,0) against the
537
+ heading rules' (1,1,1), so it takes the argument back. */
538
+ #nd-page .prose > :first-child {
539
+ margin-top: 0;
540
+ }
541
+
542
+ /* ── The page pager ──────────────────────────────────────────────────────────
543
+ Fumadocs lays the neighbours out as a grid that becomes two columns when both
544
+ exist. With only one — the first or last document of the record — it stays a
545
+ single full-width column, so a lone "next" stretched the whole row with its
546
+ text jammed against the right edge and read as a broken card. Hold it to half
547
+ the row, on the side it points to: the next card is the one the shell marks
548
+ `text-end`. Full width on a narrow screen, where one column is correct. */
549
+ @media (min-width: 48rem) {
550
+ #nd-page > [class*="@container"]:has(> a:only-child) {
551
+ grid-template-columns: repeat(2, minmax(0, 1fr));
552
+ }
553
+
554
+ #nd-page > [class*="@container"] > a:only-child[class*="text-end"] {
555
+ grid-column: 2;
556
+ }
557
+ }
558
+
559
+ /* …and it sits at the foot of the page whatever the document's length. The
560
+ article is a flex column stretched to the viewport, so on a short document
561
+ the neighbours used to land wherever the text stopped — 265px above the
562
+ bottom edge on `/docs/policies`, measured — which reads as content, not as
563
+ the end of the page. `margin-top: auto` gives the free space to the gap
564
+ above them instead. It is inert once the document is taller than the
565
+ viewport: with no free space the auto margin is zero, so a long document
566
+ keeps the shell's own 16px gap (verified both ways at 863px of viewport). */
567
+ #nd-page > [class*="@container"]:last-child {
568
+ margin-top: auto;
569
+ }
@@ -2,6 +2,8 @@ import { RootProvider } from "fumadocs-ui/provider/next";
2
2
  import "./global.css";
3
3
  import type { Metadata } from "next";
4
4
  import { appTitle } from "@/lib/shared";
5
+ import { basePath, caveatStatusByUrl } from "@/lib/source";
6
+ import KsorSearchDialog from "@/components/search-dialog";
5
7
 
6
8
  // No next/font/google: it fetches the face from Google at BUILD time, so a
7
9
  // scaffolded project could not build offline and two builds of one commit
@@ -21,16 +23,33 @@ export default function Layout({ children }: LayoutProps<"/">) {
21
23
  return (
22
24
  <html lang="en" suppressHydrationWarning>
23
25
  <body className="flex flex-col min-h-screen">
26
+ {/* Which documents carry a caveat status, for the search dialog — it
27
+ runs in the browser over a static index that has no field for it.
28
+ Delivered in the document rather than as a dialog prop because
29
+ RootProvider types `options` against the SHIPPED dialog's props, and
30
+ casting that away would hide a real break the day those props move.
31
+ `<` is escaped: a title or route is authored content, and closing
32
+ this tag early would be script injection from the record. */}
33
+ <script
34
+ type="application/json"
35
+ id="ksor-statuses"
36
+ dangerouslySetInnerHTML={{
37
+ __html: JSON.stringify(caveatStatusByUrl()).replaceAll("<", "\\u003c"),
38
+ }}
39
+ />
24
40
  <RootProvider
25
41
  search={{
26
42
  // Static search: the browser downloads the index that
27
43
  // app/api/search exports at build time (staticGET) and runs
28
44
  // Orama client-side — no server needed, so search keeps
29
45
  // working on any static host.
30
- options: {
31
- type: "static",
32
- api: `${process.env.KSOR_BASE_PATH ?? ""}/api/search`,
33
- },
46
+ //
47
+ // Our own dialog, composed from the shell's primitives, so a
48
+ // withdrawn document is marked in the RESULTS too — the last
49
+ // surface where it looked identical to the one that replaced it,
50
+ // and the one whose snippet quotes its obsolete figures.
51
+ SearchDialog: KsorSearchDialog,
52
+ options: { api: `${basePath}/api/search` },
34
53
  }}
35
54
  >
36
55
  {children}
@@ -3,8 +3,10 @@ import { getLLMText, getSortedPages } from "@/lib/source";
3
3
  export const revalidate = false;
4
4
 
5
5
  export async function GET(): Promise<Response> {
6
- const scan = getSortedPages().map(getLLMText);
7
- const scanned = await Promise.all(scan);
6
+ // The whole set is threaded through so each document's successor pointer
7
+ // resolves to a route rather than the `./x.md` a consumer cannot follow.
8
+ const pages = getSortedPages();
9
+ const scanned = await Promise.all(pages.map((page) => getLLMText(page, pages)));
8
10
 
9
11
  return new Response(scanned.join("\n\n"));
10
12
  }
@@ -1,15 +1,17 @@
1
- import { basePath, getSortedPages } from "@/lib/source";
2
- import { appName } from "@/lib/shared";
1
+ import { recordIndexText } from "@/lib/source";
3
2
 
4
3
  export const revalidate = false;
5
4
 
6
5
  // The agent-facing index of the record: this instance's name, then every
7
- // document in sidebar order, each link usable as-is on a sub-path host.
6
+ // document in sidebar order, each link usable as-is on a sub-path host — and
7
+ // each carrying its governance when the governance is a caveat.
8
+ //
9
+ // Without that last part a withdrawn document and the one that replaced it are
10
+ // two adjacent entries told apart only by whatever a human happened to type
11
+ // into a title, and an agent picks either (research/site-design.md F1).
12
+ //
13
+ // The bytes are built in lib/source (`recordIndexText`) because the home page
14
+ // shows this same index to a reader — one index, one spelling.
8
15
  export function GET(): Response {
9
- const lines = getSortedPages().map((page) => {
10
- const link = `- [${page.data.title}](${basePath}${page.url})`;
11
- return page.data.description ? `${link}: ${page.data.description}` : link;
12
- });
13
-
14
- return new Response(`# ${appName}\n\n${lines.join("\n")}\n`);
16
+ return new Response(recordIndexText());
15
17
  }