@panaversity/ksor 0.0.27 → 0.0.29

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 (31) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/cli.mjs +108 -14
  3. package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
  4. package/dist/gateway.mjs +1 -1
  5. package/docs/authorization.md +1 -1
  6. package/docs/deploying.md +71 -20
  7. package/package.json +3 -3
  8. package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
  9. package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
  10. package/templates/scaffold/AGENTS.md +54 -5
  11. package/templates/scaffold/README.md +6 -5
  12. package/templates/scaffold/env.example +24 -8
  13. package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
  14. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
  15. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
  16. package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
  17. package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
  18. package/templates/scaffold/system/site/components/governance.tsx +17 -25
  19. package/templates/scaffold/system/site/components/record-views.tsx +241 -0
  20. package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
  21. package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
  22. package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
  23. package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
  24. package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
  25. package/templates/scaffold/system/site/lib/attachments.ts +105 -0
  26. package/templates/scaffold/system/site/lib/deck.ts +59 -0
  27. package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
  28. package/templates/scaffold/system/site/lib/srs.ts +219 -0
  29. package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
  30. package/templates/scaffold/system/site/source.config.ts +54 -1
  31. package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
@@ -35,6 +35,35 @@ const REQUIRED_KEYS = ["title", "status"]; // level 0 — the ladder, not a gate
35
35
  const STATUS_VALUES = new Set(["draft", "review", "approved", "superseded"]);
36
36
  const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
37
37
 
38
+ // Study attachments: `x.summary.md` and `x.flashcards.yaml` belong to `x.md`.
39
+ // An attachment is PART OF its parent — no route, no stable id, no governance
40
+ // of its own — so it is neither a document nor an asset, and gets its own
41
+ // rules below. This mirrors packages/content/src/lib/attachment-rule.ts, which
42
+ // this dependency-free file cannot import; ATTACHMENT_CASES there is the table
43
+ // both are held to.
44
+ const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml"];
45
+ // One character off a real attachment, refused BY NAME: `.yml` reaches the
46
+ // site bundler's `Unknown file type` throw, which names the path and nothing
47
+ // about the rule.
48
+ const ATTACHMENT_NEAR_MISSES = [
49
+ [".flashcards.yml", ".flashcards.yaml"],
50
+ [".flashcards.json", ".flashcards.yaml"],
51
+ [".summary.markdown", ".summary.md"],
52
+ ];
53
+
54
+ /** The attachment suffix this name carries, or null. A dotfile has no stem. */
55
+ function attachmentSuffixOf(base) {
56
+ return ATTACHMENT_SUFFIXES.find((s) => base.length > s.length && base.endsWith(s)) ?? null;
57
+ }
58
+ function isAttachment(base) {
59
+ return attachmentSuffixOf(base) !== null;
60
+ }
61
+ /** The document an attachment belongs to: always `<stem>.md`. */
62
+ function parentDocumentOf(base) {
63
+ const suffix = attachmentSuffixOf(base);
64
+ return suffix === null ? null : `${base.slice(0, -suffix.length)}.md`;
65
+ }
66
+
38
67
  // PNG integrity, dependency-free: signature + per-chunk CRC-32. A damaged
39
68
  // image beside a document is a check-time problem with the file named, never
40
69
  // a build-time 500 with no filename in it.
@@ -381,7 +410,9 @@ if (!existsSync(knowledgeDir)) {
381
410
  }
382
411
  const dirs = walkDirs(knowledgeDir);
383
412
  const all = [...files, ...dirs];
384
- const mdFiles = files.filter((p) => p.endsWith(".md"));
413
+ // Attachments are never documents: they carry no frontmatter, own no route,
414
+ // and are checked by their own rules instead.
415
+ const mdFiles = files.filter((p) => p.endsWith(".md") && !isAttachment(path.basename(p)));
385
416
 
386
417
  if (mdFiles.length === 0) {
387
418
  problem(
@@ -446,7 +477,12 @@ if (!existsSync(knowledgeDir)) {
446
477
  );
447
478
  }
448
479
  seenLower.set(lower, rel);
449
- if (files.includes(p) && !p.endsWith(".md") && !ASSET_EXTENSIONS.has(path.extname(p))) {
480
+ if (
481
+ files.includes(p) &&
482
+ !p.endsWith(".md") &&
483
+ !isAttachment(base) &&
484
+ !ASSET_EXTENSIONS.has(path.extname(p))
485
+ ) {
450
486
  problem(
451
487
  rel,
452
488
  `unexpected file type "${path.extname(p) || base}"`,
@@ -486,6 +522,51 @@ if (!existsSync(knowledgeDir)) {
486
522
  }
487
523
  }
488
524
 
525
+ // Study attachments: bound to a parent, carrying no governance of their own.
526
+ for (const p of files) {
527
+ const base = path.basename(p);
528
+ const rel = path.relative(root, p);
529
+
530
+ const nearMiss = ATTACHMENT_NEAR_MISSES.find(
531
+ ([wrong]) => base.length > wrong.length && base.endsWith(wrong),
532
+ );
533
+ if (nearMiss && !isAttachment(base)) {
534
+ problem(
535
+ rel,
536
+ `${nearMiss[0]} is not an attachment extension`,
537
+ "the site reads decks as YAML and accepts only .yaml — a near miss is not picked up as a deck, and fails the build naming the path but not the rule",
538
+ `rename it to ${base.slice(0, -nearMiss[0].length)}${nearMiss[1]}`,
539
+ );
540
+ continue;
541
+ }
542
+
543
+ if (!isAttachment(base)) continue;
544
+
545
+ const parent = parentDocumentOf(base);
546
+ if (parent && !existsSync(path.join(path.dirname(p), parent))) {
547
+ problem(
548
+ rel,
549
+ `attachment of ${parent}, which is not in the record`,
550
+ "an attachment inherits its parent's governance — with no parent there is nothing to inherit, so it would be published under no tier and covered by no takedown",
551
+ `add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
552
+ );
553
+ }
554
+
555
+ if (base.endsWith(".md") || base.endsWith(".mdx")) {
556
+ const text = readFileSync(p, "utf8")
557
+ .replace(/^\uFEFF/, "")
558
+ .replaceAll("\r\n", "\n");
559
+ if (text.startsWith("---\n")) {
560
+ problem(
561
+ rel,
562
+ "attachment declares frontmatter",
563
+ "an attachment is part of its parent and carries none of its own governance — a key here would look like it governs something and would govern nothing (a visibility: on a summary of a restricted document is the shape that matters)",
564
+ `remove the frontmatter block; ${parent ?? "its parent"} is what carries the governance`,
565
+ );
566
+ }
567
+ }
568
+ }
569
+
489
570
  // foo.md vs foo/index.md route collisions
490
571
  for (const p of mdFiles) {
491
572
  const sibling = p.replace(/\.md$/, "");
@@ -96,7 +96,7 @@ Stand it up in this order (each step's errors explain how to fix themselves):
96
96
  - `KSOR_DB_URL` — the Postgres store named by `instance.md`'s `dsn_env`. It
97
97
  needs the pgvector extension: `CREATE EXTENSION vector;`
98
98
  - `GEMINI_API_KEY` — the embedding provider key.
99
- - `KSOR_AUTH_DISABLED=1` — **required for a local run.** `ksor serve`
99
+ - `KSOR_AUTH=disabled-local` — **required for a local run.** `ksor serve`
100
100
  refuses to boot unauthenticated without it, deliberately, so a server is
101
101
  never left open by accident. It binds loopback, where auth off is the
102
102
  intended dev shape. A PUBLIC deployment configures the SSO door instead —
@@ -228,11 +228,11 @@ abandoned ones.
228
228
  ### Serving safely (fail-closed posture)
229
229
 
230
230
  `pnpm serve` **refuses to boot unauthenticated** — there is no auth-off
231
- default. A local run says so deliberately with `KSOR_AUTH_DISABLED=1` and binds
231
+ default. A local run says so deliberately with `KSOR_AUTH=disabled-local` and binds
232
232
  loopback, which is the intended dev shape. A **public**
233
233
  bind refuses to boot unless auth is configured (`KSOR_SSO_URL` +
234
234
  `KSOR_MCP_RESOURCE_URL` + `KSOR_JWT_ALLOWED_AUDIENCES`, making it an OAuth
235
- Resource Server) OR you deliberately set `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1`.
235
+ Resource Server) OR you deliberately set `KSOR_AUTH=disabled-public`.
236
236
  Never let a dropped auth variable silently ship an open door. On a non-loopback
237
237
  bind, set `KSOR_ALLOWED_HOSTS` / `KSOR_ALLOWED_ORIGINS`; on more than one
238
238
  replica, set a shared `KSOR_SNAPSHOT_KEYS` (unset ⇒ a per-process key, so a
@@ -240,7 +240,7 @@ search token minted by one replica fails on another).
240
240
 
241
241
  Three things worth being deliberate about:
242
242
 
243
- - **`KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` serves your whole record to anyone
243
+ - **`KSOR_AUTH=disabled-public` serves your whole record to anyone
244
244
  who can reach the port.** It exists for deployments fronted by your own
245
245
  gateway or network policy. If nothing else is in front, do not set it.
246
246
  - **Set `KSOR_SSO_ISSUER` when your SSO stamps a stable `iss`.** Audience is
@@ -261,7 +261,7 @@ Once the SSO door is configured (the three variables above), the server is an
261
261
  OAuth **Resource Server**, which means a client is not told the authorization
262
262
  server — it discovers it. Nothing here needs configuring beyond those variables;
263
263
  this is what your agents will experience, and what to check when one cannot
264
- connect. With `KSOR_AUTH_DISABLED=1` — the local default `.env.example` ships —
264
+ connect. With `KSOR_AUTH=disabled-local` — the local default `.env.example` ships —
265
265
  none of it applies: there is no challenge and the metadata document answers 404,
266
266
  because there is no authorization server to point at.
267
267
 
@@ -448,6 +448,12 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
448
448
  publishes that as fact) and `superseded` (a legacy marker — prefer `status`)
449
449
  are available. No other keys; never
450
450
  `id:` or `name:` — the path is the identity.
451
+ - **Each page says how long it takes to read**, just above the body, counted from the
452
+ document's own words when the site is built. Fenced code and frontmatter do
453
+ not count toward it, so a short page carrying a long example is not reported
454
+ as a long read. Nothing to author — it is derived. A document with a summary
455
+ shows the figure on both tabs, so a reader can see at a glance how much the
456
+ summary saves them.
451
457
  - **The governance keys are rendered, so they are worth filling in.** Each
452
458
  page shows its owner and effective date under the title, lists every
453
459
  `provenance` entry separately at the foot, and — for a superseded document —
@@ -500,6 +506,49 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
500
506
  refused.
501
507
  - Images and assets live in `knowledge/` beside the document that uses them,
502
508
  referenced by relative links. A relative link must never leave `knowledge/`.
509
+ - **Study attachments.** A document may carry two optional companions named
510
+ after it, in the same folder: `<doc>.summary.md` (a short précis) and
511
+ `<doc>.flashcards.yaml` (a recall deck). The summary appears as a second tab
512
+ beside the document's own words; the deck appears at the END of that
513
+ document's page. Both appear nowhere else in the site.
514
+
515
+ An attachment is **part of its document**, not a document. It has no URL of
516
+ its own, no sidebar row, no line in `llms.txt`, and no identity an agent can
517
+ cite — so it carries **no frontmatter at all** (the checker refuses any), and
518
+ it takes its `visibility:` and any takedown from its parent. Restrict the
519
+ document and its summary and deck go with it; there is no way to publish a
520
+ summary more widely than the document it summarises. An attachment whose
521
+ document is missing is refused, by `pnpm check` and by `pnpm build` alike.
522
+
523
+ A deck is YAML, and the extension is exactly `.flashcards.yaml` — `.yml` is
524
+ refused by name:
525
+
526
+ ```yaml
527
+ deck:
528
+ title: Expense approvals
529
+ description: Recall checks for the approvals policy.
530
+ cards:
531
+ - front: Who approves a purchase above the threshold?
532
+ back: A second approver, independent of the requester.
533
+ why: Optional — a prompt shown before the answer.
534
+ ```
535
+
536
+ No `id:` anywhere, on the deck or a card: the path is the deck's identity,
537
+ and a card's identity is its own text. Edit a card and only that card's
538
+ review progress starts again; the rest is untouched.
539
+
540
+ **A card may only say what its document says.** The summary and the deck are
541
+ ways of rehearsing the record, never a second source — a card asserting
542
+ something its document does not is a claim nothing governs and no agent can
543
+ cite. Ask your coding agent to write them from a document and to check every
544
+ answer back against it.
545
+
546
+ Review scheduling uses a simple interval ladder (an SM-2 variant): a missed
547
+ card returns in about a minute, a recalled card's interval grows by roughly
548
+ 2.5x each time. It is not FSRS and makes no retention guarantee. Progress is
549
+ kept in the reader's own browser, so it is per-person and per-device, and it
550
+ is not part of the record.
551
+
503
552
  - Copy load-bearing values (numbers, thresholds, dates) exactly from their
504
553
  source, and name the source in `provenance`.
505
554
 
@@ -5,8 +5,9 @@ project's people and AI agents operate from.
5
5
 
6
6
  Two worlds live here:
7
7
 
8
- - **`knowledge/` — the record.** Plain governed markdown. Yours forever,
9
- readable anywhere, portable without this repository's code.
8
+ - **`knowledge/` — the record.** Plain governed markdown (plus the optional
9
+ study attachments a document may carry). Yours forever, readable anywhere,
10
+ portable without this repository's code.
10
11
  - **`system/` — the system.** The site (and later, services) that serve the
11
12
  record. Replaceable machinery.
12
13
 
@@ -30,13 +31,13 @@ Postgres store (with pgvector) and an embedding provider key, so it is not
30
31
  part of `pnpm dev`. The ordered path is:
31
32
 
32
33
  ```sh
33
- cp .env.example .env # fill in KSOR_DB_URL, GEMINI_API_KEY, KSOR_AUTH_DISABLED=1
34
+ cp .env.example .env # fill in KSOR_DB_URL, GEMINI_API_KEY, KSOR_AUTH=disabled-local
34
35
  pnpm provision # once: apply the schema, authorize ingest
35
36
  pnpm refresh # ingest the record, collect retired generations
36
37
  pnpm serve # the MCP server
37
38
  ```
38
39
 
39
- `ksor` reads `.env` automatically — nothing to export. `KSOR_AUTH_DISABLED=1`
40
+ `ksor` reads `.env` automatically — nothing to export. `KSOR_AUTH=disabled-local`
40
41
  is required for a local run: serve refuses to boot unauthenticated on purpose,
41
42
  so a server is never open by accident.
42
43
 
@@ -55,7 +56,7 @@ effect of starting a process. A rerun on an unchanged record
55
56
  costs nothing: no new generation, no embedding, no rows. Edit a document and
56
57
  the next run picks up exactly that change. `AGENTS.md` → "Serving to agents" is the
57
58
  full runbook; your coding agent reads it first. `pnpm serve` refuses to boot
58
- unauthenticated: a local run declares `KSOR_AUTH_DISABLED=1` (already in
59
+ unauthenticated: a local run declares `KSOR_AUTH=disabled-local` (already in
59
60
  `.env.example`) and binds loopback, so a server is never left open by accident;
60
61
  a public bind needs a configured SSO door instead. Any other operation is
61
62
  `pnpm exec ksor <verb>`.
@@ -12,11 +12,23 @@ KSOR_DB_URL=postgresql://user:password@host:5432/dbname
12
12
  # The embedding provider key. instance.md defaults to gemini-embedding-001.
13
13
  GEMINI_API_KEY=
14
14
 
15
- # Local development posture. `ksor serve` REFUSES to boot unauthenticated
16
- # without this — deliberately, so a server is never open by accident. It binds
17
- # loopback, where auth off is the intended dev shape.
15
+ # ── Who may ask ─────────────────────────────────────────────────────────────
16
+ # ONE variable, and its VALUE is the decision. `ksor serve` refuses to boot
17
+ # without either this or a configured SSO door — a server is never open by
18
+ # accident.
18
19
  #
19
- # For a PUBLIC deployment, delete this line and configure the SSO door instead:
20
+ # disabled-local no auth, loopback only. A PUBLIC bind REFUSES, so copying
21
+ # this file into a hosting dashboard cannot quietly open your
22
+ # record to the internet. This is the dev posture.
23
+ # disabled-public no auth, and serve the whole record to anyone who can reach
24
+ # the port. A deliberate choice, correct for a genuinely
25
+ # public record or behind your own gateway. NOT a way to make
26
+ # a deploy go green.
27
+ #
28
+ # A container sets $PORT, so the door binds 0.0.0.0 — that is a public bind, and
29
+ # `disabled-local` will refuse there. That refusal is the point.
30
+ #
31
+ # For a real deployment, delete this line and configure the SSO door instead:
20
32
  # KSOR_SSO_URL=https://your-sso.example.com
21
33
  # KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
22
34
  # KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
@@ -27,15 +39,19 @@ GEMINI_API_KEY=
27
39
  # answered and where the keys came from.
28
40
  # Set this only to override discovery, or when your SSO publishes no metadata:
29
41
  # KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
30
- # Serving a public bind with auth off additionally requires
31
- # KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1, which serves your whole record to anyone
32
- # who can reach the port.
33
- KSOR_AUTH_DISABLED=1
42
+ KSOR_AUTH=disabled-local
34
43
 
35
44
  # ── Production knobs ────────────────────────────────────────────────────────
36
45
  # Unset is fine for a local run; each one matters once this serves for real.
37
46
 
38
47
  # Snapshot-token signing keys, "kid=secret[,kid2=secret2]" (first is active).
48
+ # SET THIS ON ANY CONTAINER HOST. Unset mints an EPHEMERAL per-process key, so a
49
+ # generation pin issued by one instance is unverifiable by the next: `read`
50
+ # silently drops to the active generation and reports "refreshed (invalid)".
51
+ # It fails SOFT, so nothing errors and nothing logs — the only symptom is an
52
+ # agent reading a generation it did not search. Generate one with
53
+ # `openssl rand -hex 32`; the secret is used as literal text, never hex-decoded,
54
+ # and must be IDENTICAL across every replica.
39
55
  # WITHOUT this the key is generated per PROCESS. Two deployments need it, not
40
56
  # just one: any MULTI-REPLICA deploy (a token minted by one replica is refused
41
57
  # by another), and any SCALE-TO-ZERO host, where a single replica mints a new
@@ -0,0 +1,25 @@
1
+ # A recall deck for what-is-a-ksor.md.
2
+ #
3
+ # Every card states only what its parent document states — a deck is a way of
4
+ # rehearsing the record, never a second source. Ask your coding agent to write
5
+ # one of these from a document, and to check each answer against it.
6
+ deck:
7
+ title: What a KSoR is
8
+ description: Recall checks for the record's own definition of itself.
9
+ cards:
10
+ - front: When a spreadsheet disagrees with the ledger, which one wins?
11
+ back: The ledger. A system of record is the copy that governs.
12
+ why: Which copy would your organization's agents trust today?
13
+
14
+ - front: A traditional system of record settles the state of a business. What does a KSoR settle?
15
+ back: What the organization knows and how it should operate — which policies apply, which thresholds are approved, what a term means here.
16
+
17
+ - front: What problem does a KSoR solve?
18
+ back: Scatter. Knowledge spread across wikis, decks, PDFs, prompts and someone's memory, with no authoritative answer to which knowledge an agent should trust.
19
+
20
+ - front: Why can an assistant not tell you which of its sentences were checked?
21
+ back: Because it answers from everything it has ever read. Nothing in that process distinguishes a checked claim from an unchecked one.
22
+ why: This is the gap the record exists to close.
23
+
24
+ - front: Does provenance prove that a document is correct?
25
+ back: No. Provenance proves who said what, and when. Whether the source is any good is a separate matter, and the record never claims otherwise.
@@ -0,0 +1,15 @@
1
+ A Knowledge System of Record settles **which copy governs**. When a wiki page, a
2
+ slide or a model's memory disagrees with it, this record wins.
3
+
4
+ - A traditional system of record settles the _state_ of a business — the ledger
5
+ for transactions, the HRIS for employee records. A KSoR settles what the
6
+ organization _knows_ and how it should operate.
7
+ - The problem it solves is scatter: knowledge spread across wikis, decks, PDFs,
8
+ prompts and memory, with no authoritative answer to the question an agent has
9
+ to ask — which knowledge should I trust?
10
+ - An assistant cannot answer that question, because it answers from everything
11
+ it has ever read and cannot tell you which of its sentences were checked.
12
+ - Every answer here traces to a document that names who stands behind it and
13
+ when it took effect.
14
+ - It does **not** settle whether that document is right. Provenance proves who
15
+ said what and when; whether the source is any good is a separate matter.
@@ -15,6 +15,11 @@ import {
15
15
  import { predecessorsOf, readGovernance, resolveSuccessorUrl } from "@/lib/governance";
16
16
  import { showGovernance } from "@/lib/shared";
17
17
  import { RecordToc, TocItems } from "@/components/record-toc";
18
+ import { RecordViews } from "@/components/record-views";
19
+ import { Flashcards } from "@/components/flashcards";
20
+ import { StudyAids } from "@/components/study-aids";
21
+ import { deckFor, summaryFor } from "@/lib/attachments";
22
+ import { readingMinutes } from "@/lib/reading-time";
18
23
 
19
24
  export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
20
25
  const params = await props.params;
@@ -22,6 +27,18 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
22
27
  if (!page) notFound();
23
28
 
24
29
  const MDX = page.data.body;
30
+ // Attachments of THIS document, found by suffix on its own path. Null is the
31
+ // ordinary case, not an error.
32
+ const summary = summaryFor(page.path);
33
+ const Summary = summary?.body ?? null;
34
+ const deck = deckFor(page.path);
35
+ // Counted at BUILD time from the document's own markdown, so the figure is in
36
+ // the shipped HTML for a reader with a failed bundle, a crawler and an agent
37
+ // alike. The predecessor measured the rendered DOM after paint, which put it
38
+ // out of reach of all three.
39
+ const minutes = readingMinutes(await page.data.getText("processed"));
40
+ const summaryMinutes =
41
+ summary === null ? null : readingMinutes(await summary.getText("processed"));
25
42
  // What the record says about this document. The page renders it; it never
26
43
  // supplies it — an undeclared key shows nothing (specs/ksor/site-governance).
27
44
  const governance = readGovernance(page.data, page.path);
@@ -112,14 +129,35 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
112
129
  describes (measured, 2026-08-21). Short documents now end where their
113
130
  text ends. */}
114
131
  <DocsBody style={{ flexGrow: 0 }}>
115
- <MDX
116
- components={getMDXComponents({
117
- // relative links between documents in knowledge/ resolve to
118
- // their rendered pages
119
- a: createRelativeLink(source, page),
120
- })}
121
- />
132
+ {/* The summary panel is built HERE, on the server, and handed to the
133
+ client tab strip as a prop — so it is in the shipped HTML whether or
134
+ not the bundle runs, which is what an agent parsing the page and a
135
+ reader with a failed bundle both depend on. Presence-driven: with no
136
+ summary, RecordViews renders the body alone and no tab strip exists
137
+ (specs/ksor/study-attachments C3, C20). */}
138
+ <RecordViews
139
+ documentMinutes={minutes}
140
+ summaryMinutes={summaryMinutes ?? undefined}
141
+ summary={
142
+ Summary === null ? null : (
143
+ <Summary components={getMDXComponents({ a: createRelativeLink(source, page) })} />
144
+ )
145
+ }
146
+ >
147
+ <MDX
148
+ components={getMDXComponents({
149
+ // relative links between documents in knowledge/ resolve to
150
+ // their rendered pages
151
+ a: createRelativeLink(source, page),
152
+ })}
153
+ />
154
+ </RecordViews>
122
155
  </DocsBody>
156
+ {/* What a reader DOES with this document once they have read it. One
157
+ region, so the quiz that will sit beside the deck is a child here and
158
+ not a new argument about where it goes. Renders nothing at all when
159
+ the document has no study aids. */}
160
+ <StudyAids>{deck === null ? null : <Flashcards deck={deck} />}</StudyAids>
123
161
  {/* A folder's index page lists what the folder holds. Without it the
124
162
  page ended at its own sentence and the documents below it were
125
163
  reachable only from the sidebar (research/site-design.md F5). Empty
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { Check, ChevronDown, Copy, Download, FileText } from "lucide-react";
4
+ import { useCallback, useEffect, useRef, useState, type ReactElement } from "react";
5
+
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuTrigger,
12
+ } from "@/components/ui/dropdown-menu";
13
+
14
+ /**
15
+ * What a reader can DO with this document, behind one control.
16
+ *
17
+ * Opening the markdown twin and handing it to an agent are different acts, and
18
+ * a reader who wants the second should not have to perform the first — but two
19
+ * bare controls sitting on a row of read-only facts made the row look half
20
+ * clickable. One trigger says "there are actions here" once, and the menu says
21
+ * what they are.
22
+ *
23
+ * Putting the markdown LINK behind a click is safe for the audience that reads
24
+ * bytes: `generateMetadata` already advertises the twin as
25
+ * `<link rel="alternate" type="text/markdown">` in the head, so a crawler and
26
+ * an agent find the address without opening anything. Verified in the built
27
+ * HTML before this moved.
28
+ */
29
+ export function DocumentActions({ href }: { readonly href: string }): ReactElement {
30
+ const [failed, setFailed] = useState(false);
31
+ const [copied, setCopied] = useState(false);
32
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
33
+
34
+ // The shell ships `useCopyButton`, which owns this timing — but it hands back
35
+ // a MouseEventHandler, and a menu item reports selection as a CustomEvent.
36
+ // Casting one to the other to reuse six lines is worse than the six lines.
37
+ useEffect(
38
+ () => () => {
39
+ if (timer.current !== null) clearTimeout(timer.current);
40
+ },
41
+ [],
42
+ );
43
+
44
+ const onCopy = useCallback(async () => {
45
+ try {
46
+ const markdown = await fetch(href).then((response) => response.text());
47
+ await navigator.clipboard.writeText(markdown);
48
+ setFailed(false);
49
+ setCopied(true);
50
+ if (timer.current !== null) clearTimeout(timer.current);
51
+ timer.current = setTimeout(() => setCopied(false), 1600);
52
+ } catch {
53
+ // A refused clipboard or a failed fetch is not an error where a document
54
+ // should be: the menu says so, and View markdown still works.
55
+ setFailed(true);
56
+ }
57
+ }, [href]);
58
+
59
+ return (
60
+ <DropdownMenu>
61
+ <DropdownMenuTrigger asChild>
62
+ <Button
63
+ variant="ghost"
64
+ size="sm"
65
+ className="h-auto gap-1.5 px-2 py-1 font-mono text-[0.6875rem] tracking-[0.14em] text-fd-muted-foreground uppercase hover:text-fd-foreground"
66
+ >
67
+ {/* "Export", not "Markdown": the word appeared three times in one
68
+ small control, and naming the FORMAT locks the trigger to it — a
69
+ later Download or Cite would make it wrong. Not "Source", which
70
+ collides with the provenance block below. Not "Share", which in a
71
+ record where `visibility:` decides who may read a document is the
72
+ wrong verb entirely. */}
73
+ <Download aria-hidden className="size-3.5" />
74
+ Export
75
+ <ChevronDown aria-hidden className="size-3 opacity-60" />
76
+ </Button>
77
+ </DropdownMenuTrigger>
78
+
79
+ <DropdownMenuContent align="start" className="w-52">
80
+ <DropdownMenuItem asChild>
81
+ <a href={href}>
82
+ <FileText aria-hidden className="size-4" />
83
+ View markdown
84
+ </a>
85
+ </DropdownMenuItem>
86
+
87
+ {/* onSelect is prevented so the menu stays open long enough to show
88
+ that the copy landed — closing on the click would take the only
89
+ feedback away with it. */}
90
+ <DropdownMenuItem
91
+ onSelect={(event) => {
92
+ event.preventDefault();
93
+ void onCopy();
94
+ }}
95
+ >
96
+ {copied ? (
97
+ <Check aria-hidden className="size-4 text-fd-primary" />
98
+ ) : (
99
+ <Copy aria-hidden className="size-4" />
100
+ )}
101
+ {failed ? "Copy failed — open it instead" : copied ? "Copied" : "Copy markdown"}
102
+ </DropdownMenuItem>
103
+ </DropdownMenuContent>
104
+ </DropdownMenu>
105
+ );
106
+ }