@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +501 -0
  2. package/dist/cli.mjs +99 -19
  3. package/docs/authorization.md +197 -0
  4. package/docs/index.md +4 -0
  5. package/package.json +1 -1
  6. package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
  7. package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
  8. package/templates/scaffold/AGENTS.md +52 -4
  9. package/templates/scaffold/instance.md +28 -20
  10. package/templates/scaffold/knowledge/governance-ladder.md +36 -0
  11. package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
  12. package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
  13. package/templates/scaffold/knowledge/surfaces/index.md +21 -0
  14. package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
  15. package/templates/scaffold/pnpm-lock.yaml +1198 -228
  16. package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
  17. package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
  18. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
  19. package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
  20. package/templates/scaffold/system/site/app/global.css +552 -9
  21. package/templates/scaffold/system/site/app/layout.tsx +23 -4
  22. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
  23. package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
  24. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
  25. package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
  26. package/templates/scaffold/system/site/components/governance.tsx +262 -0
  27. package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
  28. package/templates/scaffold/system/site/components/record-index.tsx +120 -0
  29. package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
  30. package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
  31. package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
  32. package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
  33. package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
  34. package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
  35. package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
  36. package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
  37. package/templates/scaffold/system/site/components.json +25 -0
  38. package/templates/scaffold/system/site/lib/governance.ts +432 -0
  39. package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
  40. package/templates/scaffold/system/site/lib/shared.ts +38 -0
  41. package/templates/scaffold/system/site/lib/source.ts +221 -5
  42. package/templates/scaffold/system/site/lib/utils.ts +6 -0
  43. package/templates/scaffold/system/site/package.json +9 -3
  44. 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 };