@panaversity/ksor 0.0.19 → 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 +501 -0
- package/dist/cli.mjs +99 -19
- package/docs/authorization.md +197 -0
- package/docs/index.md +4 -0
- package/package.json +1 -1
- 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,68 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
|
3
|
+
import { ThemeSwitch } from "fumadocs-ui/layouts/shared/slots/theme-switch";
|
|
4
|
+
|
|
5
|
+
import { FooterMark } from "@/components/footer-mark";
|
|
6
|
+
import { baseOptions } from "@/lib/layout.shared";
|
|
7
|
+
import { appName } from "@/lib/shared";
|
|
8
|
+
import { basePath, getSortedPageTree, getSortedPages } from "@/lib/source";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The chrome every page of the record wears: the governed tree as a sidebar,
|
|
12
|
+
* search, and the record's own identity at its foot.
|
|
13
|
+
*
|
|
14
|
+
* The FRONT DOOR wears it too. A system of record whose first page hides the
|
|
15
|
+
* record behind a marketing layout makes a reader click before they can see
|
|
16
|
+
* what is in it; the docs homes of AI-first projects put their first page
|
|
17
|
+
* inside this same shell (modelcontextprotocol.io redirects to a document,
|
|
18
|
+
* Cursor's `/docs` renders one — both checked 2026-08-22). One shell, defined
|
|
19
|
+
* once: two copies of it drift, and the drift shows up as a sidebar that
|
|
20
|
+
* disagrees with itself between two routes.
|
|
21
|
+
*/
|
|
22
|
+
export function RecordShell({ children }: { children: ReactNode }): ReactElement {
|
|
23
|
+
const documents = getSortedPages().length;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<DocsLayout
|
|
27
|
+
tree={getSortedPageTree()}
|
|
28
|
+
{...baseOptions()}
|
|
29
|
+
// The switch ships inside a bordered bar of its own in the sidebar
|
|
30
|
+
// footer — a flex column whose children stretch, so one 61px control sat
|
|
31
|
+
// in a 236px box that was 74% empty and read as a broken input field
|
|
32
|
+
// (measured in Chromium, 2026-08-21). That bar carries `empty:hidden`,
|
|
33
|
+
// so turning the built-in switch off removes it entirely; the control
|
|
34
|
+
// moves into the footer below, on the same row as the mark.
|
|
35
|
+
themeSwitch={{ enabled: false }}
|
|
36
|
+
// After the spread: a future sidebar key in baseOptions must not
|
|
37
|
+
// silently swallow the attribution (review finding, 2026-08-18).
|
|
38
|
+
sidebar={{
|
|
39
|
+
footer: (
|
|
40
|
+
<div className="mt-3 flex flex-col gap-2">
|
|
41
|
+
{/* The record's own identity, on every page rather than only the
|
|
42
|
+
home page: the slug is what citations carry and llms.txt is the
|
|
43
|
+
door an agent is told to read. The sidebar had three links and
|
|
44
|
+
then several hundred pixels of nothing beneath them. */}
|
|
45
|
+
<p className="text-xs text-fd-muted-foreground">
|
|
46
|
+
<span className="font-mono">{appName}</span> · {documents} document
|
|
47
|
+
{documents === 1 ? "" : "s"} ·{" "}
|
|
48
|
+
<a
|
|
49
|
+
href={`${basePath}/llms.txt`}
|
|
50
|
+
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"
|
|
51
|
+
>
|
|
52
|
+
llms.txt
|
|
53
|
+
</a>
|
|
54
|
+
</p>
|
|
55
|
+
<div className="flex items-center justify-between gap-3">
|
|
56
|
+
<p className="text-xs">
|
|
57
|
+
<FooterMark />
|
|
58
|
+
</p>
|
|
59
|
+
<ThemeSwitch />
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
),
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</DocsLayout>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
|
|
4
|
+
import { statusTone } from "@/lib/governance";
|
|
5
|
+
import type { RecordEntry } from "@/lib/source";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The record on the cover, as a body of documents rather than a diagram of one.
|
|
9
|
+
*
|
|
10
|
+
* The front door used to carry an illustration of the product's claim — one
|
|
11
|
+
* governed source projecting into its surfaces. Four of them were drawn and all
|
|
12
|
+
* four were rejected (owner, 2026-08-22), and the reason is worth keeping: a
|
|
13
|
+
* stock drawing is the ONE thing on this page that can never be true of the
|
|
14
|
+
* adopter's corpus. Every KSoR would ship the identical picture, and it would
|
|
15
|
+
* say nothing about the record behind it.
|
|
16
|
+
*
|
|
17
|
+
* So the picture is made of the record. The document `Open the record` opens
|
|
18
|
+
* leads, fully set — its own title, its own words, its own governance — and the
|
|
19
|
+
* record's next entries stand behind it with depth. A record of one document
|
|
20
|
+
* and a record of two hundred therefore get visibly different front doors,
|
|
21
|
+
* which is the point: what is on the cover is what is in the volume.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here is authored (scaffolded AGENTS.md, critical rule 1): every
|
|
24
|
+
* string is a title, description, owner or status the record declares, or a UI
|
|
25
|
+
* label. The depth is CSS, so it costs no image and follows the theme.
|
|
26
|
+
*/
|
|
27
|
+
export function RecordStack({
|
|
28
|
+
lead,
|
|
29
|
+
behind,
|
|
30
|
+
documents,
|
|
31
|
+
}: {
|
|
32
|
+
/** The document `Open the record` lands on. */
|
|
33
|
+
lead: RecordEntry;
|
|
34
|
+
/** The entries standing behind it — at most three; the rest are counted. */
|
|
35
|
+
behind: readonly RecordEntry[];
|
|
36
|
+
/** Every document in the record, for the line beneath the stack. */
|
|
37
|
+
documents: number;
|
|
38
|
+
}): ReactElement {
|
|
39
|
+
// An owner is a governance fact, so it is null with `site.governance: false`;
|
|
40
|
+
// a leaf holds nothing, so its count is 0. With neither, the footer rule
|
|
41
|
+
// would be an empty bar across the card — the same defect the sidebar's
|
|
42
|
+
// theme switch shipped with, so it is not drawn at all.
|
|
43
|
+
const footed = lead.owner !== null || lead.documents > 0;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="w-full max-w-md motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-3 motion-safe:duration-700 motion-safe:[animation-delay:180ms] motion-safe:[animation-fill-mode:backwards]">
|
|
47
|
+
<Link
|
|
48
|
+
href={lead.url}
|
|
49
|
+
className="group relative z-30 block rounded-xl border border-[var(--ksor-cover-panel-rule)] bg-[var(--ksor-cover-panel)] p-7 shadow-[0_28px_60px_-32px_rgb(15_23_42/0.55)] transition-transform hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring motion-reduce:transition-none"
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
52
|
+
{/* The one label that ties this card to the button beside it. */}
|
|
53
|
+
<span className="font-mono text-[10px] tracking-[0.2em] text-[var(--ksor-cover-muted)] uppercase">
|
|
54
|
+
Opens here
|
|
55
|
+
</span>
|
|
56
|
+
{lead.status === null ? null : <StatusChip status={lead.status} />}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<h2 className="mt-4 font-display text-2xl leading-snug font-semibold tracking-[-0.008em] transition-colors group-hover:text-fd-primary">
|
|
60
|
+
{lead.title}
|
|
61
|
+
</h2>
|
|
62
|
+
|
|
63
|
+
{lead.description === null ? null : (
|
|
64
|
+
<p className="mt-3 text-sm/relaxed text-pretty text-[var(--ksor-cover-muted)]">
|
|
65
|
+
{lead.description}
|
|
66
|
+
</p>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{!footed ? null : (
|
|
70
|
+
<div className="mt-6 flex items-baseline justify-between gap-4 border-t border-[var(--ksor-cover-panel-rule)] pt-4 font-mono text-[11px] text-[var(--ksor-cover-muted)]">
|
|
71
|
+
<span>{lead.owner ?? ""}</span>
|
|
72
|
+
{lead.documents === 0 ? null : (
|
|
73
|
+
<span className="tracking-wider uppercase tabular-nums">
|
|
74
|
+
{`${lead.documents} ${lead.documents === 1 ? "doc" : "docs"}`}
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</Link>
|
|
80
|
+
|
|
81
|
+
{/* The volume it sits on. Painted in DOM order, so each card would cover
|
|
82
|
+
the one before it — the z-index descends to put them BEHIND. */}
|
|
83
|
+
{behind.map((entry, index) => (
|
|
84
|
+
<Link
|
|
85
|
+
key={entry.url}
|
|
86
|
+
href={entry.url}
|
|
87
|
+
className="group relative -mt-4 block rounded-xl border border-[var(--ksor-cover-panel-rule)] bg-[var(--ksor-cover-panel)] px-7 pt-6 pb-4 shadow-[0_18px_40px_-30px_rgb(15_23_42/0.5)] transition-transform hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring motion-reduce:transition-none"
|
|
88
|
+
style={{
|
|
89
|
+
zIndex: 20 - index * 10,
|
|
90
|
+
transform: `scale(${1 - (index + 1) * 0.028})`,
|
|
91
|
+
opacity: 1 - index * 0.13,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<span className="flex items-baseline justify-between gap-4">
|
|
95
|
+
<span className="truncate font-display text-base font-medium transition-colors group-hover:text-fd-primary">
|
|
96
|
+
{entry.title}
|
|
97
|
+
</span>
|
|
98
|
+
<span className="flex shrink-0 items-baseline gap-2 font-mono text-[10px] tracking-widest text-[var(--ksor-cover-muted)] uppercase tabular-nums">
|
|
99
|
+
{entry.documents === 0
|
|
100
|
+
? null
|
|
101
|
+
: `${entry.documents} ${entry.documents === 1 ? "doc" : "docs"}`}
|
|
102
|
+
{entry.status === null ? null : <StatusChip status={entry.status} />}
|
|
103
|
+
</span>
|
|
104
|
+
</span>
|
|
105
|
+
</Link>
|
|
106
|
+
))}
|
|
107
|
+
|
|
108
|
+
{/* Rendered from one template literal, not `{documents} documents`:
|
|
109
|
+
React splits an interpolation with a comment node, so the split form
|
|
110
|
+
ships as `3<!-- --> documents` and no assertion can ever match it. */}
|
|
111
|
+
<p className="mt-6 text-center font-mono text-[11px] tracking-[0.18em] text-[var(--ksor-cover-muted)] uppercase">
|
|
112
|
+
{`${documents} ${documents === 1 ? "document" : "documents"} in the record`}
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
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.
|
|
122
|
+
*/
|
|
123
|
+
function StatusChip({ status }: { status: string }): ReactElement {
|
|
124
|
+
return (
|
|
125
|
+
<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
|
+
>
|
|
128
|
+
{status}
|
|
129
|
+
</span>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { TOCItemType } from "fumadocs-core/toc";
|
|
4
|
+
import { createContext, use, useEffect, useState, type ReactElement, type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The document's headings, handed down from the page.
|
|
8
|
+
*
|
|
9
|
+
* A slot is a component TYPE, not an element, so the page cannot close over its
|
|
10
|
+
* own `toc` when it names one — a function cannot cross the server/client
|
|
11
|
+
* boundary. Reading the items from the shell's observer context instead would
|
|
12
|
+
* cost the server render: that context is filled in an effect, so the exported
|
|
13
|
+
* HTML would ship an empty rail where it currently ships every anchor. A
|
|
14
|
+
* context of our own, given serializable items by the server, keeps both.
|
|
15
|
+
*/
|
|
16
|
+
const TocItemsContext = createContext<readonly TOCItemType[]>([]);
|
|
17
|
+
|
|
18
|
+
export function TocItems({
|
|
19
|
+
items,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
items: readonly TOCItemType[];
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}): ReactElement {
|
|
25
|
+
return <TocItemsContext value={items}>{children}</TocItemsContext>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* "On this page", tracking where the reader actually is.
|
|
30
|
+
*
|
|
31
|
+
* The shell's own rail marks a heading active when 90% of it is visible
|
|
32
|
+
* ANYWHERE in the viewport (`AnchorProvider` watches with `{ threshold: 0.9 }`
|
|
33
|
+
* and no `rootMargin`), and then highlights whichever became active most
|
|
34
|
+
* recently. On a long page that reads fine. On a governed record it does not:
|
|
35
|
+
* these documents are short-sectioned, so several headings sit on screen at
|
|
36
|
+
* once and the one entering from the BOTTOM wins — the marker ran two to four
|
|
37
|
+
* headings ahead of the reader (measured 2026-08-22: reading "owner" while the
|
|
38
|
+
* rail marked "description").
|
|
39
|
+
*
|
|
40
|
+
* The observer options are not configurable and the observer itself is not
|
|
41
|
+
* exported, so the selection cannot be corrected from outside — only replaced.
|
|
42
|
+
* This is the supported seam for that: `DocsPage`'s `slots.toc.main`. The
|
|
43
|
+
* shell's provider and its small-screen popover are kept exactly as they are;
|
|
44
|
+
* only the rail's choice of "here" is ours.
|
|
45
|
+
*
|
|
46
|
+
* The rule: the active heading is the LAST one whose top has passed the reading
|
|
47
|
+
* line — which is what a person means by "the section I am in". Nothing below
|
|
48
|
+
* the line can be it, however visible it is.
|
|
49
|
+
*/
|
|
50
|
+
export function RecordToc(): ReactElement | null {
|
|
51
|
+
const items = use(TocItemsContext);
|
|
52
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (items.length === 0) return;
|
|
56
|
+
const ids = items.map((item) => item.url.slice(1));
|
|
57
|
+
|
|
58
|
+
// Scroll position, not intersection: a heading is "here" once it has passed
|
|
59
|
+
// the reading line, and stays here until the next one does. An observer
|
|
60
|
+
// answers "is it visible", which is a different question and the reason the
|
|
61
|
+
// shell's rail runs ahead.
|
|
62
|
+
const READING_LINE = 140;
|
|
63
|
+
let frame = 0;
|
|
64
|
+
const measure = (): void => {
|
|
65
|
+
frame = 0;
|
|
66
|
+
let current: string | null = null;
|
|
67
|
+
for (const id of ids) {
|
|
68
|
+
const element = document.getElementById(id);
|
|
69
|
+
if (element === null) continue;
|
|
70
|
+
if (element.getBoundingClientRect().top <= READING_LINE) current = id;
|
|
71
|
+
}
|
|
72
|
+
// Before the first heading passes the line the reader is in the lead
|
|
73
|
+
// paragraphs, which belong to the first section — highlighting nothing
|
|
74
|
+
// there reads as broken rather than as honest.
|
|
75
|
+
setActiveId(current ?? ids[0] ?? null);
|
|
76
|
+
};
|
|
77
|
+
const onScroll = (): void => {
|
|
78
|
+
if (frame === 0) frame = requestAnimationFrame(measure);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
measure();
|
|
82
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
83
|
+
window.addEventListener("resize", onScroll, { passive: true });
|
|
84
|
+
return () => {
|
|
85
|
+
if (frame !== 0) cancelAnimationFrame(frame);
|
|
86
|
+
window.removeEventListener("scroll", onScroll);
|
|
87
|
+
window.removeEventListener("resize", onScroll);
|
|
88
|
+
};
|
|
89
|
+
}, [items]);
|
|
90
|
+
|
|
91
|
+
if (items.length === 0) return null;
|
|
92
|
+
|
|
93
|
+
// The shallowest heading in THIS document is the left edge, so a document
|
|
94
|
+
// whose sections start at h3 does not render its whole rail indented.
|
|
95
|
+
const top = Math.min(...items.map((item) => item.depth));
|
|
96
|
+
|
|
97
|
+
// The section the reader is IN, as well as the subsection they are AT.
|
|
98
|
+
// Marking only the exact heading meant that scrolling down into a
|
|
99
|
+
// subsection put the light on a minor entry and left the section it belongs
|
|
100
|
+
// to dark — the rail stopped answering "where am I" the moment it mattered
|
|
101
|
+
// most. Walk back from the active item, taking each heading shallower than
|
|
102
|
+
// the last: the parent, then its parent.
|
|
103
|
+
const ancestors = new Set<string>();
|
|
104
|
+
const activeIndex = items.findIndex((item) => item.url.slice(1) === activeId);
|
|
105
|
+
if (activeIndex > 0) {
|
|
106
|
+
let depth = items[activeIndex]?.depth ?? top;
|
|
107
|
+
for (let index = activeIndex - 1; index >= 0 && depth > top; index -= 1) {
|
|
108
|
+
const candidate = items[index];
|
|
109
|
+
if (candidate === undefined || candidate.depth >= depth) continue;
|
|
110
|
+
ancestors.add(candidate.url);
|
|
111
|
+
depth = candidate.depth;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
// The container is the shell's own, copied verbatim: it carries the grid
|
|
117
|
+
// area, the rail width and the `max-xl:hidden` that hands small screens to
|
|
118
|
+
// the popover. Replacing a slot means supplying what the slot supplied —
|
|
119
|
+
// a first version rendered only the list and the rail escaped its column,
|
|
120
|
+
// laying 1156px wide across the page (found live, 2026-08-22).
|
|
121
|
+
<div
|
|
122
|
+
id="nd-toc"
|
|
123
|
+
className="sticky top-(--fd-docs-row-1) flex h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] w-(--fd-toc-width) flex-col [grid-area:toc] pt-12 pe-4 pb-2 max-xl:hidden xl:layout:[--fd-toc-width:268px]"
|
|
124
|
+
>
|
|
125
|
+
<p className="mb-3 ps-4 font-mono text-[0.6875rem] tracking-[0.16em] text-fd-muted-foreground uppercase">
|
|
126
|
+
On this page
|
|
127
|
+
</p>
|
|
128
|
+
<nav aria-label="On this page" className="flex flex-col overflow-y-auto text-sm">
|
|
129
|
+
{items.map((item) => {
|
|
130
|
+
const id = item.url.slice(1);
|
|
131
|
+
const here = id === activeId;
|
|
132
|
+
const within = ancestors.has(item.url);
|
|
133
|
+
return (
|
|
134
|
+
<a
|
|
135
|
+
key={item.url}
|
|
136
|
+
href={item.url}
|
|
137
|
+
// The bar IS the border, so it cannot drift from the row it
|
|
138
|
+
// marks — the shell drew it as a separately positioned track.
|
|
139
|
+
// Three states, not two: AT this heading, INSIDE its section, or
|
|
140
|
+
// neither. The section keeps the reader's place without competing
|
|
141
|
+
// with the line they are actually on — full ink and a dimmed bar
|
|
142
|
+
// against the accent and a solid one.
|
|
143
|
+
className={`border-s-2 py-1.5 pe-2 transition-colors ${
|
|
144
|
+
here
|
|
145
|
+
? "border-fd-primary text-fd-primary"
|
|
146
|
+
: within
|
|
147
|
+
? "border-fd-primary/40 text-fd-foreground"
|
|
148
|
+
: "border-fd-border text-fd-muted-foreground hover:text-fd-foreground"
|
|
149
|
+
}`}
|
|
150
|
+
style={{ paddingInlineStart: `${(item.depth - top) * 0.75 + 1}rem` }}
|
|
151
|
+
aria-current={here ? "location" : undefined}
|
|
152
|
+
>
|
|
153
|
+
{item.title}
|
|
154
|
+
</a>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</nav>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useDocsSearch } from "fumadocs-core/search/client";
|
|
4
|
+
// `staticClient`, not `oramaStaticClient`: 16.14.0 replaced the Orama engine
|
|
5
|
+
// with ZBSearch and renamed the export, keeping the old name as a deprecated
|
|
6
|
+
// alias. Riding an alias is borrowing time — the subpath and the options are
|
|
7
|
+
// unchanged, so the new name costs nothing today and does not have to be found
|
|
8
|
+
// again when the alias goes.
|
|
9
|
+
import { staticClient } from "fumadocs-core/search/client/orama-static";
|
|
10
|
+
import {
|
|
11
|
+
SearchDialog,
|
|
12
|
+
SearchDialogClose,
|
|
13
|
+
SearchDialogContent,
|
|
14
|
+
SearchDialogHeader,
|
|
15
|
+
SearchDialogIcon,
|
|
16
|
+
SearchDialogInput,
|
|
17
|
+
SearchDialogList,
|
|
18
|
+
SearchDialogListItem,
|
|
19
|
+
SearchDialogOverlay,
|
|
20
|
+
type SharedProps,
|
|
21
|
+
} from "fumadocs-ui/components/dialog/search";
|
|
22
|
+
import { useCallback, useMemo } from "react";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The search dialog, with a caveat status on the rows that have one.
|
|
26
|
+
*
|
|
27
|
+
* Search was the last place a withdrawn document and its replacement looked
|
|
28
|
+
* identical — and worse than the others, because the result SNIPPET quotes the
|
|
29
|
+
* withdrawn figure, so a reader can take the wrong number out of the results
|
|
30
|
+
* without ever opening the page (research/site-design.md F3).
|
|
31
|
+
*
|
|
32
|
+
* Composed from the shell's own exported primitives rather than rebuilt: the
|
|
33
|
+
* only difference from the default dialog is the `Item` renderer. The status
|
|
34
|
+
* is NOT written into the search index — that would put a label inside the
|
|
35
|
+
* record's own titles, which the site does not author. It travels as a map of
|
|
36
|
+
* route → status, built on the server, and is applied at render time.
|
|
37
|
+
*/
|
|
38
|
+
export interface KsorSearchDialogProps extends SharedProps {
|
|
39
|
+
/** Where the static index is served from. */
|
|
40
|
+
api?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Route → caveat status, read from the JSON the document carries (see
|
|
45
|
+
* app/layout.tsx). It arrives that way rather than as a prop because
|
|
46
|
+
* RootProvider types its `options` against the SHIPPED dialog's props, and
|
|
47
|
+
* casting that away would hide a real break the day those props move.
|
|
48
|
+
*/
|
|
49
|
+
function readStatuses(): Record<string, string> {
|
|
50
|
+
if (typeof document === "undefined") return {};
|
|
51
|
+
const el = document.getElementById("ksor-statuses");
|
|
52
|
+
if (el === null) return {};
|
|
53
|
+
try {
|
|
54
|
+
const parsed: unknown = JSON.parse(el.textContent ?? "{}");
|
|
55
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, string>) : {};
|
|
56
|
+
} catch {
|
|
57
|
+
// A record with no caveats, or a document that never carried the map:
|
|
58
|
+
// every row then renders exactly as the shipped dialog renders it.
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const trimSlash = (url: string): string =>
|
|
64
|
+
url.length > 1 && url.endsWith("/") ? url.slice(0, -1) : url;
|
|
65
|
+
|
|
66
|
+
export default function KsorSearchDialog({ api, onOpenChange, ...props }: KsorSearchDialogProps) {
|
|
67
|
+
const client = staticClient({ from: api });
|
|
68
|
+
const { search, setSearch, query } = useDocsSearch({ client });
|
|
69
|
+
|
|
70
|
+
// Closing the dialog clears the query. `useDocsSearch` keeps the term in
|
|
71
|
+
// component state and the dialog stays MOUNTED when it closes, so without
|
|
72
|
+
// this the next Cmd-K reopened onto the last search and its results — a
|
|
73
|
+
// reader coming back to look for something else had to clear the field
|
|
74
|
+
// first, and a stale result list is a worse first impression than an empty
|
|
75
|
+
// one (found live 2026-08-22).
|
|
76
|
+
const handleOpenChange = useCallback(
|
|
77
|
+
(open: boolean): void => {
|
|
78
|
+
if (!open) setSearch("");
|
|
79
|
+
onOpenChange(open);
|
|
80
|
+
},
|
|
81
|
+
[onOpenChange, setSearch],
|
|
82
|
+
);
|
|
83
|
+
const byUrl = useMemo(() => {
|
|
84
|
+
const map = new Map<string, string>();
|
|
85
|
+
for (const [url, status] of Object.entries(readStatuses())) map.set(trimSlash(url), status);
|
|
86
|
+
return map;
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<SearchDialog
|
|
91
|
+
search={search}
|
|
92
|
+
onSearchChange={setSearch}
|
|
93
|
+
isLoading={query.isLoading}
|
|
94
|
+
onOpenChange={handleOpenChange}
|
|
95
|
+
{...props}
|
|
96
|
+
>
|
|
97
|
+
<SearchDialogOverlay />
|
|
98
|
+
<SearchDialogContent>
|
|
99
|
+
<SearchDialogHeader>
|
|
100
|
+
<SearchDialogIcon />
|
|
101
|
+
<SearchDialogInput />
|
|
102
|
+
<SearchDialogClose />
|
|
103
|
+
</SearchDialogHeader>
|
|
104
|
+
<SearchDialogList
|
|
105
|
+
items={query.data !== "empty" ? query.data : null}
|
|
106
|
+
Item={(itemProps) => {
|
|
107
|
+
// Only a page row carries a document's status; a heading or a text
|
|
108
|
+
// fragment is part of one, and marking every fragment would be the
|
|
109
|
+
// noise the page's own chip rule exists to avoid.
|
|
110
|
+
const status =
|
|
111
|
+
itemProps.item.type === "page" ? byUrl.get(trimSlash(itemProps.item.url)) : undefined;
|
|
112
|
+
// The chip rides a data attribute rather than replacing the row's
|
|
113
|
+
// children: the shell renders result text through its own markdown
|
|
114
|
+
// renderer to turn the search highlights into <mark>, and children
|
|
115
|
+
// passed here REPLACE that — which published the literal string
|
|
116
|
+
// "Purchase approval <mark>thresholds</mark> (2019)" into the
|
|
117
|
+
// dialog (seen in Chromium, 2026-08-21). CSS appends the label in
|
|
118
|
+
// app/global.css.
|
|
119
|
+
return (
|
|
120
|
+
<SearchDialogListItem
|
|
121
|
+
{...itemProps}
|
|
122
|
+
{...(status === undefined ? {} : { "data-ksor-status": status })}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
</SearchDialogContent>
|
|
128
|
+
</SearchDialog>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
import { caveatStatus, statusTone } from "@/lib/governance";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The sidebar's status marker, rendered by the shell's own status-badges
|
|
7
|
+
* plugin (`lib/source.ts`).
|
|
8
|
+
*
|
|
9
|
+
* The sidebar is where a reader chooses. Without this, a withdrawn document and
|
|
10
|
+
* the one that replaced it were pixel-identical rows — the governance appeared
|
|
11
|
+
* only after the click, which is the moment it is least useful
|
|
12
|
+
* (research/site-design.md F3).
|
|
13
|
+
*
|
|
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.
|
|
18
|
+
*/
|
|
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
|
|
24
|
+
// has to survive beside a title that runs two lines in a ~200px column. An
|
|
25
|
+
// earlier hand-rolled version pinned the chip right with `truncate`, which
|
|
26
|
+
// clipped the title AND the chip to "sup…" (seen in Chromium, 2026-08-21) —
|
|
27
|
+
// the marker has to fit around the name, not fight it.
|
|
28
|
+
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>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { Slot } from "radix-ui";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const badgeVariants = cva(
|
|
8
|
+
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
|
13
|
+
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
|
14
|
+
destructive:
|
|
15
|
+
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
|
16
|
+
outline:
|
|
17
|
+
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
18
|
+
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
19
|
+
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
variant: "default",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
function Badge({
|
|
29
|
+
className,
|
|
30
|
+
variant = "default",
|
|
31
|
+
asChild = false,
|
|
32
|
+
...props
|
|
33
|
+
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
34
|
+
const Comp = asChild ? Slot.Root : "span";
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Comp
|
|
38
|
+
data-slot="badge"
|
|
39
|
+
data-variant={variant}
|
|
40
|
+
className={cn(badgeVariants({ variant }), className)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { Badge, badgeVariants };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { Slot } from "radix-ui";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
|
15
|
+
outline:
|
|
16
|
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
17
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
18
|
+
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
19
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
20
|
+
},
|
|
21
|
+
size: {
|
|
22
|
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
23
|
+
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
24
|
+
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
|
25
|
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
26
|
+
icon: "size-9",
|
|
27
|
+
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
|
28
|
+
"icon-sm": "size-8",
|
|
29
|
+
"icon-lg": "size-10",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
variant: "default",
|
|
34
|
+
size: "default",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function Button({
|
|
40
|
+
className,
|
|
41
|
+
variant = "default",
|
|
42
|
+
size = "default",
|
|
43
|
+
asChild = false,
|
|
44
|
+
...props
|
|
45
|
+
}: React.ComponentProps<"button"> &
|
|
46
|
+
VariantProps<typeof buttonVariants> & {
|
|
47
|
+
asChild?: boolean;
|
|
48
|
+
}) {
|
|
49
|
+
const Comp = asChild ? Slot.Root : "button";
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Comp
|
|
53
|
+
data-slot="button"
|
|
54
|
+
data-variant={variant}
|
|
55
|
+
data-size={size}
|
|
56
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Separator as SeparatorPrimitive } from "radix-ui";
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
function Separator({
|
|
9
|
+
className,
|
|
10
|
+
orientation = "horizontal",
|
|
11
|
+
decorative = true,
|
|
12
|
+
...props
|
|
13
|
+
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
14
|
+
return (
|
|
15
|
+
<SeparatorPrimitive.Root
|
|
16
|
+
data-slot="separator"
|
|
17
|
+
decorative={decorative}
|
|
18
|
+
orientation={orientation}
|
|
19
|
+
className={cn(
|
|
20
|
+
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { Separator };
|