@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.
- package/CHANGELOG.md +472 -0
- package/dist/cli.mjs +71 -19
- package/package.json +3 -3
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/AGENTS.md +52 -4
- package/templates/scaffold/instance.md +28 -20
- package/templates/scaffold/knowledge/governance-ladder.md +36 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
- package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
- package/templates/scaffold/knowledge/surfaces/index.md +21 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
- package/templates/scaffold/pnpm-lock.yaml +1198 -228
- package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
- package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
- package/templates/scaffold/system/site/app/global.css +552 -9
- package/templates/scaffold/system/site/app/layout.tsx +23 -4
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
- package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
- package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
- package/templates/scaffold/system/site/components/governance.tsx +262 -0
- package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
- package/templates/scaffold/system/site/components/record-index.tsx +120 -0
- package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
- package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
- package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
- package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
- package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
- package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
- package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
- package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
- package/templates/scaffold/system/site/components.json +25 -0
- package/templates/scaffold/system/site/lib/governance.ts +432 -0
- package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
- package/templates/scaffold/system/site/lib/shared.ts +38 -0
- package/templates/scaffold/system/site/lib/source.ts +221 -5
- package/templates/scaffold/system/site/lib/utils.ts +6 -0
- package/templates/scaffold/system/site/package.json +9 -3
- package/templates/scaffold/knowledge/example.md +0 -23
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
|
|
3
|
+
import { getLLMText, getSortedPages, source } from "@/lib/source";
|
|
4
|
+
|
|
5
|
+
export const revalidate = false;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every document as markdown, at a stable address derived from its path:
|
|
9
|
+
* `/docs/policies/purchase-approval` is also served at
|
|
10
|
+
* `/md/policies/purchase-approval.md`.
|
|
11
|
+
*
|
|
12
|
+
* The record already IS markdown; without this an agent handed a document URL
|
|
13
|
+
* had to scrape a React app to reach text the record holds verbatim
|
|
14
|
+
* (research/site-design.md F2). The body carries the document's governance as
|
|
15
|
+
* frontmatter, exactly as `llms-full.txt` does, so a consumer reading one
|
|
16
|
+
* document knows its status, owner, sources and successor.
|
|
17
|
+
*
|
|
18
|
+
* Why a `/md/` prefix rather than appending `.md` to the document's own URL,
|
|
19
|
+
* which is the convention the field has settled on: under `output: "export"` a
|
|
20
|
+
* Route Handler cannot share a route segment with a Page, and there is no
|
|
21
|
+
* middleware to rewrite one onto the other. The prefix is the shape that
|
|
22
|
+
* survives a static host, and the page advertises it in a `rel="alternate"`
|
|
23
|
+
* link so a consumer discovers it rather than guessing. Appending `.md` to the
|
|
24
|
+
* canonical URL becomes possible the day a build emits these artifacts itself.
|
|
25
|
+
*/
|
|
26
|
+
export function generateStaticParams(): { slug: string[] }[] {
|
|
27
|
+
return source.generateParams().map(({ slug }) => {
|
|
28
|
+
const segments = slug ?? [];
|
|
29
|
+
const last = segments.at(-1);
|
|
30
|
+
return {
|
|
31
|
+
slug: last === undefined ? ["index.md"] : [...segments.slice(0, -1), `${last}.md`],
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function GET(
|
|
37
|
+
_request: Request,
|
|
38
|
+
{ params }: { params: Promise<{ slug?: string[] }> },
|
|
39
|
+
): Promise<Response> {
|
|
40
|
+
const { slug = [] } = await params;
|
|
41
|
+
const last = slug.at(-1);
|
|
42
|
+
if (last === undefined || !last.endsWith(".md")) notFound();
|
|
43
|
+
const docSlug = last === "index.md" ? [] : [...slug.slice(0, -1), last.slice(0, -".md".length)];
|
|
44
|
+
|
|
45
|
+
const page = source.getPage(docSlug);
|
|
46
|
+
if (!page) notFound();
|
|
47
|
+
|
|
48
|
+
return new Response(await getLLMText(page, getSortedPages()), {
|
|
49
|
+
headers: { "content-type": "text/markdown; charset=utf-8" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCopyButton } from "fumadocs-ui/utils/use-copy-button";
|
|
4
|
+
import { Check, Copy } from "lucide-react";
|
|
5
|
+
import { useState, type ReactElement } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hand this document to an agent: copy the governed markdown, verbatim.
|
|
9
|
+
*
|
|
10
|
+
* The record's second audience reads bytes, and until now a reader who wanted
|
|
11
|
+
* to give an agent a document had to open its markdown twin and select the
|
|
12
|
+
* page. This fetches that same twin — the one `/md/<path>.md` already serves,
|
|
13
|
+
* so there is no second rendering of the document to drift — and puts it on the
|
|
14
|
+
* clipboard.
|
|
15
|
+
*
|
|
16
|
+
* Fumadocs ships an `ai/page-actions` component that does this alongside "Open
|
|
17
|
+
* in ChatGPT" and "Open in Claude". Those two are deliberately NOT taken: this
|
|
18
|
+
* product's claim is that one corpus answers in ANY assistant because the
|
|
19
|
+
* surface is an open standard, and hardcoding two vendors into every adopter's
|
|
20
|
+
* page argues the opposite. What is taken is the shell's own `useCopyButton`,
|
|
21
|
+
* which owns the copied-state timing — the only part worth not rewriting.
|
|
22
|
+
*
|
|
23
|
+
* It rests in the muted grey the rest of the row's metadata wears and takes the
|
|
24
|
+
* accent only when it has actually copied. An accent at REST said "link" on a
|
|
25
|
+
* row of facts and added to a page that was already too blue; an accent on the
|
|
26
|
+
* state CHANGE is the one thing the accent is for.
|
|
27
|
+
*/
|
|
28
|
+
export function CopyMarkdown({ href }: { href: string }): ReactElement {
|
|
29
|
+
const [failed, setFailed] = useState(false);
|
|
30
|
+
const [copied, onClick] = useCopyButton(async () => {
|
|
31
|
+
setFailed(false);
|
|
32
|
+
try {
|
|
33
|
+
// `navigator.clipboard` exists only in a secure context, so a site served
|
|
34
|
+
// over plain http on a LAN address has no clipboard at all. Saying so
|
|
35
|
+
// beats a button that reports success it did not have.
|
|
36
|
+
if (navigator.clipboard === undefined) throw new Error("no clipboard");
|
|
37
|
+
const response = await fetch(href);
|
|
38
|
+
if (!response.ok) throw new Error(`markdown twin returned ${response.status}`);
|
|
39
|
+
await navigator.clipboard.writeText(await response.text());
|
|
40
|
+
} catch {
|
|
41
|
+
setFailed(true);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={onClick}
|
|
49
|
+
// aria-live, because the label is the only feedback: a screen reader that
|
|
50
|
+
// does not hear "Copied" is told nothing happened at all.
|
|
51
|
+
aria-live="polite"
|
|
52
|
+
// The hover tone is dropped while copied, not overridden: a `hover:`
|
|
53
|
+
// variant is a class AND a pseudo-class, so it outranks a plain colour
|
|
54
|
+
// class — and the pointer is by definition still over the button at the
|
|
55
|
+
// moment it has just been clicked, so the success colour never showed
|
|
56
|
+
// (measured 2026-08-22: lab(4.41), the foreground, where the accent
|
|
57
|
+
// belonged).
|
|
58
|
+
className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-[0.6875rem] tracking-[0.14em] uppercase transition-colors hover:bg-fd-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring ${
|
|
59
|
+
copied ? "text-fd-primary" : "text-fd-muted-foreground hover:text-fd-foreground"
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
{copied ? (
|
|
63
|
+
<Check aria-hidden className="size-3.5" />
|
|
64
|
+
) : (
|
|
65
|
+
<Copy aria-hidden className="size-3.5" />
|
|
66
|
+
)}
|
|
67
|
+
{failed ? "Copy failed" : copied ? "Copied" : "Copy"}
|
|
68
|
+
</button>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { FileText } from "lucide-react";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
|
|
4
|
+
import { CopyMarkdown } from "@/components/copy-markdown";
|
|
5
|
+
import type { ReactElement } from "react";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
caveatStatus,
|
|
9
|
+
statusTone,
|
|
10
|
+
isCalendarDate,
|
|
11
|
+
sourceHref,
|
|
12
|
+
type DocumentGovernance,
|
|
13
|
+
} from "@/lib/governance";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the record says about the document you are reading.
|
|
17
|
+
*
|
|
18
|
+
* The site renders the record; these render the record's governance — the
|
|
19
|
+
* frontmatter `pnpm check` enforces on every document. Nothing here is
|
|
20
|
+
* authored in the site (critical rule 1) and nothing is inferred: a key the
|
|
21
|
+
* document does not declare renders nothing at all.
|
|
22
|
+
*
|
|
23
|
+
* All three are plain server-rendered markup — no client component, no
|
|
24
|
+
* disclosure widget. A governance fact that only exists once JavaScript runs
|
|
25
|
+
* is a governance fact missing from the printout and from the reader with a
|
|
26
|
+
* failed bundle.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The successor of a superseded document: its route and the text naming it.
|
|
31
|
+
* `href` is null when the pointer could not be resolved to a route, and then
|
|
32
|
+
* the pointer itself is shown as text rather than as a dead link.
|
|
33
|
+
*/
|
|
34
|
+
export interface Successor {
|
|
35
|
+
readonly href: string | null;
|
|
36
|
+
readonly label: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The supersession notice. Deliberately the first thing on the page, above the
|
|
41
|
+
* title: a reader must not have to notice a subtle badge to learn that what
|
|
42
|
+
* they are about to read has been replaced.
|
|
43
|
+
*/
|
|
44
|
+
export function SupersededNotice({ successor }: { successor: Successor }): ReactElement {
|
|
45
|
+
return (
|
|
46
|
+
<aside
|
|
47
|
+
// A landmark, not a note: GOV.UK ships this as role="region" with
|
|
48
|
+
// aria-labelledby so a screen-reader user can reach the most consequential
|
|
49
|
+
// thing on the page by landmark, rather than only meeting it in reading
|
|
50
|
+
// order. role="note" is announced but not navigable.
|
|
51
|
+
role="region"
|
|
52
|
+
aria-labelledby="ksor-superseded"
|
|
53
|
+
// Tinted and ruled down the left edge in the CAUTION role, never
|
|
54
|
+
// --color-fd-muted: the shipped light theme defines --color-fd-muted and
|
|
55
|
+
// --color-fd-background as the same value, so the callout composited to
|
|
56
|
+
// exactly the page and had no visible surface at all (measured in
|
|
57
|
+
// Chromium, 2026-08-20). The one thing this notice cannot be is missable.
|
|
58
|
+
className="ksor-caution mb-8 rounded-lg border border-l-4 px-4 py-3 text-sm"
|
|
59
|
+
>
|
|
60
|
+
<p id="ksor-superseded" className="font-medium text-fd-foreground">
|
|
61
|
+
Superseded
|
|
62
|
+
</p>
|
|
63
|
+
<p className="mt-1 text-fd-muted-foreground">
|
|
64
|
+
This document has been replaced by{" "}
|
|
65
|
+
{successor.href === null ? (
|
|
66
|
+
<code className="break-words text-fd-foreground">{successor.label}</code>
|
|
67
|
+
) : (
|
|
68
|
+
<Link
|
|
69
|
+
href={successor.href}
|
|
70
|
+
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"
|
|
71
|
+
>
|
|
72
|
+
{successor.label}
|
|
73
|
+
</Link>
|
|
74
|
+
)}
|
|
75
|
+
. It is kept because the record never deletes what it replaces.
|
|
76
|
+
</p>
|
|
77
|
+
</aside>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Fact({
|
|
82
|
+
label,
|
|
83
|
+
children,
|
|
84
|
+
}: {
|
|
85
|
+
label: string;
|
|
86
|
+
children: ReactElement | string;
|
|
87
|
+
}): ReactElement {
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex items-baseline gap-2.5">
|
|
90
|
+
{/* Mono, uppercase, letterspaced: these are the record's checkable facts,
|
|
91
|
+
and they read as a register's column heads rather than as a form.
|
|
92
|
+
|
|
93
|
+
The label is deliberately SMALLER and more letterspaced than the value
|
|
94
|
+
it introduces. Both used to be mono a single pixel apart — 11px label
|
|
95
|
+
against a 12px value — so "Owner Product Effective 2026-08-22" read as
|
|
96
|
+
one undifferentiated mono run rather than as two facts with names
|
|
97
|
+
(reported by the owner, 2026-08-22, quoting the run back verbatim).
|
|
98
|
+
The step is now 10px against 13px, and the value carries the weight. */}
|
|
99
|
+
<dt className="font-mono text-[0.625rem] tracking-[0.18em] text-fd-muted-foreground uppercase">
|
|
100
|
+
{label}
|
|
101
|
+
</dt>
|
|
102
|
+
<dd className="font-mono text-[0.8125rem] font-medium break-words text-fd-foreground">
|
|
103
|
+
{children}
|
|
104
|
+
</dd>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The one-line governance strip under the document's title: any caveat on its
|
|
111
|
+
* status, who stands behind it, and when it took effect.
|
|
112
|
+
*/
|
|
113
|
+
export function GovernanceMeta({
|
|
114
|
+
governance,
|
|
115
|
+
replaces = [],
|
|
116
|
+
markdownUrl,
|
|
117
|
+
}: {
|
|
118
|
+
governance: DocumentGovernance;
|
|
119
|
+
/** Documents this one replaced — derived from the record, never declared. */
|
|
120
|
+
replaces?: readonly Successor[];
|
|
121
|
+
/** The document's markdown twin, offered beside its governance. */
|
|
122
|
+
markdownUrl?: string;
|
|
123
|
+
}): ReactElement | null {
|
|
124
|
+
const { owner, effective } = governance;
|
|
125
|
+
const status = caveatStatus(governance.status);
|
|
126
|
+
const bare = status === null && owner === null && effective === null && replaces.length === 0;
|
|
127
|
+
if (bare && markdownUrl === undefined) return null;
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<dl className="mb-7 flex flex-wrap items-baseline gap-x-8 gap-y-2.5 border-b border-fd-border pb-4">
|
|
131
|
+
{status === null ? null : (
|
|
132
|
+
<Fact label="Status">
|
|
133
|
+
<span
|
|
134
|
+
className={`rounded-sm border border-fd-border px-1.5 py-0.5 tracking-widest uppercase ${statusTone(status)}`}
|
|
135
|
+
>
|
|
136
|
+
{status}
|
|
137
|
+
</span>
|
|
138
|
+
</Fact>
|
|
139
|
+
)}
|
|
140
|
+
{owner === null ? null : <Fact label="Owner">{owner}</Fact>}
|
|
141
|
+
{replaces.length === 0 ? null : (
|
|
142
|
+
// The other half of a supersession. The withdrawn document names its
|
|
143
|
+
// successor above the title; this is the successor naming what it
|
|
144
|
+
// replaced, so the history the record kept is reachable from the
|
|
145
|
+
// current document instead of only from the retired one.
|
|
146
|
+
<Fact label={replaces.length === 1 ? "Replaces" : "Replaces"}>
|
|
147
|
+
<>
|
|
148
|
+
{replaces.map((entry, index) => (
|
|
149
|
+
<span key={entry.href ?? `${index}-${entry.label}`}>
|
|
150
|
+
{index === 0 ? null : ", "}
|
|
151
|
+
{entry.href === null ? (
|
|
152
|
+
entry.label
|
|
153
|
+
) : (
|
|
154
|
+
<Link
|
|
155
|
+
href={entry.href}
|
|
156
|
+
className="underline underline-offset-4 transition-colors hover:text-fd-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
157
|
+
>
|
|
158
|
+
{entry.label}
|
|
159
|
+
</Link>
|
|
160
|
+
)}
|
|
161
|
+
</span>
|
|
162
|
+
))}
|
|
163
|
+
</>
|
|
164
|
+
</Fact>
|
|
165
|
+
)}
|
|
166
|
+
{effective === null ? null : (
|
|
167
|
+
<Fact label="Effective">
|
|
168
|
+
{/* The machine attribute is stamped only for a real day on the
|
|
169
|
+
calendar. A SHAPE test was not enough: the checker's own remedy
|
|
170
|
+
for `2026-06-31` is to QUOTE it, and quoted text arrives here — so
|
|
171
|
+
a shape test published `<time dateTime="2026-06-31">`, which is
|
|
172
|
+
invalid HTML and which a consumer reads as July 1st. That is the
|
|
173
|
+
precise hazard the record's date rule exists to prevent. */}
|
|
174
|
+
{isCalendarDate(effective) ? (
|
|
175
|
+
<time dateTime={effective}>{effective}</time>
|
|
176
|
+
) : (
|
|
177
|
+
<span>{effective}</span>
|
|
178
|
+
)}
|
|
179
|
+
</Fact>
|
|
180
|
+
)}
|
|
181
|
+
{markdownUrl === undefined ? null : (
|
|
182
|
+
// On the governance row, not as a footnote below the sources: it is
|
|
183
|
+
// how a reader hands this document to an agent, and it was previously
|
|
184
|
+
// the smallest text on the page, last (research/site-design.md F2).
|
|
185
|
+
// These are the ACTIONS on a row of FACTS, so they wear neither the
|
|
186
|
+
// bordered badge (that means "a status the record declares", and
|
|
187
|
+
// dressing a link in it made the only clickable thing look like another
|
|
188
|
+
// read-only field) nor the accent at rest. An icon and a hover surface
|
|
189
|
+
// say "control" without spending the brand colour on something that is
|
|
190
|
+
// merely sitting there — the page had gone blue enough that the accent
|
|
191
|
+
// had stopped meaning anything (owner, 2026-08-22).
|
|
192
|
+
// Inline after the facts, NOT right-aligned: `ms-auto` parked it at the
|
|
193
|
+
// far edge of a 900px row, 498px from the nearest thing on a document
|
|
194
|
+
// with two facts, where it read as belonging to nothing (measured in
|
|
195
|
+
// Chromium, 2026-08-21).
|
|
196
|
+
<span className="flex items-center gap-0.5">
|
|
197
|
+
<a
|
|
198
|
+
href={markdownUrl}
|
|
199
|
+
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-[0.6875rem] tracking-[0.14em] text-fd-muted-foreground uppercase transition-colors hover:bg-fd-muted hover:text-fd-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
200
|
+
>
|
|
201
|
+
<FileText aria-hidden className="size-3.5" />
|
|
202
|
+
Markdown
|
|
203
|
+
</a>
|
|
204
|
+
{/* Beside the link, not instead of it: opening the markdown and
|
|
205
|
+
handing it to an agent are different acts, and a reader who wants
|
|
206
|
+
the second should not have to perform the first. */}
|
|
207
|
+
<CopyMarkdown href={markdownUrl} />
|
|
208
|
+
</span>
|
|
209
|
+
)}
|
|
210
|
+
</dl>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Where the document came from.
|
|
216
|
+
*
|
|
217
|
+
* One entry per source, each independently visible — that is the whole point
|
|
218
|
+
* of `provenance` being a list: a citation has to be able to point at exactly
|
|
219
|
+
* one of them. Rendering them as prose would take that away.
|
|
220
|
+
*/
|
|
221
|
+
export function Provenance({ entries }: { entries: readonly string[] }): ReactElement | null {
|
|
222
|
+
if (entries.length === 0) return null;
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<section className="mt-10 border-t border-fd-border pt-5 text-sm">
|
|
226
|
+
<h2 className="mb-2 font-mono text-xs tracking-[0.18em] text-fd-muted-foreground uppercase">
|
|
227
|
+
Sources
|
|
228
|
+
</h2>
|
|
229
|
+
{/* break-words, because a citation is often a long unbroken URL: on a
|
|
230
|
+
phone it overflowed its row by 175px under an ancestor with
|
|
231
|
+
`overflow-x: clip`, so the middle of the source was clipped away with
|
|
232
|
+
no ellipsis and nothing to scroll (measured, 2026-08-20). A source
|
|
233
|
+
nobody can read is not provenance. */}
|
|
234
|
+
<ul className="space-y-1 break-words text-fd-muted-foreground">
|
|
235
|
+
{entries.map((entry, index) => {
|
|
236
|
+
// An entry that IS a URL becomes followable; a citation stays text.
|
|
237
|
+
// `rel="noreferrer"` because the destination is authored in the
|
|
238
|
+
// record, not chosen by this site.
|
|
239
|
+
const href = sourceHref(entry);
|
|
240
|
+
return (
|
|
241
|
+
// Position, not text: a record may cite the same source twice, and
|
|
242
|
+
// duplicate keys are a console error on a governed page.
|
|
243
|
+
<li key={`${index}-${entry}`}>
|
|
244
|
+
{href === null ? (
|
|
245
|
+
entry
|
|
246
|
+
) : (
|
|
247
|
+
<a
|
|
248
|
+
href={href}
|
|
249
|
+
target="_blank"
|
|
250
|
+
rel="noreferrer"
|
|
251
|
+
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"
|
|
252
|
+
>
|
|
253
|
+
{entry}
|
|
254
|
+
</a>
|
|
255
|
+
)}
|
|
256
|
+
</li>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
</ul>
|
|
260
|
+
</section>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import Image, { type StaticImageData } from "next/image";
|
|
3
|
+
import type { ReactElement } from "react";
|
|
4
|
+
|
|
5
|
+
import { RecordStack } from "@/components/record-stack";
|
|
6
|
+
import type { RecordEntry } from "@/lib/source";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The cover of the record: one screen, standing alone.
|
|
10
|
+
*
|
|
11
|
+
* It is cover stock, one step off the page beneath it — pale in light, and in
|
|
12
|
+
* the dark not white but the surface that catches the light. Every colour on it
|
|
13
|
+
* is a `--ksor-cover-*` token, so the whole composition inverts with the theme
|
|
14
|
+
* rather than staying dark in both, and the accent inverts with it.
|
|
15
|
+
*
|
|
16
|
+
* The record's own words on the left, the record ITSELF on the right — the
|
|
17
|
+
* document it opens on, and the entries standing behind it. That stack is
|
|
18
|
+
* where this page spends its boldness; everything around it is deliberately
|
|
19
|
+
* quiet.
|
|
20
|
+
*/
|
|
21
|
+
export function HomeCover({
|
|
22
|
+
foot,
|
|
23
|
+
mark,
|
|
24
|
+
name,
|
|
25
|
+
title,
|
|
26
|
+
purpose,
|
|
27
|
+
documents,
|
|
28
|
+
firstUrl,
|
|
29
|
+
lead,
|
|
30
|
+
behind,
|
|
31
|
+
}: {
|
|
32
|
+
mark: StaticImageData;
|
|
33
|
+
name: string;
|
|
34
|
+
title: string;
|
|
35
|
+
purpose: string | null;
|
|
36
|
+
documents: number;
|
|
37
|
+
firstUrl: string;
|
|
38
|
+
/** The document `Open the record` opens, as the record describes it. */
|
|
39
|
+
lead: RecordEntry;
|
|
40
|
+
/** The entries standing behind it in the stack. */
|
|
41
|
+
behind: readonly RecordEntry[];
|
|
42
|
+
/** Signed from inside the cover, so the front door is one screen. */
|
|
43
|
+
foot?: ReactElement;
|
|
44
|
+
}): ReactElement {
|
|
45
|
+
// The cover fills the window under the navbar (measured at 56px, the shell's
|
|
46
|
+
// `h-14`): a front door is the whole view, not a band with the page's
|
|
47
|
+
// background showing beneath it. `dvh` rather than `vh`, so a phone's
|
|
48
|
+
// collapsing browser chrome does not leave a gap at the bottom.
|
|
49
|
+
return (
|
|
50
|
+
<section className="relative flex min-h-[calc(100dvh-3.5rem)] flex-col bg-[var(--ksor-cover)] text-[var(--ksor-cover-foreground)]">
|
|
51
|
+
{/* A ruled ground — the ledger's own lines, not a texture. Masked to
|
|
52
|
+
fade at both ends: at full strength edge to edge the rules read as
|
|
53
|
+
stripes ACROSS the composition rather than as the paper under it. */}
|
|
54
|
+
<div
|
|
55
|
+
aria-hidden
|
|
56
|
+
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
|
57
|
+
style={{
|
|
58
|
+
backgroundImage:
|
|
59
|
+
"repeating-linear-gradient(to bottom, currentColor 0 1px, transparent 1px 2.25rem)",
|
|
60
|
+
maskImage: "linear-gradient(to bottom, transparent, black 22%, black 70%, transparent)",
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
{/* Centred in the space ABOVE the signature, not top-aligned with the
|
|
65
|
+
slack dumped underneath: `justify-center` on the section was cancelled
|
|
66
|
+
by the signature's `mt-auto`, which measured as 197px of dead space
|
|
67
|
+
below the content and none above it (found live 2026-08-22). */}
|
|
68
|
+
<div className="relative flex flex-1 items-center">
|
|
69
|
+
<div className="mx-auto grid w-full max-w-6xl items-center gap-16 px-6 py-14 lg:grid-cols-[1fr_1fr] lg:gap-20">
|
|
70
|
+
<div>
|
|
71
|
+
<div className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 motion-safe:duration-500">
|
|
72
|
+
<div className="flex items-center gap-3">
|
|
73
|
+
<Image
|
|
74
|
+
src={mark}
|
|
75
|
+
alt=""
|
|
76
|
+
width={30}
|
|
77
|
+
height={30}
|
|
78
|
+
priority
|
|
79
|
+
className="size-[30px] rounded ring-1 ring-[var(--ksor-cover-rule)]"
|
|
80
|
+
/>
|
|
81
|
+
<p className="font-mono text-xs tracking-[0.18em] text-[var(--ksor-cover-muted)] uppercase">
|
|
82
|
+
System of record
|
|
83
|
+
<span aria-hidden className="mx-2 text-[var(--ksor-cover-rule)]">
|
|
84
|
+
/
|
|
85
|
+
</span>
|
|
86
|
+
<span className="normal-case">{name}</span>
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<h1 className="mt-8 max-w-4xl font-display text-[clamp(2.5rem,5vw,4rem)] leading-[1.02] font-semibold tracking-[-0.022em] text-balance">
|
|
91
|
+
{title}
|
|
92
|
+
</h1>
|
|
93
|
+
|
|
94
|
+
{/* The accent as structure, in the token so it inverts with the
|
|
95
|
+
cover. It was pinned to the dark-theme blue back when the cover
|
|
96
|
+
was dark in BOTH themes; once the cover started following the
|
|
97
|
+
theme that left a pale blue hairline on a pale ground, all but
|
|
98
|
+
invisible in light (found live 2026-08-22). */}
|
|
99
|
+
<div className="mt-7 h-0.5 w-16 bg-fd-primary" />
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{purpose === null ? null : (
|
|
103
|
+
<p className="relative mt-7 max-w-xl text-lg/[1.65] text-pretty sm:text-xl/[1.6] text-[var(--ksor-cover-muted)] motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500 motion-safe:[animation-delay:120ms] motion-safe:[animation-fill-mode:backwards]">
|
|
104
|
+
{purpose}
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div className="mt-9 flex flex-wrap items-center gap-3 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500 motion-safe:[animation-delay:220ms] motion-safe:[animation-fill-mode:backwards]">
|
|
109
|
+
{/* One primary action. It names where it lands, because a front door
|
|
110
|
+
that says only "open" makes you click to find out. */}
|
|
111
|
+
<Link
|
|
112
|
+
href={firstUrl}
|
|
113
|
+
className="group inline-flex items-center gap-2.5 rounded-md bg-fd-primary px-6 py-3.5 text-sm font-medium text-fd-primary-foreground transition-transform hover:-translate-y-px focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring motion-reduce:transition-none"
|
|
114
|
+
>
|
|
115
|
+
Open the record
|
|
116
|
+
<span
|
|
117
|
+
aria-hidden
|
|
118
|
+
className="transition-transform group-hover:translate-x-0.5 motion-reduce:transform-none"
|
|
119
|
+
>
|
|
120
|
+
→
|
|
121
|
+
</span>
|
|
122
|
+
</Link>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* The record, not a picture of one. It also carries what the meta
|
|
127
|
+
line beside the button used to say — which document opens, and
|
|
128
|
+
how many the record holds — so neither is said twice. */}
|
|
129
|
+
<div className="flex justify-center lg:justify-end">
|
|
130
|
+
<RecordStack lead={lead} behind={behind} documents={documents} />
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
{foot === undefined ? null : <div className="relative pb-10">{foot}</div>}
|
|
135
|
+
</section>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ArrowRight, FileText, Folder } from "lucide-react";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import type { ReactElement } from "react";
|
|
4
|
+
|
|
5
|
+
import { statusTone } from "@/lib/governance";
|
|
6
|
+
import type { RecordEntry } from "@/lib/source";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the record holds below this point.
|
|
10
|
+
*
|
|
11
|
+
* The site renders the record; this renders its shape. Every value comes from
|
|
12
|
+
* the record — title, description, status, owner and the count below an entry
|
|
13
|
+
* are frontmatter and tree, and the order is the governed `order:` key — so
|
|
14
|
+
* nothing is authored in the site (critical rule 1).
|
|
15
|
+
*
|
|
16
|
+
* It exists because a folder's index page listed nothing: `/docs/policies`
|
|
17
|
+
* rendered a title, a sentence and then empty space, while the two policies it
|
|
18
|
+
* contains were reachable only from the sidebar. The home page had the same
|
|
19
|
+
* gap — it said "5 documents" and linked to one (research/site-design.md F2/F5).
|
|
20
|
+
*
|
|
21
|
+
* Each entry is a CARD, not a hairline row (owner, 2026-08-22, choosing it from
|
|
22
|
+
* four prototyped treatments). The register the design language called for was
|
|
23
|
+
* honest about hierarchy and silent about being usable: at rest a row carried
|
|
24
|
+
* no affordance at all — only a hover tint and a hover colour — so on a touch
|
|
25
|
+
* screen, where there is no hover, nothing ever said the row was a link. A card
|
|
26
|
+
* is a surface a reader expects to press, and the arrow says where pressing
|
|
27
|
+
* leads before anyone has moved a pointer.
|
|
28
|
+
*
|
|
29
|
+
* What survives from the register is the part that was never about looks: the
|
|
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`
|
|
32
|
+
* staying silent, because a label on every row is a label nobody reads.
|
|
33
|
+
*
|
|
34
|
+
* Server-rendered plain markup — it survives print, a failed bundle and
|
|
35
|
+
* JavaScript off, like every other governance fact.
|
|
36
|
+
*/
|
|
37
|
+
export function RecordIndex({
|
|
38
|
+
entries,
|
|
39
|
+
heading,
|
|
40
|
+
}: {
|
|
41
|
+
entries: readonly RecordEntry[];
|
|
42
|
+
heading: string;
|
|
43
|
+
}): ReactElement | null {
|
|
44
|
+
if (entries.length === 0) return null;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<section className="mt-14">
|
|
48
|
+
{/* The head of the list stays a register head: this is a label, and a
|
|
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>
|
|
53
|
+
|
|
54
|
+
<ul className="grid gap-3">
|
|
55
|
+
{entries.map((entry) => {
|
|
56
|
+
// A folder and a leaf are different things to open, and the icon is
|
|
57
|
+
// read before the count is.
|
|
58
|
+
const Icon = entry.documents > 0 ? Folder : FileText;
|
|
59
|
+
return (
|
|
60
|
+
<li key={entry.url}>
|
|
61
|
+
<Link
|
|
62
|
+
href={entry.url}
|
|
63
|
+
className="group flex items-start gap-4 rounded-lg border border-fd-border bg-fd-muted/40 px-5 py-4 transition-all hover:-translate-y-px hover:border-fd-primary/40 hover:bg-fd-muted hover:shadow-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring motion-reduce:transition-none motion-reduce:hover:translate-y-0"
|
|
64
|
+
>
|
|
65
|
+
<Icon
|
|
66
|
+
aria-hidden
|
|
67
|
+
className="mt-1 size-4 shrink-0 text-fd-muted-foreground transition-colors group-hover:text-fd-primary"
|
|
68
|
+
/>
|
|
69
|
+
|
|
70
|
+
<span className="min-w-0 flex-1">
|
|
71
|
+
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
72
|
+
<span className="font-display text-lg leading-snug font-semibold tracking-[-0.008em] transition-colors group-hover:text-fd-primary">
|
|
73
|
+
{entry.title}
|
|
74
|
+
</span>
|
|
75
|
+
{entry.status === null ? null : (
|
|
76
|
+
<span
|
|
77
|
+
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)}`}
|
|
78
|
+
>
|
|
79
|
+
{entry.status}
|
|
80
|
+
</span>
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
83
|
+
|
|
84
|
+
{entry.description === null ? null : (
|
|
85
|
+
<span className="mt-1 block text-sm text-pretty text-fd-muted-foreground">
|
|
86
|
+
{entry.description}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</span>
|
|
90
|
+
|
|
91
|
+
{/* What the record says about it, in the same column every
|
|
92
|
+
time, so two entries can be compared without reading. */}
|
|
93
|
+
<span className="flex shrink-0 items-center gap-3 self-center">
|
|
94
|
+
<span className="hidden items-baseline gap-2 font-mono text-xs tracking-wider text-fd-muted-foreground uppercase tabular-nums sm:flex">
|
|
95
|
+
{entry.documents === 0 ? null : (
|
|
96
|
+
<span>{`${entry.documents} ${entry.documents === 1 ? "doc" : "docs"}`}</span>
|
|
97
|
+
)}
|
|
98
|
+
{entry.documents === 0 || entry.owner === null ? null : (
|
|
99
|
+
<span aria-hidden className="text-fd-border">
|
|
100
|
+
·
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
{entry.owner === null ? null : <span>{entry.owner}</span>}
|
|
104
|
+
</span>
|
|
105
|
+
|
|
106
|
+
{/* The affordance the register never had: a mark that says
|
|
107
|
+
"this opens something", visible without a pointer. */}
|
|
108
|
+
<ArrowRight
|
|
109
|
+
aria-hidden
|
|
110
|
+
className="size-4 text-fd-muted-foreground transition-all group-hover:translate-x-0.5 group-hover:text-fd-primary motion-reduce:transition-none"
|
|
111
|
+
/>
|
|
112
|
+
</span>
|
|
113
|
+
</Link>
|
|
114
|
+
</li>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</ul>
|
|
118
|
+
</section>
|
|
119
|
+
);
|
|
120
|
+
}
|