@panaversity/ksor 0.0.39 → 0.0.41

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 (108) hide show
  1. package/CHANGELOG.md +965 -0
  2. package/README.md +29 -9
  3. package/dist/checker/check-main.mjs +14049 -0
  4. package/dist/cli.mjs +11528 -5213
  5. package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
  6. package/dist/gateway.d.mts +52 -13
  7. package/dist/gateway.mjs +2 -2
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
  11. package/docs/authorization.md +8 -6
  12. package/docs/deploying.md +36 -25
  13. package/docs/index.md +26 -13
  14. package/docs/ingesting.md +70 -22
  15. package/docs/tool-surface.md +69 -16
  16. package/package.json +4 -3
  17. package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
  18. package/schema/schema.sql +77 -14
  19. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
  20. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
  21. package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
  22. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
  23. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
  24. package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
  25. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
  26. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
  27. package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
  28. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
  29. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
  30. package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
  31. package/templates/scaffold/.github/workflows/validate.yml +9 -1
  32. package/templates/scaffold/.ksor/governance.yaml +17 -0
  33. package/templates/scaffold/AGENTS.md +293 -119
  34. package/templates/scaffold/Dockerfile +5 -1
  35. package/templates/scaffold/README.md +160 -42
  36. package/templates/scaffold/env.example +37 -6
  37. package/templates/scaffold/gitignore +14 -6
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +11 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
  42. package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
  43. package/templates/scaffold/knowledge/surfaces/index.md +4 -20
  44. package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
  45. package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
  46. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
  47. package/templates/scaffold/package.json +3 -4
  48. package/templates/scaffold/pnpm-lock.yaml +3 -0
  49. package/templates/scaffold/system/gateways/content.ts +13 -0
  50. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  51. package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
  52. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
  53. package/templates/scaffold/system/site/app/global.css +198 -6
  54. package/templates/scaffold/system/site/app/layout.tsx +8 -3
  55. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
  56. package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
  57. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
  58. package/templates/scaffold/system/site/components/code-block.tsx +87 -0
  59. package/templates/scaffold/system/site/components/embed.tsx +276 -0
  60. package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
  61. package/templates/scaffold/system/site/components/governance.tsx +219 -87
  62. package/templates/scaffold/system/site/components/mdx.tsx +10 -2
  63. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  64. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  65. package/templates/scaffold/system/site/components/record-views.tsx +6 -17
  66. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  67. package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
  68. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  69. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  70. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  71. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  72. package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
  73. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  74. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  75. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  76. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  77. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  78. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  79. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  80. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  81. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  82. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  83. package/templates/scaffold/system/site/lib/source.ts +256 -186
  84. package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
  85. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  86. package/templates/scaffold/system/site/package.json +1 -0
  87. package/templates/scaffold/system/site/record/actor.ts +23 -0
  88. package/templates/scaffold/system/site/record/check.ts +571 -0
  89. package/templates/scaffold/system/site/record/citations.ts +312 -0
  90. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  91. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  92. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  93. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  94. package/templates/scaffold/system/site/record/index.ts +103 -0
  95. package/templates/scaffold/system/site/record/instance.ts +257 -0
  96. package/templates/scaffold/system/site/record/instant.ts +43 -0
  97. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  98. package/templates/scaffold/system/site/record/load.ts +129 -0
  99. package/templates/scaffold/system/site/record/lock.ts +306 -0
  100. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  101. package/templates/scaffold/system/site/record/policy.ts +414 -0
  102. package/templates/scaffold/system/site/record/profile.ts +535 -0
  103. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  104. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  105. package/templates/scaffold/system/site/source.config.ts +109 -23
  106. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  107. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  108. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -1,21 +1,29 @@
1
1
  import Link from "next/link";
2
2
 
3
3
  import { DocumentActions } from "@/components/document-actions";
4
+ import { Clock } from "lucide-react";
4
5
  import type { ReactElement } from "react";
5
6
 
6
7
  import {
7
- caveatStatus,
8
- statusTone,
8
+ badgeAddsToStatus,
9
+ badgeText,
10
+ badgeTone,
11
+ dayOf,
9
12
  isCalendarDate,
13
+ plainBadge,
10
14
  sourceHref,
15
+ statusLabel,
16
+ statusTone,
17
+ trustSignal,
11
18
  type DocumentGovernance,
12
19
  } from "@/lib/governance";
20
+ import type { LifecycleBadge } from "@/lib/lifecycle-rule";
13
21
 
14
22
  /**
15
23
  * What the record says about the document you are reading.
16
24
  *
17
25
  * The site renders the record; these render the record's governance — the
18
- * frontmatter `pnpm check` enforces on every document. Nothing here is
26
+ * profile's frontmatter `pnpm check` enforces on every concept. Nothing here is
19
27
  * authored in the site (critical rule 1) and nothing is inferred: a key the
20
28
  * document does not declare renders nothing at all.
21
29
  *
@@ -26,9 +34,9 @@ import {
26
34
  */
27
35
 
28
36
  /**
29
- * The successor of a superseded document: its route and the text naming it.
30
- * `href` is null when the pointer could not be resolved to a route, and then
31
- * the pointer itself is shown as text rather than as a dead link.
37
+ * The successor of a deprecated document: its route and the text naming it.
38
+ * `href` is null when the pointer could not be resolved to a route in this
39
+ * build, and then the pointer itself is shown as text rather than as a dead link.
32
40
  */
33
41
  export interface Successor {
34
42
  readonly href: string | null;
@@ -36,11 +44,12 @@ export interface Successor {
36
44
  }
37
45
 
38
46
  /**
39
- * The supersession notice. Deliberately the first thing on the page, above the
47
+ * The deprecation notice. Deliberately the first thing on the page, above the
40
48
  * title: a reader must not have to notice a subtle badge to learn that what
41
- * they are about to read has been replaced.
49
+ * they are about to read has been withdrawn. `successor` is null when the
50
+ * record deprecated the document without naming a replacement.
42
51
  */
43
- export function SupersededNotice({ successor }: { successor: Successor }): ReactElement {
52
+ export function DeprecatedNotice({ successor }: { successor: Successor | null }): ReactElement {
44
53
  return (
45
54
  <aside
46
55
  // A landmark, not a note: GOV.UK ships this as role="region" with
@@ -48,7 +57,7 @@ export function SupersededNotice({ successor }: { successor: Successor }): React
48
57
  // thing on the page by landmark, rather than only meeting it in reading
49
58
  // order. role="note" is announced but not navigable.
50
59
  role="region"
51
- aria-labelledby="ksor-superseded"
60
+ aria-labelledby="ksor-deprecated"
52
61
  // Tinted and ruled down the left edge in the CAUTION role, never
53
62
  // --color-fd-muted: the shipped light theme defines --color-fd-muted and
54
63
  // --color-fd-background as the same value, so the callout composited to
@@ -56,22 +65,29 @@ export function SupersededNotice({ successor }: { successor: Successor }): React
56
65
  // Chromium, 2026-08-20). The one thing this notice cannot be is missable.
57
66
  className="ksor-caution mb-8 rounded-lg border border-l-4 px-4 py-3 text-sm"
58
67
  >
59
- <p id="ksor-superseded" className="font-medium text-fd-foreground">
60
- Superseded
68
+ <p id="ksor-deprecated" className="font-medium text-fd-foreground">
69
+ Deprecated
61
70
  </p>
62
71
  <p className="mt-1 text-fd-muted-foreground">
63
- This document has been replaced by{" "}
64
- {successor.href === null ? (
65
- <code className="break-words text-fd-foreground">{successor.label}</code>
72
+ {successor === null ? (
73
+ "The record has withdrawn this document."
66
74
  ) : (
67
- <Link
68
- href={successor.href}
69
- className="font-medium text-fd-foreground underline underline-offset-4 transition-colors hover:text-fd-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
70
- >
71
- {successor.label}
72
- </Link>
73
- )}
74
- . It is kept because the record never deletes what it replaces.
75
+ <>
76
+ This document has been replaced by{" "}
77
+ {successor.href === null ? (
78
+ <code className="break-words text-fd-foreground">{successor.label}</code>
79
+ ) : (
80
+ <Link
81
+ href={successor.href}
82
+ className="font-medium text-fd-foreground underline underline-offset-4 transition-colors hover:text-fd-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
83
+ >
84
+ {successor.label}
85
+ </Link>
86
+ )}
87
+ .
88
+ </>
89
+ )}{" "}
90
+ It is kept because the record never deletes what it replaces.
75
91
  </p>
76
92
  </aside>
77
93
  );
@@ -88,13 +104,10 @@ function Fact({
88
104
  <div className="flex items-baseline gap-2.5">
89
105
  {/* Mono, uppercase, letterspaced: these are the record's checkable facts,
90
106
  and they read as a register's column heads rather than as a form.
91
-
92
107
  The label is deliberately SMALLER and more letterspaced than the value
93
- it introduces. Both used to be mono a single pixel apart — 11px label
94
- against a 12px value so "Owner Product Effective 2026-08-22" read as
95
- one undifferentiated mono run rather than as two facts with names
96
- (reported by the owner, 2026-08-22, quoting the run back verbatim).
97
- The step is now 10px against 13px, and the value carries the weight. */}
108
+ it introduces (10px against 13px), so "Owner Product Effective
109
+ 2026-08-22" reads as two facts with names rather than one mono run
110
+ (reported by the owner, 2026-08-22). */}
98
111
  <dt className="font-mono text-[0.625rem] tracking-[0.18em] text-fd-muted-foreground uppercase">
99
112
  {label}
100
113
  </dt>
@@ -105,44 +118,163 @@ function Fact({
105
118
  );
106
119
  }
107
120
 
121
+ /** One chip: a word the record says about this document, in the register's voice. */
122
+ function Chip({ text, tone = "" }: { text: string; tone?: string }): ReactElement {
123
+ return (
124
+ <span
125
+ className={`rounded-sm border border-fd-border px-1.5 py-0.5 tracking-widest whitespace-nowrap uppercase ${tone}`}
126
+ >
127
+ {text}
128
+ </span>
129
+ );
130
+ }
131
+
132
+ /** The chip a badge wears, in every listing and on the page. */
133
+ export function BadgeChip({
134
+ badge,
135
+ effectiveFrom = null,
136
+ }: {
137
+ badge: LifecycleBadge;
138
+ /** `ksor.effective_from`, which fills record spec §2.5's "effective from …". */
139
+ effectiveFrom?: string | null;
140
+ }): ReactElement {
141
+ return <Chip text={badgeText(badge, effectiveFrom) ?? ""} tone={badgeTone(badge)} />;
142
+ }
143
+
108
144
  /**
109
- * The one-line governance strip under the document's title: any caveat on its
110
- * status, who stands behind it, and when it took effect.
145
+ * The lifecycle caveat ALONE, for a page with no governance strip under its
146
+ * title a record whose `site.governance` is off.
147
+ *
148
+ * That key turns off attribution, not caveats: the deprecation notice has
149
+ * always survived it, and the sidebar row, the folder card and the search
150
+ * result for this same document carry their badge whatever it says. The rule
151
+ * for which states reach here is `plainBadge`, beside the vocabulary itself.
152
+ *
153
+ * Typed like the strip's own values (mono, 13px), because a badge is the
154
+ * record speaking about a document and that is the voice it speaks in
155
+ * everywhere else.
156
+ */
157
+ export function LifecycleCaveat({
158
+ badge,
159
+ effectiveFrom = null,
160
+ }: {
161
+ badge: LifecycleBadge | null;
162
+ effectiveFrom?: string | null;
163
+ }): ReactElement | null {
164
+ const caveat = plainBadge(badge);
165
+ if (caveat === null) return null;
166
+ return (
167
+ <div className="mb-7 font-mono text-[0.8125rem] font-medium text-fd-foreground">
168
+ <BadgeChip badge={caveat} effectiveFrom={effectiveFrom} />
169
+ </div>
170
+ );
171
+ }
172
+
173
+ /**
174
+ * The one-line governance strip under the document's title: what the record
175
+ * says about this document, and who said it.
176
+ *
177
+ * Two kinds of thing sit here and they are drawn differently on purpose. The
178
+ * CHIPS are states — the lifecycle status, and the date badge when the calendar
179
+ * keeps an otherwise current document off the machine surfaces (record spec
180
+ * §2.5) — and the FACTS are attributions: who owns it, who approved it, who
181
+ * verified it and when. A reader deciding whether to act on a document needs
182
+ * both halves, and the predecessor's failure was showing neither.
111
183
  */
112
184
  export function GovernanceMeta({
113
185
  governance,
186
+ badge,
114
187
  replaces = [],
115
188
  markdownUrl,
189
+ minutes,
116
190
  }: {
117
191
  governance: DocumentGovernance;
192
+ /** Why the machine surfaces decline this page, or null. */
193
+ badge: LifecycleBadge | null;
118
194
  /** Documents this one replaced — derived from the record, never declared. */
119
195
  replaces?: readonly Successor[];
120
- /** The document's markdown twin, offered beside its governance. */
196
+ /** The document's markdown twin, offered beside its governance — only where one exists. */
121
197
  markdownUrl?: string;
122
- }): ReactElement | null {
123
- const { owner, effective } = governance;
124
- const status = caveatStatus(governance.status);
125
- const bare = status === null && owner === null && effective === null && replaces.length === 0;
126
- if (bare && markdownUrl === undefined) return null;
198
+ /**
199
+ * How long the document takes to read, when this row is the only place for
200
+ * it a document with a summary shows it on that view's own strip instead,
201
+ * because there the number belongs to the view you picked.
202
+ */
203
+ minutes?: number;
204
+ }): ReactElement {
205
+ const { status, owner, effectiveFrom, staleAfter, approval, deprecated } = governance;
206
+ const state = statusLabel(status);
207
+ // The badge is a SECOND chip only where it says something the status does
208
+ // not: `draft` and `deprecated` are both words, and printing either twice
209
+ // reads as two facts about one document.
210
+ const alsoBadge = badgeAddsToStatus(badge, status) ? badge : null;
211
+ const trust = trustSignal(governance.verified);
212
+ // …and where the badge carries the date, the fact beside it would repeat it.
213
+ const showEffective = effectiveFrom !== null && alsoBadge !== "effective-from";
127
214
 
215
+ // There is no "nothing to show" case any more: every concept has a trust
216
+ // tier, `unverified` included, and that is the whole point of printing it.
217
+ // The early return this replaced would have hidden the tier on exactly the
218
+ // documents whose tier is the only governance fact they have.
128
219
  return (
129
220
  <dl className="mb-7 flex flex-wrap items-baseline gap-x-8 gap-y-2.5 border-b border-fd-border pb-4">
130
- {status === null ? null : (
221
+ {state === null && alsoBadge === null ? null : (
131
222
  <Fact label="Status">
132
- <span
133
- className={`rounded-sm border border-fd-border px-1.5 py-0.5 tracking-widest uppercase ${statusTone(status)}`}
134
- >
135
- {status}
223
+ <span className="flex flex-wrap items-baseline gap-1.5">
224
+ {state === null ? null : <Chip text={state} tone={statusTone(status)} />}
225
+ {alsoBadge === null ? null : (
226
+ <BadgeChip badge={alsoBadge} effectiveFrom={effectiveFrom} />
227
+ )}
136
228
  </span>
137
229
  </Fact>
138
230
  )}
231
+ {/* The tier OKF's own vocabulary names, on every document including the
232
+ unverified ones — that is the honest state of a stable, approved
233
+ concept nobody has reviewed, and hiding it would leave a reader unable
234
+ to tell "checked" from "never mentioned" (research/okf-native.md
235
+ §1.1). Never a colour: a tier is a fact about review, not a warning. */}
236
+ <Fact label="Trust">
237
+ <span className="flex flex-wrap items-baseline gap-1.5">
238
+ <Chip text={trust.tier} />
239
+ {trust.by === null ? null : (
240
+ <span className="font-normal text-fd-muted-foreground">
241
+ {trust.by}
242
+ {trust.at === null ? null : <> · {day(trust.at)}</>}
243
+ </span>
244
+ )}
245
+ </span>
246
+ </Fact>
139
247
  {owner === null ? null : <Fact label="Owner">{owner}</Fact>}
248
+ {/* Who let this into the record. `ksor.approval` is what makes a `stable`
249
+ document stable at all (record spec §2.2), so a page that showed the
250
+ word and not the signature would be publishing the claim without its
251
+ author. */}
252
+ {approval === null ? null : (
253
+ <Fact label="Approved">
254
+ <>
255
+ {approval.by} · {day(approval.at)}
256
+ </>
257
+ </Fact>
258
+ )}
259
+ {/* found live 2026-08-25: a deprecated page named its successor and said
260
+ nothing about WHO withdrew it, though `ksor.deprecated` is required on
261
+ every deprecated concept (record spec §2.2) and readGovernance already
262
+ refuses a document that omits it. Withdrawal is the most consequential
263
+ act in a document's life; publishing it unattributed is exactly the
264
+ gap the approver fact above closes at the other end. */}
265
+ {deprecated === null ? null : (
266
+ <Fact label="Withdrawn">
267
+ <>
268
+ {deprecated.by} · {day(deprecated.at)}
269
+ </>
270
+ </Fact>
271
+ )}
140
272
  {replaces.length === 0 ? null : (
141
273
  // The other half of a supersession. The withdrawn document names its
142
274
  // successor above the title; this is the successor naming what it
143
275
  // replaced, so the history the record kept is reachable from the
144
276
  // current document instead of only from the retired one.
145
- <Fact label={replaces.length === 1 ? "Replaces" : "Replaces"}>
277
+ <Fact label="Replaces">
146
278
  <>
147
279
  {replaces.map((entry, index) => (
148
280
  <span key={entry.href ?? `${index}-${entry.label}`}>
@@ -162,77 +294,77 @@ export function GovernanceMeta({
162
294
  </>
163
295
  </Fact>
164
296
  )}
165
- {effective === null ? null : (
166
- <Fact label="Effective">
167
- {/* The machine attribute is stamped only for a real day on the
168
- calendar. A SHAPE test was not enough: the checker's own remedy
169
- for `2026-06-31` is to QUOTE it, and quoted text arrives here — so
170
- a shape test published `<time dateTime="2026-06-31">`, which is
171
- invalid HTML and which a consumer reads as July 1st. That is the
172
- precise hazard the record's date rule exists to prevent. */}
173
- {isCalendarDate(effective) ? (
174
- <time dateTime={effective}>{effective}</time>
175
- ) : (
176
- <span>{effective}</span>
177
- )}
178
- </Fact>
297
+ {effectiveFrom === null || !showEffective ? null : (
298
+ <Fact label="Effective from">{day(effectiveFrom)}</Fact>
179
299
  )}
300
+ {staleAfter === null ? null : <Fact label="Review by">{day(staleAfter)}</Fact>}
180
301
  {markdownUrl === undefined ? null : (
181
302
  // On the governance row, not as a footnote below the sources: it is
182
- // how a reader hands this document to an agent, and it was previously
183
- // the smallest text on the page, last (research/site-design.md F2).
184
- // ONE control rather than two: opening the markdown and copying it are
185
- // different acts, but two bare controls on a row of read-only facts
186
- // made the row look half clickable. It wears neither the bordered badge
187
- // (that means "a status the record declares") nor the accent at rest —
188
- // the page had gone blue enough that the accent had stopped meaning
189
- // anything (owner, 2026-08-22).
190
- // RIGHT-ALIGNED (owner, and a reversal). The 2026-08-21 finding stands
191
- // on its own terms: `ms-auto` once parked this 498px from the nearest
192
- // thing on a two-fact document, where it read as belonging to nothing.
193
- // What changed is the column beside it — the reading time now sits at
194
- // the right end of the strip directly below, so the far edge is no
195
- // longer empty space but a line the eye already follows. The two form
196
- // a right-hand column of things you DO, against a left-hand row of
197
- // things the record DECLARES.
303
+ // how a reader hands this document to an agent (research/site-design.md
304
+ // F2). Right-aligned: a column of things you DO, against a row of
305
+ // things the record DECLARES (owner, 2026-08-22).
198
306
  <span className="ms-auto">
199
307
  <DocumentActions href={markdownUrl} />
200
308
  </span>
201
309
  )}
310
+ {minutes === undefined ? null : (
311
+ <div className="ms-auto flex items-center gap-2 text-sm text-fd-muted-foreground">
312
+ <Clock aria-hidden className="size-3.5 shrink-0" />
313
+ <span>{minutes} min read</span>
314
+ </div>
315
+ )}
202
316
  </dl>
203
317
  );
204
318
  }
205
319
 
320
+ /** An instant as a day, stamped as machine-readable only when it is a real one. */
321
+ function day(instant: string): ReactElement {
322
+ const value = dayOf(instant);
323
+ return isCalendarDate(value) ? <time dateTime={value}>{value}</time> : <span>{value}</span>;
324
+ }
325
+
206
326
  /**
207
- * Where the document came from.
208
- *
209
- * One entry per source, each independently visible that is the whole point
210
- * of `provenance` being a list: a citation has to be able to point at exactly
211
- * one of them. Rendering them as prose would take that away.
327
+ * Where the document came from: `sources`, one entry each — that is the whole
328
+ * point of it being a list: a footnote has to be able to point at exactly one
329
+ * of them. Rendering them as prose would take that away.
212
330
  */
213
- export function Provenance({ entries }: { entries: readonly string[] }): ReactElement | null {
331
+ export function Provenance({
332
+ entries,
333
+ }: {
334
+ entries: readonly {
335
+ readonly id: string | null;
336
+ readonly title: string | null;
337
+ readonly resource: string;
338
+ }[];
339
+ }): ReactElement | null {
214
340
  if (entries.length === 0) return null;
215
341
 
216
342
  return (
217
343
  <section className="mt-10 border-t border-fd-border pt-5 text-sm">
218
344
  <h2 className="ksor-section-label mb-2">Sources</h2>
219
- {/* break-words, because a citation is often a long unbroken URL: on a
345
+ {/* break-words, because a source is often a long unbroken URL: on a
220
346
  phone it overflowed its row by 175px under an ancestor with
221
347
  `overflow-x: clip`, so the middle of the source was clipped away with
222
348
  no ellipsis and nothing to scroll (measured, 2026-08-20). A source
223
349
  nobody can read is not provenance. */}
224
350
  <ul className="space-y-1 break-words text-fd-muted-foreground">
225
351
  {entries.map((entry, index) => {
226
- // An entry that IS a URL becomes followable; a citation stays text.
227
- // `rel="noreferrer"` because the destination is authored in the
228
- // record, not chosen by this site.
229
- const href = sourceHref(entry);
352
+ // A resource that IS a URL becomes followable; a bundle path or a
353
+ // scope descriptor stays text. `rel="noreferrer"` because the
354
+ // destination is authored in the record, not chosen by this site.
355
+ const href = sourceHref(entry.resource);
356
+ const label = entry.title ?? entry.resource;
230
357
  return (
231
358
  // Position, not text: a record may cite the same source twice, and
232
359
  // duplicate keys are a console error on a governed page.
233
- <li key={`${index}-${entry}`}>
360
+ <li key={`${index}-${entry.resource}`}>
361
+ {entry.id === null ? null : (
362
+ <span className="me-2 font-mono text-xs text-fd-muted-foreground">
363
+ [^{entry.id}]
364
+ </span>
365
+ )}
234
366
  {href === null ? (
235
- entry
367
+ label
236
368
  ) : (
237
369
  <a
238
370
  href={href}
@@ -240,7 +372,7 @@ export function Provenance({ entries }: { entries: readonly string[] }): ReactEl
240
372
  rel="noreferrer"
241
373
  className="underline underline-offset-4 transition-colors hover:text-fd-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
242
374
  >
243
- {entry}
375
+ {label}
244
376
  </a>
245
377
  )}
246
378
  </li>
@@ -1,4 +1,7 @@
1
1
  import defaultMdxComponents from "fumadocs-ui/mdx";
2
+
3
+ import { WrappableCodeBlock } from "@/components/code-block";
4
+ import { Embed } from "@/components/embed";
2
5
  import { CodeBlockTabsTrigger } from "fumadocs-ui/components/codeblock";
3
6
  import { Tab, Tabs } from "fumadocs-ui/components/tabs";
4
7
  import type { MDXComponents } from "mdx/types";
@@ -32,9 +35,14 @@ export function getMDXComponents(components?: MDXComponents) {
32
35
  // here or a document whose page forgot to pass one serves a 500 rather
33
36
  // than a page without an aid.
34
37
  TeachingAid: () => null,
38
+ // A long line is the reader's to unwrap, per block — see
39
+ // components/code-block.tsx. Replaces fumadocs' own `pre`.
40
+ pre: WrappableCodeBlock,
41
+ // `rehypeEmbeds` (source.config.ts) rewrites a link titled `embed` into
42
+ // this; an unknown component fails the build, so it has to be in the map.
43
+ Embed,
35
44
  // `remarkCodeTab` (source.config.ts) rewrites consecutive fenced blocks
36
- // that declare `tab="…"` into these, so they have to be in the map or the
37
- // build fails on an unknown component rather than at authoring time.
45
+ // that declare `tab="…"` into these, for the same reason.
38
46
  Tabs,
39
47
  Tab,
40
48
  CodeBlockTabsTrigger: BrandedTabsTrigger,
@@ -2,7 +2,7 @@ import { ArrowRight, FileText, Folder } from "lucide-react";
2
2
  import Link from "next/link";
3
3
  import type { ReactElement } from "react";
4
4
 
5
- import { statusTone } from "@/lib/governance";
5
+ import { badgeLabel, badgeTone } from "@/lib/governance";
6
6
  import type { RecordEntry } from "@/lib/source";
7
7
 
8
8
  /**
@@ -28,7 +28,7 @@ import type { RecordEntry } from "@/lib/source";
28
28
  *
29
29
  * What survives from the register is the part that was never about looks: the
30
30
  * record's serif for its own words, mono for what the record says ABOUT them —
31
- * who owns it, how much it holds, whether it carries a caveat — and `approved`
31
+ * who owns it, how much it holds, whether it carries a caveat — and `stable`
32
32
  * staying silent, because a label on every row is a label nobody reads.
33
33
  *
34
34
  * Server-rendered plain markup — it survives print, a failed bundle and
@@ -70,11 +70,11 @@ export function RecordIndex({
70
70
  <span className="font-display text-lg leading-snug font-semibold tracking-[-0.008em] transition-colors group-hover:text-fd-primary">
71
71
  {entry.title}
72
72
  </span>
73
- {entry.status === null ? null : (
73
+ {entry.badge === null ? null : (
74
74
  <span
75
- className={`rounded-sm border border-fd-border px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-fd-foreground uppercase ${statusTone(entry.status)}`}
75
+ className={`rounded-sm border border-fd-border px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-fd-foreground uppercase ${badgeTone(entry.badge)}`}
76
76
  >
77
- {entry.status}
77
+ {badgeLabel(entry.badge)}
78
78
  </span>
79
79
  )}
80
80
  </span>
@@ -1,7 +1,8 @@
1
1
  import Link from "next/link";
2
2
  import type { ReactElement } from "react";
3
3
 
4
- import { statusTone } from "@/lib/governance";
4
+ import { badgeLabel, badgeTone } from "@/lib/governance";
5
+ import type { LifecycleBadge } from "@/lib/lifecycle-rule";
5
6
  import type { RecordEntry } from "@/lib/source";
6
7
 
7
8
  /**
@@ -53,7 +54,7 @@ export function RecordStack({
53
54
  <span className="font-mono text-[10px] tracking-[0.2em] text-[var(--ksor-cover-muted)] uppercase">
54
55
  Opens here
55
56
  </span>
56
- {lead.status === null ? null : <StatusChip status={lead.status} />}
57
+ {lead.badge === null ? null : <StatusChip badge={lead.badge} />}
57
58
  </div>
58
59
 
59
60
  <h2 className="mt-4 font-display text-2xl leading-snug font-semibold tracking-[-0.008em] transition-colors group-hover:text-fd-primary">
@@ -99,7 +100,7 @@ export function RecordStack({
99
100
  {entry.documents === 0
100
101
  ? null
101
102
  : `${entry.documents} ${entry.documents === 1 ? "doc" : "docs"}`}
102
- {entry.status === null ? null : <StatusChip status={entry.status} />}
103
+ {entry.badge === null ? null : <StatusChip badge={entry.badge} />}
103
104
  </span>
104
105
  </span>
105
106
  </Link>
@@ -116,16 +117,16 @@ export function RecordStack({
116
117
  }
117
118
 
118
119
  /**
119
- * A caveat status, in the same chip the record's listings use — only ever
120
- * `draft`, `review` or `superseded`, because a reader already assumes a
121
- * document in the record is current.
120
+ * A caveat badge, in the same chip the record's listings use — only ever a
121
+ * state the machine surfaces decline (record spec §2.5), because a reader
122
+ * already assumes a document in the record is current and stable.
122
123
  */
123
- function StatusChip({ status }: { status: string }): ReactElement {
124
+ function StatusChip({ badge }: { badge: LifecycleBadge }): ReactElement {
124
125
  return (
125
126
  <span
126
- className={`rounded-sm border border-[var(--ksor-cover-rule)] px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-[var(--ksor-cover-foreground)] uppercase ${statusTone(status)}`}
127
+ className={`rounded-sm border border-[var(--ksor-cover-rule)] px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-[var(--ksor-cover-foreground)] uppercase ${badgeTone(badge)}`}
127
128
  >
128
- {status}
129
+ {badgeLabel(badge)}
129
130
  </span>
130
131
  );
131
132
  }
@@ -146,23 +146,12 @@ export function RecordViews({
146
146
  [select, views],
147
147
  );
148
148
 
149
- // No summary, so no tabs — but the strip row still carries the reading time.
150
- // It sits here rather than up with the title because this row is about the
151
- // reading you are ABOUT to do, and a lone line above the facts row read as
152
- // floating between two things it belonged to neither of.
153
- if (only) {
154
- return (
155
- <div className="ksor-views">
156
- {documentMinutes === undefined ? null : (
157
- <p className="mb-6 flex items-center justify-end gap-2 border-b border-fd-border pb-2.5 text-sm text-fd-muted-foreground">
158
- <Clock aria-hidden className="size-3.5 shrink-0" />
159
- <span>{documentMinutes} min read</span>
160
- </p>
161
- )}
162
- {children}
163
- </div>
164
- );
165
- }
149
+ // No summary, so no tabs — and so no strip. It used to keep the row anyway,
150
+ // for the reading time alone: a full-width rule under an empty band with one
151
+ // number at its far end, which is what most documents got, because most have
152
+ // no summary. The number moved to the governance row, where it sits with the
153
+ // document's other facts and needs no furniture of its own.
154
+ if (only) return <div className="ksor-views">{children}</div>;
166
155
 
167
156
  return (
168
157
  <div className="ksor-views">
@@ -1,35 +1,36 @@
1
- import type { ReactElement } from "react";
1
+ import type { ReactElement, ReactNode } from "react";
2
2
 
3
- import { caveatStatus, statusTone } from "@/lib/governance";
3
+ import { badgeLabel, badgeTone } from "@/lib/governance";
4
+ import type { LifecycleBadge } from "@/lib/lifecycle-rule";
4
5
 
5
6
  /**
6
- * The sidebar's status marker, rendered by the shell's own status-badges
7
- * plugin (`lib/source.ts`).
7
+ * The sidebar's badge, drawn beside a row's name (`lib/source.ts` composes it
8
+ * into the page tree while sorting it).
8
9
  *
9
10
  * The sidebar is where a reader chooses. Without this, a withdrawn document and
10
11
  * the one that replaced it were pixel-identical rows — the governance appeared
11
12
  * only after the click, which is the moment it is least useful
12
13
  * (research/site-design.md F3).
13
14
  *
14
- * Only a CAVEAT is drawn. `approved` returns null, because a reader already
15
- * assumes a document in the record is current and a label that never varies
16
- * stops being read so the marker stays rare enough to be noticed on the rows
17
- * where it matters. That rule is ours; the walk over the tree is the shell's.
15
+ * Only a BADGE is drawn a state the machine surfaces decline (record spec
16
+ * §2.5). A current document shows nothing, because a reader already assumes a
17
+ * document in the record is current and a label that never varies stops being
18
+ * read; the marker stays rare enough to be noticed on the rows where it matters.
18
19
  */
19
- export function renderCaveatBadge(status: string): ReactElement | null {
20
- const caveat = caveatStatus(status);
21
- if (caveat === null) return null;
22
- // `inline-block` with the row's own wrapping, not a flex wrapper: the plugin
23
- // composes `<>{name}{badge}</>` with no element around the pair, so the badge
20
+ export function renderBadge(name: ReactNode, badge: LifecycleBadge): ReactElement {
21
+ // `inline-block` with the row's own wrapping, not a flex wrapper: the badge
24
22
  // has to survive beside a title that runs two lines in a ~200px column. An
25
23
  // earlier hand-rolled version pinned the chip right with `truncate`, which
26
24
  // clipped the title AND the chip to "sup…" (seen in Chromium, 2026-08-21) —
27
25
  // the marker has to fit around the name, not fight it.
28
26
  return (
29
- <span
30
- className={`ms-1.5 inline-block rounded border border-fd-border px-1 py-px align-middle text-[0.6rem] font-medium whitespace-nowrap text-fd-muted-foreground ${statusTone(caveat)}`}
31
- >
32
- {caveat}
33
- </span>
27
+ <>
28
+ {name}
29
+ <span
30
+ className={`ms-1.5 inline-block rounded border border-fd-border px-1 py-px align-middle text-[0.6rem] font-medium whitespace-nowrap text-fd-muted-foreground ${badgeTone(badge)}`}
31
+ >
32
+ {badgeLabel(badge)}
33
+ </span>
34
+ </>
34
35
  );
35
36
  }