@panaversity/ksor 0.0.38 → 0.0.40

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 CHANGED
@@ -1,5 +1,111 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.40
4
+
5
+ ### Patch Changes
6
+
7
+ - cd42f81: Three readability changes to a document's page.
8
+
9
+ A long line in a code block can be **unwrapped by the reader**, with a button
10
+ that appears only on a block that actually overflows. The record's commands run
11
+ to hundreds of characters, and in a docs column that was a scrollbar with the
12
+ left edge of every line going out of view. Wrapping is not the default, because
13
+ it is worse for the blocks that do not need it.
14
+
15
+ A table's rows **alternate**, faintly, so a row holds together across three
16
+ columns of wrapped prose.
17
+
18
+ A callout carries a **rule down its left edge**, in its kind's colour — the
19
+ same device a verbatim block uses, so "this passage is set apart" looks the
20
+ same everywhere it happens.
21
+
22
+ - e19f226: A passage a reader must not miss can now be a callout. Write GitHub's alert
23
+ syntax — a blockquote whose first line is `[!WARNING]` — and the site renders a
24
+ styled panel tinted with that kind's own colour:
25
+
26
+ ```markdown
27
+ > [!TIP]
28
+ > Climb one rung at a time, and only when something has gone wrong without it.
29
+ ```
30
+
31
+ It stays a plain blockquote everywhere else the record is read, carrying a
32
+ visible label, and `/md/` and `llms-full.txt` keep the author's blockquote
33
+ rather than this site's component.
34
+
35
+ Not `:::warning`: a dialect renders as literal punctuation everywhere outside
36
+ this site.
37
+
38
+ - 704e949: A document can now show something running, where the prose puts it. Give a link
39
+ the title `embed` and the site renders it as a click-to-load frame:
40
+
41
+ ```markdown
42
+ [Play run-until-done](goal-loop.sim.html "embed")
43
+ ```
44
+
45
+ It stays an ordinary CommonMark link everywhere else — GitHub, a plain editor,
46
+ `/md/`, `llms-full.txt` — so nothing was added to `knowledge/`.
47
+
48
+ Prefer carrying the page in. A `<name>.sim.html` beside its document, exactly
49
+ like a figure, is published by the build and served from your own site: it
50
+ works offline, tells nobody outside what someone is reading, and is versioned
51
+ with the document. An `https:` link works too, but many hosts send
52
+ `X-Frame-Options: SAMEORIGIN` and a browser will refuse to frame them.
53
+
54
+ Nothing is requested until a reader clicks, so a built page still makes zero
55
+ external requests. A carried page is measured, so the frame is exactly as tall
56
+ as what it holds — you never write a height into a document.
57
+
58
+ - a55e93e: A numbered list now counts in the record's accent, and the term a list item
59
+ defines takes it too — `**Heartbeat:** a schedule that…` puts the term in
60
+ accent and leaves the sentence in ink. The markers were grey at regular weight,
61
+ lighter than the text they counted, so a list of six steps read as six
62
+ paragraphs that happened to start with digits.
63
+ - a59a7af: The documented way to run `ksor init` pins a version, so a stale runner cache
64
+ can no longer decide which ksor an adopter meets.
65
+
66
+ `npx @panaversity/ksor init my-sor` is spec `*`, and any cached version
67
+ satisfies it — so npx runs whatever that machine already has without consulting
68
+ the registry. Found live on a Windows box following the README: it replayed
69
+ `0.0.0`, the name-reservation stub published on the first day of the project,
70
+ whose whole implementation prints "the name is reserved; this is not a release"
71
+ and exits 2. Thirty-nine releases later, the first command in the README
72
+ produced a placeholder, and nothing in that output points at the cause.
73
+
74
+ Both READMEs now say `@panaversity/ksor@latest`. The three "Start here" forms
75
+ change together — `pnpm dlx` reuses its cache for 24 hours by default and
76
+ `bunx` resolves from the install cache before the registry, so pinning only npx
77
+ would have left two of the three supported managers in the trap. `npm install
78
+ -g` is unchanged: an install resolves the `latest` dist-tag by definition.
79
+
80
+ If you have run ksor before, your own cache is still warm. Run the `@latest`
81
+ form once and it resolves the current release.
82
+
83
+ - 9e92e9e: A document with no summary no longer carries an empty view strip. The reading
84
+ time moved into the governance row, beside the owner and the effective date,
85
+ so the commonest document — one with no summary — gets one bar of facts rather
86
+ than a second bar holding a single number at the far end of a rule.
87
+ - d723487: A table's head now reads as a head: the record's accent as a wash behind it,
88
+ the column labels in that accent's ink, and an accent rule under the band. They
89
+ were grey on near-white, so on a three-column table the eye had to work out
90
+ which row was the label.
91
+ - 8ec1a10: A fenced block with no language is set as a passage to reproduce rather than as
92
+ code: prose leading, and the record's accent down its left edge. Prompts,
93
+ commands to say and messages to paste were arriving as walls of tight
94
+ monospace, set for scanning columns of code when what is in them is sentences.
95
+
96
+ Nothing to author — the highlighter's own output is the signal, so a block that
97
+ was never highlighted is the one that changes.
98
+
99
+ ## 0.0.39
100
+
101
+ ### Patch Changes
102
+
103
+ - b6a3853: A malformed `KSOR_SNAPSHOT_KEYS` entry no longer has its text echoed into the
104
+ refusal message. The likeliest operator mistake — pasting a bare secret without
105
+ its `kid=` prefix — put that secret verbatim into an error that lands in
106
+ whatever collects logs. The refusal now names the entry's position and length
107
+ only, and keeps the remedy line.
108
+
3
109
  ## 0.0.38
4
110
 
5
111
  ### Patch Changes
package/README.md CHANGED
@@ -10,7 +10,7 @@ when the corpus does not cover the question.
10
10
  ## Start
11
11
 
12
12
  ```bash
13
- npx @panaversity/ksor init my-sor
13
+ npx @panaversity/ksor@latest init my-sor
14
14
  cd my-sor
15
15
  pnpm install
16
16
  pnpm dev # the site, live at http://localhost:3000
@@ -32,6 +32,22 @@ scaffold is deterministic and offline, and every refusal explains itself.
32
32
  > and the released version number are authoritative for the exact released
33
33
  > functionality.
34
34
 
35
+ ### What a document can do
36
+
37
+ `knowledge/` is CommonMark, and stays CommonMark — every affordance below is
38
+ syntax that other readers already handle, so a record renders honestly on
39
+ GitHub, in a plain editor, at `/md/` and in `llms-full.txt`.
40
+
41
+ | Write | The site renders |
42
+ | ----- | ---------------- |
43
+ | `> [!WARNING]` as a blockquote's first line | a callout, tinted in that kind's colour |
44
+ | ` ```bash tab="Claude Code" ` on consecutive fences | one tab group, the choice remembered |
45
+ | a fence with no language | a passage to reproduce, set for reading |
46
+
47
+ Nothing to author for the rest: a table's head reads as a head, a numbered list
48
+ counts in the record's accent, and a code block wider than the column gets a
49
+ button that unwraps it.
50
+
35
51
  ### Study attachments
36
52
 
37
53
  A document may carry companions named after it, and the site renders each on
@@ -72,7 +88,7 @@ Full concept, design goals, and project status:
72
88
 
73
89
  ```bash
74
90
  npm install -g @panaversity/ksor # command installed: ksor
75
- npx @panaversity/ksor # or run without installing
91
+ npx @panaversity/ksor@latest # or run without installing
76
92
  ```
77
93
 
78
94
  ## License
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { n as resolveCommand, r as verbs, t as exitCodes } from "./src-pl4aOpVs.mjs";
3
- import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-6nC9x54K-BWFTI_6U.mjs";
3
+ import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-CmIthmJS-IUA9qS-T.mjs";
4
4
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { InMemoryTransport, LATEST_PROTOCOL_VERSION, createMcpHandler } from "@modelcontextprotocol/server";
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
16
16
  import { execFileSync, spawnSync } from "node:child_process";
17
17
  import { parseArgs } from "node:util";
18
18
  import { readFile, readdir, stat } from "node:fs/promises";
19
- //#region ../content-gateway/dist/main-DdIuyneg.mjs
19
+ //#region ../content-gateway/dist/main-Cc5BU8p-.mjs
20
20
  /**
21
21
  * The default registration — and the ORIGINAL of the file `ksor init` emits.
22
22
  *
@@ -1583,7 +1583,7 @@ async function withPgRetry(op, options = {}) {
1583
1583
  throw lastError;
1584
1584
  }
1585
1585
  //#endregion
1586
- //#region ../content/dist/commands-mZxv9bLG.mjs
1586
+ //#region ../content/dist/commands-NwtLt0OE.mjs
1587
1587
  /**
1588
1588
  * EVAL-LOCKED constants, quarried verbatim from the oracle
1589
1589
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -5,7 +5,7 @@ import { z, z as z$1 } from "zod";
5
5
  import pg from "pg";
6
6
  import path from "node:path";
7
7
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
8
- //#region ../content-gateway/dist/gateway-api-6nC9x54K.mjs
8
+ //#region ../content-gateway/dist/gateway-api-CmIthmJS.mjs
9
9
  /**
10
10
  * The record's system prompt: instance.md's body, preserved beneath a framework
11
11
  * floor. Lifted out of server.ts so the public gateway surface can re-export
@@ -1352,9 +1352,9 @@ function keyRingFromEnv(raw) {
1352
1352
  }
1353
1353
  const keys = /* @__PURE__ */ new Map();
1354
1354
  let active = null;
1355
- for (const part of raw.split(",")) {
1355
+ for (const [i, part] of raw.split(",").entries()) {
1356
1356
  const eq = part.indexOf("=");
1357
- if (eq <= 0 || eq === part.length - 1) throw new Error(`KSOR_SNAPSHOT_KEYS entry ${JSON.stringify(part)} is not kid=secret — a half-parsed key ring would mint tokens nothing can validate; write comma-separated kid=secret pairs, first one active`);
1357
+ if (eq <= 0 || eq === part.length - 1) throw new Error(`KSOR_SNAPSHOT_KEYS entry ${i + 1} (${part.length} chars) is not kid=secret — a half-parsed key ring would mint tokens nothing can validate; write comma-separated kid=secret pairs, first one active`);
1358
1358
  const kid = part.slice(0, eq).trim();
1359
1359
  keys.set(kid, Buffer.from(part.slice(eq + 1), "utf8"));
1360
1360
  active = active ?? kid;
package/dist/gateway.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-6nC9x54K-BWFTI_6U.mjs";
1
+ import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-CmIthmJS-IUA9qS-T.mjs";
2
2
  export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
5
5
  "keywords": [
6
6
  "abstention",
@@ -71,8 +71,8 @@
71
71
  "tsdown": "0.22.14",
72
72
  "typescript": "7.0.2",
73
73
  "vitest": "^4.1.10",
74
- "@panaversity/ksor-content-gateway": "0.0.0",
75
- "@panaversity/ksor-content": "0.0.0"
74
+ "@panaversity/ksor-content": "0.0.0",
75
+ "@panaversity/ksor-content-gateway": "0.0.0"
76
76
  },
77
77
  "engines": {
78
78
  "node": ">=24"
@@ -449,12 +449,14 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
449
449
  publishes that as fact) and `superseded` (a legacy marker — prefer `status`)
450
450
  are available. No other keys; never
451
451
  `id:` or `name:` — the path is the identity.
452
- - **Each page says how long it takes to read**, just above the body, counted from the
453
- document's own words when the site is built. Fenced code and frontmatter do
454
- not count toward it, so a short page carrying a long example is not reported
455
- as a long read. Nothing to author — it is derived. A document with a summary
456
- shows the figure on both tabs, so a reader can see at a glance how much the
457
- summary saves them.
452
+ - **Each page says how long it takes to read**, counted from the document's own
453
+ words when the site is built. Fenced code and frontmatter do not count toward
454
+ it, so a short page carrying a long example is not reported as a long read.
455
+ Nothing to author — it is derived. It sits with the owner and the effective
456
+ date, in the row under the title; a document with a SUMMARY moves it onto
457
+ that view's own strip instead, where it reports the view you are reading
458
+ rather than the document. Each tab names its own figure to a screen reader,
459
+ so the cost of each view is available without switching to it.
458
460
  - **The governance keys are rendered, so they are worth filling in.** Each
459
461
  page shows its owner and effective date under the title, lists every
460
462
  `provenance` entry separately at the foot, and — for a superseded document —
@@ -664,6 +666,57 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
664
666
  renders in the site's accent. The list lives in `system/site/app/global.css`
665
667
  and is yours — adding a tool is one colour and one mark.
666
668
 
669
+ - **A passage the reader must not miss, as a CALLOUT.** GitHub's alert syntax —
670
+ a blockquote whose first line is the kind in brackets:
671
+
672
+ ```markdown
673
+ > [!WARNING]
674
+ > This threshold changed in March. The figure below is the old one.
675
+ ```
676
+
677
+ Five kinds: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. The site tints
678
+ the panel in that kind's colour and rules its left edge; every other reader
679
+ of the record — GitHub, a plain editor, `/md/`, `llms-full.txt` — shows an
680
+ ordinary blockquote carrying a visible label, so nothing is lost and nothing
681
+ becomes punctuation.
682
+
683
+ **Not `:::warning`.** That form is a dialect: a record written in one renders
684
+ as literal colons in every reader except the one site that understands it.
685
+
686
+ - **A long line is the reader's to unwrap.** Nothing to author — a fenced block
687
+ wider than the column gets a button beside its copy button that wraps it, and
688
+ a block that fits gets no button at all.
689
+ - **Something running, as an embed.** A document that wants to show a page in
690
+ motion — a simulation, a player, a dashboard — links to it and gives the link
691
+ the title `embed`:
692
+
693
+ ```markdown
694
+ [Play run-until-done](goal-loop.sim.html "embed")
695
+ ```
696
+
697
+ Still CommonMark: a link title is a tooltip everywhere else, so GitHub, a
698
+ plain editor, `/md/` and `llms-full.txt` all show the author's link. Nothing
699
+ loads until a reader clicks, which is what keeps a built page free of
700
+ external requests — and the panel names what it is about to reach, so the
701
+ click is informed.
702
+
703
+ **You do not state a height.** A page carried in the record is measured, so
704
+ the frame is exactly as tall as what it holds — on this record's own seven,
705
+ to the pixel. A number written into a document would be a number some other
706
+ measure makes wrong.
707
+
708
+ **Carry the page in where you can.** A file named `<name>.sim.html`, sitting
709
+ beside its document exactly like a figure, is published by the build and
710
+ served from this site — so it works offline, tells nobody outside what
711
+ someone is reading, and is versioned with the document instead of changing
712
+ under it. It is an ASSET, not an attachment: named freely, as many per
713
+ document as the prose needs.
714
+
715
+ An `https:` link works too, for a page you cannot carry. It is the weaker
716
+ option for a reason worth knowing: many hosts send `X-Frame-Options:
717
+ SAMEORIGIN`, which forbids any other site from framing them, and a browser
718
+ enforces that whatever this record does. Check before you rely on one.
719
+
667
720
  - Copy load-bearing values (numbers, thresholds, dates) exactly from their
668
721
  source, and name the source in `provenance`.
669
722
 
@@ -11,6 +11,9 @@ system/site/.staged-knowledge/
11
11
  # and the lock that keeps one evaluation of a build staging at a time; it only
12
12
  # outlives a build that was killed mid-stage, and the next build clears it
13
13
  system/site/.staged-knowledge.lock
14
+ # a build's copy of every `.sim.html` in the record, put where it can be
15
+ # SERVED; the record owns the sim, this is only where the site publishes it
16
+ system/site/public/sims/
14
17
  *.tsbuildinfo
15
18
 
16
19
  # secrets never enter the record — system/ is their future home (serve)
@@ -18,6 +18,11 @@ record of such documents is already publishable, searchable and citable.
18
18
 
19
19
  Each rung is worth climbing when the domain asks for it, and not before.
20
20
 
21
+ > [!TIP]
22
+ > Climb one rung at a time, and only when something has gone wrong without it.
23
+ > A record that adds owners before anyone disputes a document spends review
24
+ > effort buying nothing.
25
+
21
26
  ### Owners and sources
22
27
 
23
28
  An owner names who stands behind a document. Provenance names where its claims
@@ -22,6 +22,12 @@ be checked against the document that carries it rather than taken on trust.
22
22
  "Not in this record" is a correct answer. It is never an error, and never a
23
23
  licence to fall back on what a model happens to remember.
24
24
 
25
+ > [!WARNING]
26
+ >
27
+ > An agent that fills a gap from its own memory has not used this record — it
28
+ > has used it as an opening paragraph. Nothing in the answer says which half
29
+ > came from where.
30
+
25
31
  ## Files beside the interface
26
32
 
27
33
  The build publishes the same knowledge as plain files an agent can fetch without
@@ -130,7 +130,15 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
130
130
  <DocsTitle>{page.data.title}</DocsTitle>
131
131
  <DocsDescription>{page.data.description}</DocsDescription>
132
132
  {showGovernance ? (
133
- <GovernanceMeta governance={governance} replaces={replaces} markdownUrl={markdownUrl} />
133
+ <GovernanceMeta
134
+ governance={governance}
135
+ replaces={replaces}
136
+ markdownUrl={markdownUrl}
137
+ // Only when there is no view strip to carry it — with a summary,
138
+ // the number belongs to the view the reader picked, not to the
139
+ // document as a whole.
140
+ minutes={summary === null ? minutes : undefined}
141
+ />
134
142
  ) : null}
135
143
  {/* grow-0, against the shell's own `flex-1`: the article is a flex column
136
144
  stretched to the viewport, so the body inflated from ~150px of text to
@@ -678,13 +678,53 @@ html > body[data-scroll-locked] {
678
678
  font-weight: 500;
679
679
  letter-spacing: 0.14em;
680
680
  text-transform: uppercase;
681
- color: var(--color-fd-muted-foreground);
681
+ /* The record's own accent, at reading strength. The labels were grey on
682
+ near-white and the head did not read as a head at all — on a three-column
683
+ table the eye had to work out which row was the label. */
684
+ color: color-mix(in oklab, var(--color-fd-primary) 72%, var(--color-fd-foreground));
685
+ background: color-mix(in oklab, var(--color-fd-primary) 7%, var(--color-fd-muted));
686
+ }
687
+
688
+ /* The head is a band, so it is ruled off from the body rather than floating
689
+ above it — and the rule is the accent's, not the hairline's, so the two read
690
+ as one object. */
691
+ #nd-page .prose thead {
692
+ border-bottom: 2px solid color-mix(in oklab, var(--color-fd-primary) 28%, var(--color-fd-border));
693
+ }
694
+
695
+ .dark #nd-page .prose thead th {
696
+ /* Against near-black the same 7% is almost nothing, and the same 72% ink is
697
+ too bright to sit under body text. */
698
+ color: color-mix(in oklab, var(--color-fd-primary) 60%, var(--color-fd-foreground));
699
+ background: color-mix(in oklab, var(--color-fd-primary) 12%, var(--color-fd-muted));
682
700
  }
683
701
 
684
702
  #nd-page .prose tbody tr {
685
703
  border-bottom: 1px solid var(--color-fd-border);
686
704
  }
687
705
 
706
+ /* And every other row carries a wash. A hairline alone is enough on a
707
+ two-column table; across three columns of wrapped prose the eye loses which
708
+ cells belong together, because the gap between rows and the gap between
709
+ lines INSIDE a cell are the same distance. The band is what holds a row
710
+ together over that distance.
711
+
712
+ Faint on purpose: it groups, it does not label. Anything stronger competes
713
+ with the head, which is the row that is meant to be read first. */
714
+ #nd-page .prose tbody tr:nth-child(even) {
715
+ background: var(--color-fd-background);
716
+ }
717
+
718
+ #nd-page .prose tbody tr:nth-child(odd) {
719
+ background: color-mix(in oklab, var(--color-fd-border) 14%, var(--color-fd-muted));
720
+ }
721
+
722
+ .dark #nd-page .prose tbody tr:nth-child(odd) {
723
+ /* A step of the same SIZE reads much stronger against near-black, so the
724
+ dark pair takes less of the border to land at the same apparent distance. */
725
+ background: color-mix(in oklab, var(--color-fd-border) 8%, var(--color-fd-muted));
726
+ }
727
+
688
728
  #nd-page .prose tbody tr:last-child {
689
729
  border-bottom: 0;
690
730
  }
@@ -731,6 +771,117 @@ html > body[data-scroll-locked] {
731
771
  background: var(--color-fd-muted);
732
772
  }
733
773
 
774
+ /* A block with no language is not code — it is a passage to reproduce
775
+ verbatim: a prompt to type, a command to say, a message to paste. This
776
+ record has eight, and they arrived as walls of tight monospace, set for
777
+ scanning columns of code when what is in them is sentences.
778
+
779
+ The highlighter's own output is the selector, so nothing has to be authored
780
+ and nothing changes for the record: shiki gives every token in a HIGHLIGHTED
781
+ block an inline `--shiki-light`, and a plain one has none. `:has()` asks
782
+ that question directly.
783
+
784
+ Prose leading, because the lines are sentences; and the accent down the left
785
+ edge, which is what the rest of this site uses to mark structure — here it
786
+ says "reproduce this exactly", which is the one thing these blocks have in
787
+ common. */
788
+ #nd-page .prose figure.shiki:not(:has(code span[style*="--shiki-light"])) {
789
+ border-left: 3px solid color-mix(in oklab, var(--color-fd-primary) 55%, var(--color-fd-border));
790
+ }
791
+
792
+ #nd-page .prose figure.shiki:not(:has(code span[style*="--shiki-light"])) code {
793
+ line-height: 1.7;
794
+ }
795
+
796
+ /* Showing the whole of a long line, when the reader asks for it.
797
+ components/code-block.tsx puts the button on the block and sets the
798
+ attribute; this is what the attribute means.
799
+
800
+ `pre` is `w-max` in the shell, which sizes it to the longest line — that is
801
+ what makes wrapping impossible, so the width has to come back to the column
802
+ before `pre-wrap` can do anything. A wrapped line is then indented under its
803
+ own, the way a terminal marks a continuation, because otherwise a two-line
804
+ command reads as two commands.
805
+
806
+ Wrapping is NOT the default. It was, for one commit, and it is worse for the
807
+ blocks that do not need it: a yaml file rewrapped at the column reads as
808
+ though its indentation means something else. */
809
+ #nd-page .prose figure.shiki[data-wrapped] pre {
810
+ width: 100%;
811
+ min-width: 0;
812
+ }
813
+
814
+ #nd-page .prose figure.shiki[data-wrapped] code > * {
815
+ white-space: pre-wrap;
816
+ overflow-wrap: anywhere;
817
+ /* The hang, plus the gutter below — so a continuation is indented from the
818
+ text, not from the block's edge. */
819
+ padding-left: calc(1rem + 2ch);
820
+ text-indent: -2ch;
821
+ }
822
+
823
+ /* A block needs a gutter on both sides. Unwrapped, the shell gives one on the
824
+ left with `ps-*` and lets a long line run under the buttons on the right,
825
+ because the line can always be scrolled clear of them. Wrapped, it cannot:
826
+ the first line ends under the copy button with no way to move it, and the
827
+ left edge sits on the rule (seen live). So the wrapped state pays for its
828
+ own margins — a gutter left, and enough right for two 24px buttons. */
829
+ #nd-page .prose figure.shiki[data-wrapped] code {
830
+ padding-inline: 0 4rem;
831
+ }
832
+
833
+ #nd-page .prose figure.shiki[data-wrapped] .fd-scroll-container {
834
+ padding-inline-start: 0;
835
+ }
836
+
837
+ /* And a hairline where a scrollbar appears, in place of the platform's slab. */
838
+ #nd-page .prose figure.shiki .fd-scroll-container {
839
+ scrollbar-width: thin;
840
+ scrollbar-color: var(--color-fd-border) transparent;
841
+ }
842
+ /* A callout has to look like a callout. The same `bg-fd-card` trap as the code
843
+ block above: fumadocs paints the panel with `--card`, which in this palette
844
+ is all but the page colour, so a Note and a Warning both arrived as a white
845
+ box with a coloured hair down one edge — the kind was legible only in the
846
+ icon.
847
+
848
+ Fumadocs already puts the kind's own colour on the element as
849
+ `--callout-color`, so ONE rule tints every kind: a wash of that colour over
850
+ `--muted`, the token that means "a surface on the page". No per-kind
851
+ selector, and a kind added upstream is tinted without touching this.
852
+
853
+ 6% in light and 10% in dark, because the same fraction reads as a different
854
+ amount of colour against near-white and against near-black. Kept low on
855
+ purpose: this is the record's own accent discipline — the tint says which
856
+ kind, it does not compete with the prose. */
857
+ #nd-page .prose [style*="--callout-color"] {
858
+ background: color-mix(in oklab, var(--callout-color) 6%, var(--color-fd-muted));
859
+ border-color: color-mix(in oklab, var(--callout-color) 20%, var(--color-fd-border));
860
+ /* The shadow was doing the work the colour now does, and lifted the panel
861
+ off the page in a way nothing else on it does. */
862
+ box-shadow: none;
863
+
864
+ /* THE RULE DOWN THE LEFT EDGE — the same device the verbatim block uses, so
865
+ "this passage is set apart" looks the same everywhere it happens, whatever
866
+ kind of thing is set apart. Fumadocs draws a half-pixel bar INSIDE the
867
+ panel instead, which is too faint to read as a marker at all.
868
+
869
+ In the kind's own colour, which for a Note — much the commonest — is the
870
+ accent, so the standard case is the blue line. */
871
+ border-left: 3px solid color-mix(in oklab, var(--callout-color) 65%, var(--color-fd-border));
872
+ }
873
+
874
+ /* And the inner bar goes, now that the edge carries it. Two rules for the same
875
+ job, a pixel apart, read as a mistake. */
876
+ #nd-page .prose [style*="--callout-color"] > div[class*="w-0.5"] {
877
+ display: none;
878
+ }
879
+
880
+ .dark #nd-page .prose [style*="--callout-color"] {
881
+ background: color-mix(in oklab, var(--callout-color) 10%, var(--color-fd-muted));
882
+ border-color: color-mix(in oklab, var(--callout-color) 24%, var(--color-fd-border));
883
+ }
884
+
734
885
  /* Re-assert the shell's own reset, which the ramp above broke. The prose
735
886
  plugin zeroes the first block's top margin, but writes that rule as
736
887
  `.prose :where(> :first-child)`, and `:where()` contributes nothing to
@@ -743,6 +894,39 @@ html > body[data-scroll-locked] {
743
894
  margin-top: 0;
744
895
  }
745
896
 
897
+ /* A numbered list is a sequence, and its numbers should say so. The marker
898
+ arrived grey at regular weight, lighter than the text it counts, so a list
899
+ of six steps read as six paragraphs that happened to start with digits.
900
+
901
+ The accent, and only here: a number is structure, which is what this
902
+ record's accent is for. */
903
+ #nd-page .prose ol > li::marker {
904
+ color: var(--color-fd-primary);
905
+ font-weight: 600;
906
+ }
907
+
908
+ /* And the TERM a list item defines. `**Heartbeat:** a schedule that…` is the
909
+ commonest shape in this record — a term, then what it means — and the term
910
+ is the thing a reader scans for.
911
+
912
+ Only the term. Bold elsewhere in the sentence stays ink, because a page
913
+ where every emphasis is coloured has no emphasis: the accent stops meaning
914
+ "this is the one to find" and starts meaning "this is bold". */
915
+ #nd-page .prose li > strong:first-child,
916
+ #nd-page .prose li > p:first-child > strong:first-child {
917
+ color: var(--color-fd-primary);
918
+ }
919
+
920
+ /* The same rule, one level in. The document's first block is not a child of
921
+ `.prose` — it is inside the views wrapper, and inside a tab panel when the
922
+ document has a summary — so the reset above never reached it and the top of
923
+ every document sat 20px lower than the rule above it intended (measured
924
+ after the empty view strip was removed, which is what exposed it). */
925
+ #nd-page .prose > .ksor-views > :first-child,
926
+ #nd-page .prose > .ksor-views [role="tabpanel"] > :first-child {
927
+ margin-top: 0;
928
+ }
929
+
746
930
  /* ── The page pager ──────────────────────────────────────────────────────────
747
931
  Fumadocs lays the neighbours out as a grid that becomes two columns when both
748
932
  exist. With only one — the first or last document of the record — it stays a
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock";
4
+ import { WrapText } from "lucide-react";
5
+ import { useCallback, useEffect, useRef, useState, type ReactElement } from "react";
6
+
7
+ /**
8
+ * A code block that can be asked to show the whole line.
9
+ *
10
+ * A record's commands are long — one in the fixture runs to 292 characters —
11
+ * and in a 672px column that is a scrollbar, with the left edge of every line
12
+ * going out of view as you drag it. Wrapping everything by default was tried
13
+ * and is worse for the blocks that do not need it: a yaml file rewrapped at
14
+ * the column reads as though its indentation means something else.
15
+ *
16
+ * So the reader decides, per block. The button appears ONLY on a block that
17
+ * actually overflows, which is measured after layout rather than guessed —
18
+ * most blocks fit, and a control that does nothing is worse than no control.
19
+ *
20
+ * The `Actions` slot is fumadocs' own, so the button sits with the copy button
21
+ * and inherits its placement rather than being positioned against it.
22
+ */
23
+ export function WrappableCodeBlock(props: React.ComponentProps<"pre">): ReactElement {
24
+ const figure = useRef<HTMLElement>(null);
25
+ const [overflows, setOverflows] = useState(false);
26
+ const [wrapped, setWrapped] = useState(false);
27
+
28
+ const measure = useCallback((): void => {
29
+ const viewport = figure.current?.querySelector<HTMLElement>(".fd-scroll-container");
30
+ if (!viewport) return;
31
+ // While wrapped there is nothing to overflow, so the question is only
32
+ // asked in the state that can answer it — otherwise turning wrapping on
33
+ // would remove the button that turns it off.
34
+ setOverflows((was) => (wrapped ? was : viewport.scrollWidth > viewport.clientWidth + 1));
35
+ }, [wrapped]);
36
+
37
+ useEffect(() => {
38
+ measure();
39
+ if (typeof ResizeObserver === "undefined") return;
40
+ const viewport = figure.current?.querySelector<HTMLElement>(".fd-scroll-container");
41
+ if (!viewport) return;
42
+ // The column changes width with the window, and with the table of contents
43
+ // appearing; a block that fits at one width overflows at another.
44
+ const observer = new ResizeObserver(measure);
45
+ observer.observe(viewport);
46
+ return () => observer.disconnect();
47
+ }, [measure]);
48
+
49
+ return (
50
+ <CodeBlock
51
+ ref={figure}
52
+ data-wrapped={wrapped ? "" : undefined}
53
+ Actions={({ className, children }) => (
54
+ // `children` IS the copy button — fumadocs hands the default actions
55
+ // in, and a slot that ignores them REPLACES the copy button instead of
56
+ // joining it (found live: the block lost copy entirely). The wrapper
57
+ // mirrors the default's own `empty:hidden`, so a block with neither
58
+ // button still renders nothing.
59
+ <div className={`flex items-center empty:hidden ${className ?? ""}`}>
60
+ {children}
61
+ {overflows ? (
62
+ <button
63
+ type="button"
64
+ aria-pressed={wrapped}
65
+ onClick={() => setWrapped((on) => !on)}
66
+ // The copy button's own metrics — measured, not guessed: 24x24
67
+ // with a 3.5 icon. Mine came out 30x30 and the pair read as two
68
+ // controls of different importance.
69
+ className="inline-flex size-6 items-center justify-center rounded-lg transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground [&_svg]:size-3.5"
70
+ // Said as what it does, not as what it is: the reader wants the
71
+ // rest of the line, not a setting.
72
+ title={wrapped ? "Show one line per line" : "Show the whole line"}
73
+ >
74
+ <WrapText aria-hidden />
75
+ <span className="sr-only">
76
+ {wrapped ? "Show one line per line" : "Show the whole line"}
77
+ </span>
78
+ </button>
79
+ ) : null}
80
+ </div>
81
+ )}
82
+ {...props}
83
+ >
84
+ <Pre>{props.children}</Pre>
85
+ </CodeBlock>
86
+ );
87
+ }