OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      c4de7e0973c7
DATE      2026-07-17
SUBJECT   web: wikilinks, docs collection, and a nav that wraps
FILES     21 CHANGED
HASH      c4de7e0973c7
DATE      2026-07-17
SUBJECT   web: wikilinks, docs collection, and a
          nav that wraps
FILES     21 CHANGED
 

diff --git a/ooknet-design/astro.config.ts b/ooknet-design/astro.config.ts
index 1eb8919..1d569a4 100644
--- a/ooknet-design/astro.config.ts
+++ b/ooknet-design/astro.config.ts
@@ -1,9 +1,11 @@
 import { defineConfig } from "astro/config";
 import { rehypeAscii } from "./src/lib/rehype-ascii";
+import { remarkWikilinks } from "./src/lib/remark-wikilinks";
 
 export default defineConfig({
   markdown: {
     syntaxHighlight: false,
+    remarkPlugins: [remarkWikilinks],
     rehypePlugins: [rehypeAscii],
   },
 });

diff --git a/ooknet-design/astro.config.ts b
/ooknet-design/astro.config.ts
index 1eb8919..1d569a4 100644
--- a/ooknet-design/astro.config.ts
+++ b/ooknet-design/astro.config.ts
@@ -1,9 +1,11 @@
 import { defineConfig } from "astro/config"
;
 import { rehypeAscii } from "./src/lib/rehy
pe-ascii";
+import { remarkWikilinks } from "./src/lib/
remark-wikilinks";
 
 export default defineConfig({
   markdown: {
     syntaxHighlight: false,
+    remarkPlugins: [remarkWikilinks],
     rehypePlugins: [rehypeAscii],
   },
 });
 

diff --git a/ooknet-design/src/components/NavBar/NavBar.astro b/ooknet-design/src/
components/NavBar/NavBar.astro
index 8382305..3b7828d 100644
--- a/ooknet-design/src/components/NavBar/NavBar.astro
+++ b/ooknet-design/src/components/NavBar/NavBar.astro
@@ -3,7 +3,7 @@ import Pre from "../Pre/Pre.astro";
 import Button from "../Button/Button.astro";
 import NavLanguage from "../NavLanguage/NavLanguage.astro";
 import { FRAME_W } from "../../lib/config";
-import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, isActive, itemsWidth, type Props } from 
"./NavBar";
+import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, isActive, itemsWidth, navRows, type Prop
s } from "./NavBar";
 
 const {
   width = FRAME_W,
@@ -12,9 +12,11 @@ const {
   lang = true,
 } = Astro.props as Props;
 const path = Astro.url.pathname;
-const langW = lang ? NAV_LANG_W : 0;
-const padW = Math.max(1, width - itemsWidth(items, gap) - langW);
-const padStr = " ".repeat(padW);
+const rows = navRows(items, width, gap, lang ? NAV_LANG_W + 1 : 0);
 const gapStr = " ".repeat(gap);
+const pad = (row: typeof items, first: boolean) =>
+  " ".repeat(Math.max(1, width - itemsWidth(row, gap) - (first && lang ? NAV_LANG
_W : 0)));
 ---
-<Pre class="nav-bar">{items.map((it, i) => (<Fragment>{i > 0 ? gapStr : ""}<Butto
n label={it.label} href={it.href} variant={isActive(it.href, path) ? "primary" : "
default"} current={isActive(it.href, path)} /></Fragment>))}{padStr}{lang && <NavL
anguage />}</Pre>
+{rows.map((row, ri) => (
+  <Pre class="nav-bar">{row.map((it, i) => (<Fragment>{i > 0 ? gapStr : ""}<Butto
n label={it.label} href={it.href} variant={isActive(it.href, path) ? "primary" : "
default"} current={isActive(it.href, path)} /></Fragment>))}{ri === 0 && lang ? pa
d(row, true) : ""}{ri === 0 && lang && <NavLanguage />}</Pre>
+))}

diff --git a/ooknet-design/src/components/Na
vBar/NavBar.astro b/ooknet-design/src/compon
ents/NavBar/NavBar.astro
index 8382305..3b7828d 100644
--- a/ooknet-design/src/components/NavBar/Na
vBar.astro
+++ b/ooknet-design/src/components/NavBar/Na
vBar.astro
@@ -3,7 +3,7 @@ import Pre from "../Pre/Pre.
astro";
 import Button from "../Button/Button.astro"
;
 import NavLanguage from "../NavLanguage/Nav
Language.astro";
 import { FRAME_W } from "../../lib/config";
-import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, is
Active, itemsWidth, type Props } from "./Nav
Bar";
+import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, is
Active, itemsWidth, navRows, type Props } fr
om "./NavBar";
 
 const {
   width = FRAME_W,
@@ -12,9 +12,11 @@ const {
   lang = true,
 } = Astro.props as Props;
 const path = Astro.url.pathname;
-const langW = lang ? NAV_LANG_W : 0;
-const padW = Math.max(1, width - itemsWidth
(items, gap) - langW);
-const padStr = " ".repeat(padW);
+const rows = navRows(items, width, gap, lan
g ? NAV_LANG_W + 1 : 0);
 const gapStr = " ".repeat(gap);
+const pad = (row: typeof items, first: bool
ean) =>
+  " ".repeat(Math.max(1, width - itemsWidth
(row, gap) - (first && lang ? NAV_LANG_W : 0
)));
 ---
-<Pre class="nav-bar">{items.map((it, i) => 
(<Fragment>{i > 0 ? gapStr : ""}<Button labe
l={it.label} href={it.href} variant={isActiv
e(it.href, path) ? "primary" : "default"} cu
rrent={isActive(it.href, path)} /></Fragment
>))}{padStr}{lang && <NavLanguage />}</Pre>
+{rows.map((row, ri) => (
+  <Pre class="nav-bar">{row.map((it, i) => 
(<Fragment>{i > 0 ? gapStr : ""}<Button labe
l={it.label} href={it.href} variant={isActiv
e(it.href, path) ? "primary" : "default"} cu
rrent={isActive(it.href, path)} /></Fragment
>))}{ri === 0 && lang ? pad(row, true) : ""}
{ri === 0 && lang && <NavLanguage />}</Pre>
+))}
 

diff --git a/ooknet-design/src/components/NavBar/NavBar.test.ts b/ooknet-design/sr
c/components/NavBar/NavBar.test.ts
new file mode 100644
index 0000000..6a9fbc9
--- /dev/null
+++ b/ooknet-design/src/components/NavBar/NavBar.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { NAV_ITEMS, NAV_LANG_W, itemsWidth, navRows } from "./NavBar";
+import { MOBILE_FRAME_W, FRAME_W } from "../../lib/config";
+
+describe("navRows", () => {
+  it("keeps all five items on one row at desktop width", () => {
+    const rows = navRows(NAV_ITEMS, FRAME_W, 4, NAV_LANG_W + 1);
+    expect(rows).toHaveLength(1);
+  });
+
+  it("wraps onto rows at the narrow width", () => {
+    const rows = navRows(NAV_ITEMS, MOBILE_FRAME_W, 1);
+    expect(rows.length).toBeGreaterThan(1);
+    for (const row of rows) {
+      expect(itemsWidth(row, 1)).toBeLessThanOrEqual(MOBILE_FRAME_W);
+    }
+  });
+
+  it("preserves item order across rows", () => {
+    const rows = navRows(NAV_ITEMS, MOBILE_FRAME_W, 1);
+    expect(rows.flat().map((it) => it.label)).toEqual(NAV_ITEMS.map((it) => it.la
bel));
+  });
+});

diff --git a/ooknet-design/src/components/Na
vBar/NavBar.test.ts b/ooknet-design/src/comp
onents/NavBar/NavBar.test.ts
new file mode 100644
index 0000000..6a9fbc9
--- /dev/null
+++ b/ooknet-design/src/components/NavBar/Na
vBar.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vites
t";
+import { NAV_ITEMS, NAV_LANG_W, itemsWidth,
 navRows } from "./NavBar";
+import { MOBILE_FRAME_W, FRAME_W } from "..
/../lib/config";
+
+describe("navRows", () => {
+  it("keeps all five items on one row at de
sktop width", () => {
+    const rows = navRows(NAV_ITEMS, FRAME_W
, 4, NAV_LANG_W + 1);
+    expect(rows).toHaveLength(1);
+  });
+
+  it("wraps onto rows at the narrow width",
 () => {
+    const rows = navRows(NAV_ITEMS, MOBILE_
FRAME_W, 1);
+    expect(rows.length).toBeGreaterThan(1);
+    for (const row of rows) {
+      expect(itemsWidth(row, 1)).toBeLessTh
anOrEqual(MOBILE_FRAME_W);
+    }
+  });
+
+  it("preserves item order across rows", ()
 => {
+    const rows = navRows(NAV_ITEMS, MOBILE_
FRAME_W, 1);
+    expect(rows.flat().map((it) => it.label
)).toEqual(NAV_ITEMS.map((it) => it.label));
+  });
+});
 

diff --git a/ooknet-design/src/components/NavBar/NavBar.ts b/ooknet-design/src/com
ponents/NavBar/NavBar.ts
index 98c9f7c..14adddd 100644
--- a/ooknet-design/src/components/NavBar/NavBar.ts
+++ b/ooknet-design/src/components/NavBar/NavBar.ts
@@ -19,6 +19,7 @@ export interface Props {
 
 export const NAV_ITEMS: NavItem[] = [
   { label: "NOTES",     href: "/" },
+  { label: "DOCS",      href: "/docs/" },
   { label: "PROJECTS",  href: "/projects/" },
   { label: "RESOURCES", href: "/resources/" },
   { label: "ABOUT",     href: "/about/" },
@@ -35,6 +36,26 @@ export function itemsWidth(items: NavItem[], gap = NAV_GAP): nu
mber {
   );
 }
 
+/** Greedy row wrap. The first row reserves `reserved` cells (the EN
+ *  label and a space) so nav grows downward instead of overflowing the
+ *  narrow frame. */
+export function navRows(items: NavItem[], width: number, gap = NAV_GAP, reserved 
= 0): NavItem[][] {
+  const rows: NavItem[][] = [[]];
+  let used = reserved;
+  for (const it of items) {
+    const row = rows[rows.length - 1];
+    const w = buttonWidth(it.label) + (row.length ? gap : 0);
+    if (row.length && used + w > width) {
+      rows.push([it]);
+      used = buttonWidth(it.label);
+    } else {
+      row.push(it);
+      used += w;
+    }
+  }
+  return rows;
+}
+
 /** Active match. Root `/` also claims any /notes/ subpath since the
  *  index is the notes list. */
 export function isActive(href: string, path: string): boolean {

diff --git a/ooknet-design/src/components/Na
vBar/NavBar.ts b/ooknet-design/src/component
s/NavBar/NavBar.ts
index 98c9f7c..14adddd 100644
--- a/ooknet-design/src/components/NavBar/Na
vBar.ts
+++ b/ooknet-design/src/components/NavBar/Na
vBar.ts
@@ -19,6 +19,7 @@ export interface Props {
 
 export const NAV_ITEMS: NavItem[] = [
   { label: "NOTES",     href: "/" },
+  { label: "DOCS",      href: "/docs/" },
   { label: "PROJECTS",  href: "/projects/" 
},
   { label: "RESOURCES", href: "/resources/"
 },
   { label: "ABOUT",     href: "/about/" },
@@ -35,6 +36,26 @@ export function itemsWidt
h(items: NavItem[], gap = NAV_GAP): number {
   );
 }
 
+/** Greedy row wrap. The first row reserves
 `reserved` cells (the EN
+ *  label and a space) so nav grows downwar
d instead of overflowing the
+ *  narrow frame. */
+export function navRows(items: NavItem[], w
idth: number, gap = NAV_GAP, reserved = 0): 
NavItem[][] {
+  const rows: NavItem[][] = [[]];
+  let used = reserved;
+  for (const it of items) {
+    const row = rows[rows.length - 1];
+    const w = buttonWidth(it.label) + (row.
length ? gap : 0);
+    if (row.length && used + w > width) {
+      rows.push([it]);
+      used = buttonWidth(it.label);
+    } else {
+      row.push(it);
+      used += w;
+    }
+  }
+  return rows;
+}
+
 /** Active match. Root `/` also claims any 
/notes/ subpath since the
  *  index is the notes list. */
 export function isActive(href: string, path
: string): boolean {
 

diff --git a/ooknet-design/src/components/Prose/Prose.scss b/ooknet-design/src/com
ponents/Prose/Prose.scss
index 7319592..a0179df 100644
--- a/ooknet-design/src/components/Prose/Prose.scss
+++ b/ooknet-design/src/components/Prose/Prose.scss
@@ -17,6 +17,14 @@
     text-decoration: underline;
     text-underline-offset: 3px;
   }
+
+  // Unresolved [[wikilinks]] - dead-link style, in the wiki tradition.
+  .wikilink-dead {
+    color: var(--ink-soft);
+    text-decoration: underline dashed;
+    text-underline-offset: 3px;
+    cursor: help;
+  }
   a:focus-visible {
     outline: 1px dashed currentColor;
     outline-offset: 2px;

diff --git a/ooknet-design/src/components/Pr
ose/Prose.scss b/ooknet-design/src/component
s/Prose/Prose.scss
index 7319592..a0179df 100644
--- a/ooknet-design/src/components/Prose/Pro
se.scss
+++ b/ooknet-design/src/components/Prose/Pro
se.scss
@@ -17,6 +17,14 @@
     text-decoration: underline;
     text-underline-offset: 3px;
   }
+
+  // Unresolved [[wikilinks]] - dead-link s
tyle, in the wiki tradition.
+  .wikilink-dead {
+    color: var(--ink-soft);
+    text-decoration: underline dashed;
+    text-underline-offset: 3px;
+    cursor: help;
+  }
   a:focus-visible {
     outline: 1px dashed currentColor;
     outline-offset: 2px;
 

diff --git a/ooknet-design/src/components/TopHeader/TopHeader.astro b/ooknet-desig
n/src/components/TopHeader/TopHeader.astro
index 5b5c877..ae32ca8 100644
--- a/ooknet-design/src/components/TopHeader/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader/TopHeader.astro
@@ -41,7 +41,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18);
 <script>
   import Fuse from "fuse.js";
 
-  type Note = { id: string; note: string; title: string; category: string; tags: 
string[]; filed: string };
+  type Note = { note: string; title: string; category: string; tags: string[]; fi
led: string; url: string };
   let fuse: Fuse<Note> | null = null;
 
   async function ensureLoaded() {
@@ -88,7 +88,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18);
       .slice(0, 8)
       .map((h, i) => {
         const safe = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;"
).replace(/>/g, "&gt;");
-        return `<li${i === 0 ? ' class="active"' : ""}><a href="/notes/${encodeUR
IComponent(h.item.id)}/">${safe(h.item.note)} - ${safe(h.item.title)}</a></li>`;
+        return `<li${i === 0 ? ' class="active"' : ""}><a href="${safe(h.item.url
)}">${safe(h.item.note)} - ${safe(h.item.title)}</a></li>`;
       })
       .join("");
   }

diff --git a/ooknet-design/src/components/To
pHeader/TopHeader.astro b/ooknet-design/src/
components/TopHeader/TopHeader.astro
index 5b5c877..ae32ca8 100644
--- a/ooknet-design/src/components/TopHeader
/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader
/TopHeader.astro
@@ -41,7 +41,7 @@ const PLACEHOLDER_NARROW =
 "_".repeat(18);
 <script>
   import Fuse from "fuse.js";
 
-  type Note = { id: string; note: string; t
itle: string; category: string; tags: string
[]; filed: string };
+  type Note = { note: string; title: string
; category: string; tags: string[]; filed: s
tring; url: string };
   let fuse: Fuse<Note> | null = null;
 
   async function ensureLoaded() {
@@ -88,7 +88,7 @@ const PLACEHOLDER_NARROW =
 "_".repeat(18);
       .slice(0, 8)
       .map((h, i) => {
         const safe = (s: string) => s.repla
ce(/&/g, "&amp;").replace(/</g, "&lt;").repl
ace(/>/g, "&gt;");
-        return `<li${i === 0 ? ' class="act
ive"' : ""}><a href="/notes/${encodeURICompo
nent(h.item.id)}/">${safe(h.item.note)} - ${
safe(h.item.title)}</a></li>`;
+        return `<li${i === 0 ? ' class="act
ive"' : ""}><a href="${safe(h.item.url)}">${
safe(h.item.note)} - ${safe(h.item.title)}</
a></li>`;
       })
       .join("");
   }
 

diff --git a/ooknet-design/src/content.config.ts b/ooknet-design/src/content.confi
g.ts
index ce664a6..2721063 100644
--- a/ooknet-design/src/content.config.ts
+++ b/ooknet-design/src/content.config.ts
@@ -30,4 +30,16 @@ const notes = defineCollection({
   }),
 });
 
-export const collections = { notes };
+// Docs: hierarchical wiki pages. Directory structure is the taxonomy;
+// `order` sorts pages within a directory.
+const docs = defineCollection({
+  loader: glob({ pattern: "**/*.md", base: "./src/content/docs" }),
+  schema: z.object({
+    title: z.string(),
+    description: z.string().optional(),
+    order: z.number().optional(),
+    draft: z.boolean().default(false),
+  }),
+});
+
+export const collections = { notes, docs };

diff --git a/ooknet-design/src/content.confi
g.ts b/ooknet-design/src/content.config.ts
index ce664a6..2721063 100644
--- a/ooknet-design/src/content.config.ts
+++ b/ooknet-design/src/content.config.ts
@@ -30,4 +30,16 @@ const notes = defineColle
ction({
   }),
 });
 
-export const collections = { notes };
+// Docs: hierarchical wiki pages. Directory
 structure is the taxonomy;
+// `order` sorts pages within a directory.
+const docs = defineCollection({
+  loader: glob({ pattern: "**/*.md", base: 
"./src/content/docs" }),
+  schema: z.object({
+    title: z.string(),
+    description: z.string().optional(),
+    order: z.number().optional(),
+    draft: z.boolean().default(false),
+  }),
+});
+
+export const collections = { notes, docs };
 

diff --git a/ooknet-design/src/content/docs/authoring/filing-notes.md b/ooknet-des
ign/src/content/docs/authoring/filing-notes.md
new file mode 100644
index 0000000..6350f4c
--- /dev/null
+++ b/ooknet-design/src/content/docs/authoring/filing-notes.md
@@ -0,0 +1,39 @@
+---
+title: "Filing notes"
+order: 1
+---
+
+# Anatomy of a note
+
+Notes live in `src/content/notes/` as markdown with frontmatter:
+
+```yaml
+title: "ENTROPY BOUNDS IN SPARSE GRAPHS"
+note: "N217"
+published: "2026-04-19"
+status: "PEER-CHECKED [PASS]"
+filed: "/INFO/GRAPH/ENTROPY/BOUND"
+meta:
+  category: "graph-theory"
+  tags: ["entropy", "sparse-graphs"]
+```
+
+Only `title`, `note`, and `published` are required; everything else
+defaults so a bare draft can't crash the content sync.
+
+# The filed path
+
+`filed` is the taxonomy - it drives the breadcrumbs on the note page
+and the tree on the index. Segments are searchable, so
+`/INFO/GRAPH/ENTROPY/BOUND` makes the note reachable from any of its
+levels.
+
+# Cross-references
+
+Link other notes by number: [[N217]], by title, or with an alias:
+[[N217|the entropy bounds note]]. Unresolved targets like [[N999]]
+render as dead links instead of failing the build, so drafts can
+reference notes that don't exist yet.
+
+Set `draft: true` to keep a note out of the index, the search, and the
+wikilink map.

diff --git a/ooknet-design/src/content/docs/
authoring/filing-notes.md b/ooknet-design/sr
c/content/docs/authoring/filing-notes.md
new file mode 100644
index 0000000..6350f4c
--- /dev/null
+++ b/ooknet-design/src/content/docs/authori
ng/filing-notes.md
@@ -0,0 +1,39 @@
+---
+title: "Filing notes"
+order: 1
+---
+
+# Anatomy of a note
+
+Notes live in `src/content/notes/` as markd
own with frontmatter:
+
+```yaml
+title: "ENTROPY BOUNDS IN SPARSE GRAPHS"
+note: "N217"
+published: "2026-04-19"
+status: "PEER-CHECKED [PASS]"
+filed: "/INFO/GRAPH/ENTROPY/BOUND"
+meta:
+  category: "graph-theory"
+  tags: ["entropy", "sparse-graphs"]
+```
+
+Only `title`, `note`, and `published` are r
equired; everything else
+defaults so a bare draft can't crash the co
ntent sync.
+
+# The filed path
+
+`filed` is the taxonomy - it drives the bre
adcrumbs on the note page
+and the tree on the index. Segments are sea
rchable, so
+`/INFO/GRAPH/ENTROPY/BOUND` makes the note 
reachable from any of its
+levels.
+
+# Cross-references
+
+Link other notes by number: [[N217]], by ti
tle, or with an alias:
+[[N217|the entropy bounds note]]. Unresolve
d targets like [[N999]]
+render as dead links instead of failing the
 build, so drafts can
+reference notes that don't exist yet.
+
+Set `draft: true` to keep a note out of the
 index, the search, and the
+wikilink map.
 

diff --git a/ooknet-design/src/content/docs/authoring/wikilinks.md b/ooknet-design
/src/content/docs/authoring/wikilinks.md
new file mode 100644
index 0000000..3e57328
--- /dev/null
+++ b/ooknet-design/src/content/docs/authoring/wikilinks.md
@@ -0,0 +1,36 @@
+---
+title: "Wikilinks"
+order: 2
+---
+
+# Syntax
+
+| FORM                    | RESOLVES BY                    |
+| ----------------------- | ------------------------------ |
+| `[[N217]]`              | note number                    |
+| `[[n0-style-sheet]]`    | file id                        |
+| `[[Style Sheet]]`       | title, case-insensitive        |
+| `[[design/frames]]`     | doc path                       |
+| `[[N217\|see bounds]]`  | alias - target before the pipe |
+
+Live: [[N217]] by number, [[Style Sheet]] by title,
+[[N217|aliased to this]] with an alias.
+
+# Resolution
+
+The map is built once per build by scanning notes and docs
+frontmatter. Every published page is indexed under its id, its note
+number (notes), and its title. Targets are trimmed and matched
+case-insensitively.
+
+# Dead links
+
+An unresolved target renders soft with a dashed underline - like
+[[N999]] - rather than failing the build. Hover shows the unresolved
+target. This is deliberate: drafts reference future notes, and the
+wiki tradition is to show the gap.
+
+# Where they work
+
+Anywhere markdown renders: notes, docs, and any prose the pipeline
+touches. They don't fire inside code fences or existing links.

diff --git a/ooknet-design/src/content/docs/
authoring/wikilinks.md b/ooknet-design/src/c
ontent/docs/authoring/wikilinks.md
new file mode 100644
index 0000000..3e57328
--- /dev/null
+++ b/ooknet-design/src/content/docs/authori
ng/wikilinks.md
@@ -0,0 +1,36 @@
+---
+title: "Wikilinks"
+order: 2
+---
+
+# Syntax
+
+| FORM                    | RESOLVES BY    
                |
+| ----------------------- | ---------------
--------------- |
+| `[[N217]]`              | note number    
                |
+| `[[n0-style-sheet]]`    | file id        
                |
+| `[[Style Sheet]]`       | title, case-ins
ensitive        |
+| `[[design/frames]]`     | doc path       
                |
+| `[[N217\|see bounds]]`  | alias - target 
before the pipe |
+
+Live: [[N217]] by number, [[Style Sheet]] b
y title,
+[[N217|aliased to this]] with an alias.
+
+# Resolution
+
+The map is built once per build by scanning
 notes and docs
+frontmatter. Every published page is indexe
d under its id, its note
+number (notes), and its title. Targets are 
trimmed and matched
+case-insensitively.
+
+# Dead links
+
+An unresolved target renders soft with a da
shed underline - like
+[[N999]] - rather than failing the build. H
over shows the unresolved
+target. This is deliberate: drafts referenc
e future notes, and the
+wiki tradition is to show the gap.
+
+# Where they work
+
+Anywhere markdown renders: notes, docs, and
 any prose the pipeline
+touches. They don't fire inside code fences
 or existing links.
 

diff --git a/ooknet-design/src/content/docs/design/components.md b/ooknet-design/s
rc/content/docs/design/components.md
new file mode 100644
index 0000000..36a7cea
--- /dev/null
+++ b/ooknet-design/src/content/docs/design/components.md
@@ -0,0 +1,28 @@
+---
+title: "Component library"
+order: 3
+---
+
+# Two kinds of component
+
+**Static** components are pure builders: a `.ts` function takes a
+width and returns a string, unit-tested, rendered at both frame widths
+through `Frame`. Tables, trees, timelines, gauges, calendars.
+
+**Interactive** components render once and wear ASCII chrome over
+native elements - a checkbox behind `[x]`, a range input over
+`├───█───┤`. They can't dual-render: duplicating an input duplicates
+its name and splits state across variants.
+
+# Conventions
+
+- one directory per component: `.astro`, `.ts`, optional `.scss` and `.test.ts`
+- every frame string passes `checkGrid` before it ships
+- motion is discrete and glyph-stepped, never a CSS fade
+- the live inventory renders on the [components page](/components)
+
+# Where to look first
+
+`Frame.astro` is the bridge between builders and the page. `Pre.astro`
+is the strict grid. Copy an existing component's directory and follow
+its shape.

diff --git a/ooknet-design/src/content/docs/
design/components.md b/ooknet-design/src/con
tent/docs/design/components.md
new file mode 100644
index 0000000..36a7cea
--- /dev/null
+++ b/ooknet-design/src/content/docs/design/
components.md
@@ -0,0 +1,28 @@
+---
+title: "Component library"
+order: 3
+---
+
+# Two kinds of component
+
+**Static** components are pure builders: a 
`.ts` function takes a
+width and returns a string, unit-tested, re
ndered at both frame widths
+through `Frame`. Tables, trees, timelines, 
gauges, calendars.
+
+**Interactive** components render once and 
wear ASCII chrome over
+native elements - a checkbox behind `[x]`, 
a range input over
+`├───█───┤`. They can't dual-render: duplic
ating an input duplicates
+its name and splits state across variants.
+
+# Conventions
+
+- one directory per component: `.astro`, `.
ts`, optional `.scss` and `.test.ts`
+- every frame string passes `checkGrid` bef
ore it ships
+- motion is discrete and glyph-stepped, nev
er a CSS fade
+- the live inventory renders on the [compon
ents page](/components)
+
+# Where to look first
+
+`Frame.astro` is the bridge between builder
s and the page. `Pre.astro`
+is the strict grid. Copy an existing compon
ent's directory and follow
+its shape.
 

diff --git a/ooknet-design/src/content/docs/design/frames.md b/ooknet-design/src/c
ontent/docs/design/frames.md
new file mode 100644
index 0000000..0f2225f
--- /dev/null
+++ b/ooknet-design/src/content/docs/design/frames.md
@@ -0,0 +1,36 @@
+---
+title: "Frames & the grid"
+order: 1
+---
+
+# The grid is the contract
+
+Every visual element on this site is text on a monospace grid. The
+frame is 86 cells wide on desktop and 48 on mobile; components render
+both variants at build time and CSS shows one per viewport.
+
+| CONSTANT       | CELLS | WHERE                |
+| -------------- | ----: | -------------------- |
+| FRAME_W        |    86 | src/lib/config.ts    |
+| MOBILE_FRAME_W |    48 | src/lib/config.ts    |
+
+# checkGrid
+
+Nothing ships that would bend a border. Every frame passes through
+`checkGrid`, which fails the build if a line overflows its width or
+uses a glyph the frame font doesn't ship at one cell wide:
+
+```ts
+export function checkGrid(text: string, maxW: number, ctx: string): string {
+  // throws on overflow or off-grid codepoints
+}
+```
+
+The full glyph policy lives in [[N0]] - the style sheet note that
+exercises every construct the pipeline renders.
+
+# Fonts
+
+Prose sets in IBM Plex Mono; frames set in Cascadia Mono, whose
+box-drawing glyphs are uniformly one cell. Never mix them on the same
+line of a frame.

diff --git a/ooknet-design/src/content/docs/
design/frames.md b/ooknet-design/src/content
/docs/design/frames.md
new file mode 100644
index 0000000..0f2225f
--- /dev/null
+++ b/ooknet-design/src/content/docs/design/
frames.md
@@ -0,0 +1,36 @@
+---
+title: "Frames & the grid"
+order: 1
+---
+
+# The grid is the contract
+
+Every visual element on this site is text o
n a monospace grid. The
+frame is 86 cells wide on desktop and 48 on
 mobile; components render
+both variants at build time and CSS shows o
ne per viewport.
+
+| CONSTANT       | CELLS | WHERE           
     |
+| -------------- | ----: | ----------------
---- |
+| FRAME_W        |    86 | src/lib/config.t
s    |
+| MOBILE_FRAME_W |    48 | src/lib/config.t
s    |
+
+# checkGrid
+
+Nothing ships that would bend a border. Eve
ry frame passes through
+`checkGrid`, which fails the build if a lin
e overflows its width or
+uses a glyph the frame font doesn't ship at
 one cell wide:
+
+```ts
+export function checkGrid(text: string, max
W: number, ctx: string): string {
+  // throws on overflow or off-grid codepoi
nts
+}
+```
+
+The full glyph policy lives in [[N0]] - the
 style sheet note that
+exercises every construct the pipeline rend
ers.
+
+# Fonts
+
+Prose sets in IBM Plex Mono; frames set in 
Cascadia Mono, whose
+box-drawing glyphs are uniformly one cell. 
Never mix them on the same
+line of a frame.
 

diff --git a/ooknet-design/src/content/docs/design/pipeline.md b/ooknet-design/src
/content/docs/design/pipeline.md
new file mode 100644
index 0000000..e2e342b
--- /dev/null
+++ b/ooknet-design/src/content/docs/design/pipeline.md
@@ -0,0 +1,30 @@
+---
+title: "Markdown pipeline"
+order: 2
+---
+
+# One source, ASCII out
+
+Notes and docs are plain markdown - previewable in Obsidian and
+GitHub - transformed at build time by `rehype-ascii`:
+
+| CONSTRUCT      | BECOMES                                  |
+| -------------- | ---------------------------------------- |
+| fenced code    | framed box with [ COPY ]                 |
+| table          | ASCII table + sr-only semantic table     |
+| [!NOTE] quote  | labeled callout                          |
+| lone image     | numbered figure                          |
+| diagram fence  | verbatim ASCII art as a figure           |
+| mermaid fence  | generated diagram as a figure            |
+| diff fence     | framed diff, adds bold, deletions soft   |
+
+# Diagrams
+
+A `mermaid` fence renders linear chains through the house layout and
+delegates branching graphs to mermaid-ascii. A `diagram` fence keeps
+hand-drawn art verbatim. Both take a `:: CAPTION` first line.
+
+# Wikilinks
+
+`[[target]]` resolves against notes and docs at build time - see
+[[Wikilinks]] for the syntax.

diff --git a/ooknet-design/src/content/docs/
design/pipeline.md b/ooknet-design/src/conte
nt/docs/design/pipeline.md
new file mode 100644
index 0000000..e2e342b
--- /dev/null
+++ b/ooknet-design/src/content/docs/design/
pipeline.md
@@ -0,0 +1,30 @@
+---
+title: "Markdown pipeline"
+order: 2
+---
+
+# One source, ASCII out
+
+Notes and docs are plain markdown - preview
able in Obsidian and
+GitHub - transformed at build time by `rehy
pe-ascii`:
+
+| CONSTRUCT      | BECOMES                 
                 |
+| -------------- | ------------------------
---------------- |
+| fenced code    | framed box with [ COPY ]
                 |
+| table          | ASCII table + sr-only se
mantic table     |
+| [!NOTE] quote  | labeled callout         
                 |
+| lone image     | numbered figure         
                 |
+| diagram fence  | verbatim ASCII art as a 
figure           |
+| mermaid fence  | generated diagram as a f
igure            |
+| diff fence     | framed diff, adds bold, 
deletions soft   |
+
+# Diagrams
+
+A `mermaid` fence renders linear chains thr
ough the house layout and
+delegates branching graphs to mermaid-ascii
. A `diagram` fence keeps
+hand-drawn art verbatim. Both take a `:: CA
PTION` first line.
+
+# Wikilinks
+
+`[[target]]` resolves against notes and doc
s at build time - see
+[[Wikilinks]] for the syntax.
 

diff --git a/ooknet-design/src/content/notes/n0-style-sheet.md b/ooknet-design/src
/content/notes/n0-style-sheet.md
index f2064fb..4de4e42 100644
--- a/ooknet-design/src/content/notes/n0-style-sheet.md
+++ b/ooknet-design/src/content/notes/n0-style-sheet.md
@@ -118,6 +118,12 @@ palette stays monochrome:
  }
 ```
 
+# Wikilinks
+
+A wikilink resolves by note number [[N217]], title [[Style Sheet]],
+or doc path [[design/frames]], and takes an alias:
+[[N217|the entropy note]]. Unresolved targets render dead: [[N999]].
+
 # Lists
 
 - frames are strings, components are chrome

diff --git a/ooknet-design/src/content/notes
/n0-style-sheet.md b/ooknet-design/src/conte
nt/notes/n0-style-sheet.md
index f2064fb..4de4e42 100644
--- a/ooknet-design/src/content/notes/n0-sty
le-sheet.md
+++ b/ooknet-design/src/content/notes/n0-sty
le-sheet.md
@@ -118,6 +118,12 @@ palette stays monochrom
e:
  }
 ```
 
+# Wikilinks
+
+A wikilink resolves by note number [[N217]]
, title [[Style Sheet]],
+or doc path [[design/frames]], and takes an
 alias:
+[[N217|the entropy note]]. Unresolved targe
ts render dead: [[N999]].
+
 # Lists
 
 - frames are strings, components are chrome
 

diff --git a/ooknet-design/src/lib/docs.test.ts b/ooknet-design/src/lib/docs.test.
ts
new file mode 100644
index 0000000..78c6460
--- /dev/null
+++ b/ooknet-design/src/lib/docs.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { docsOrder, docsTree } from "./docs";
+
+const DOCS = [
+  { id: "design/pipeline", title: "Pipeline", order: 2 },
+  { id: "design/frames", title: "Frames", order: 1 },
+  { id: "authoring/wikilinks", title: "Wikilinks", order: 2 },
+  { id: "authoring/filing-notes", title: "Filing notes", order: 1 },
+  { id: "design/zeta", title: "Zeta" },
+];
+
+describe("docsOrder", () => {
+  it("sorts directories alphabetically, pages by order then title", () => {
+    expect(docsOrder(DOCS).map((d) => d.id)).toEqual([
+      "authoring/filing-notes",
+      "authoring/wikilinks",
+      "design/frames",
+      "design/pipeline",
+      "design/zeta",
+    ]);
+  });
+
+  it("sorts a top-level page before directory contents", () => {
+    const withIndex = [...DOCS, { id: "overview", title: "Overview" }];
+    expect(docsOrder(withIndex)[0].id).toBe("overview");
+  });
+});
+
+describe("docsTree", () => {
+  it("builds branches from path segments with linked leaves", () => {
+    const tree = docsTree(DOCS);
+    expect(tree.map((n) => n.label)).toEqual(["/AUTHORING", "/DESIGN"]);
+    const design = tree[1];
+    expect(design.children!.map((n) => n.label)).toEqual(["FRAMES", "PIPELINE", "
ZETA"]);
+    expect(design.children![0].href).toBe("/docs/design/frames/");
+  });
+});

diff --git a/ooknet-design/src/lib/docs.test
.ts b/ooknet-design/src/lib/docs.test.ts
new file mode 100644
index 0000000..78c6460
--- /dev/null
+++ b/ooknet-design/src/lib/docs.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vites
t";
+import { docsOrder, docsTree } from "./docs
";
+
+const DOCS = [
+  { id: "design/pipeline", title: "Pipeline
", order: 2 },
+  { id: "design/frames", title: "Frames", o
rder: 1 },
+  { id: "authoring/wikilinks", title: "Wiki
links", order: 2 },
+  { id: "authoring/filing-notes", title: "F
iling notes", order: 1 },
+  { id: "design/zeta", title: "Zeta" },
+];
+
+describe("docsOrder", () => {
+  it("sorts directories alphabetically, pag
es by order then title", () => {
+    expect(docsOrder(DOCS).map((d) => d.id)
).toEqual([
+      "authoring/filing-notes",
+      "authoring/wikilinks",
+      "design/frames",
+      "design/pipeline",
+      "design/zeta",
+    ]);
+  });
+
+  it("sorts a top-level page before directo
ry contents", () => {
+    const withIndex = [...DOCS, { id: "over
view", title: "Overview" }];
+    expect(docsOrder(withIndex)[0].id).toBe
("overview");
+  });
+});
+
+describe("docsTree", () => {
+  it("builds branches from path segments wi
th linked leaves", () => {
+    const tree = docsTree(DOCS);
+    expect(tree.map((n) => n.label)).toEqua
l(["/AUTHORING", "/DESIGN"]);
+    const design = tree[1];
+    expect(design.children!.map((n) => n.la
bel)).toEqual(["FRAMES", "PIPELINE", "ZETA"]
);
+    expect(design.children![0].href).toBe("
/docs/design/frames/");
+  });
+});
 

diff --git a/ooknet-design/src/lib/docs.ts b/ooknet-design/src/lib/docs.ts
new file mode 100644
index 0000000..59ee60d
--- /dev/null
+++ b/ooknet-design/src/lib/docs.ts
@@ -0,0 +1,59 @@
+// Pure docs helpers: reading order and nav tree from doc ids like
+// "design/frames". astro:content stays in the pages, as with notes.ts.
+import type { TreeNode } from "../components/Tree/Tree";
+
+export interface DocEntry {
+  id: string;
+  title: string;
+  order?: number;
+}
+
+/** Depth-first reading order: directories alphabetical, pages within a
+ *  directory by `order` then title. */
+export function docsOrder(docs: DocEntry[]): DocEntry[] {
+  return [...docs].sort((a, b) => {
+    const as = a.id.split("/");
+    const bs = b.id.split("/");
+    const dirs = Math.max(as.length, bs.length) - 1;
+    for (let i = 0; i < dirs; i++) {
+      const ad = as[i] ?? "";
+      const bd = bs[i] ?? "";
+      // a page sorts before its sibling directories' contents
+      if (i === as.length - 1 && i < bs.length - 1) return -1;
+      if (i === bs.length - 1 && i < as.length - 1) return 1;
+      if (ad !== bd) return ad.localeCompare(bd);
+    }
+    const ao = a.order ?? Number.MAX_SAFE_INTEGER;
+    const bo = b.order ?? Number.MAX_SAFE_INTEGER;
+    if (ao !== bo) return ao - bo;
+    return a.title.localeCompare(b.title);
+  });
+}
+
+/** Nav tree: branches are path segments, leaves link to the doc. */
+export function docsTree(docs: DocEntry[]): TreeNode[] {
+  type Branch = { children: Map<string, Branch>; leaves: DocEntry[] };
+  const root: Branch = { children: new Map(), leaves: [] };
+
+  for (const d of docsOrder(docs)) {
+    const segments = d.id.split("/");
+    let branch = root;
+    for (const seg of segments.slice(0, -1)) {
+      if (!branch.children.has(seg)) branch.children.set(seg, { children: new Map
(), leaves: [] });
+      branch = branch.children.get(seg)!;
+    }
+    branch.leaves.push(d);
+  }
+
+  const toNodes = (b: Branch, depth: number): TreeNode[] => [
+    ...b.leaves.map((d) => ({
+      label: d.title.toUpperCase(),
+      href: `/docs/${d.id}/`,
+    })),
+    ...[...b.children.entries()].map(([seg, child]) => ({
+      label: depth === 0 ? `/${seg.toUpperCase()}` : seg.toUpperCase(),
+      children: toNodes(child, depth + 1),
+    })),
+  ];
+  return toNodes(root, 0);
+}

diff --git a/ooknet-design/src/lib/docs.ts b
/ooknet-design/src/lib/docs.ts
new file mode 100644
index 0000000..59ee60d
--- /dev/null
+++ b/ooknet-design/src/lib/docs.ts
@@ -0,0 +1,59 @@
+// Pure docs helpers: reading order and nav
 tree from doc ids like
+// "design/frames". astro:content stays in 
the pages, as with notes.ts.
+import type { TreeNode } from "../component
s/Tree/Tree";
+
+export interface DocEntry {
+  id: string;
+  title: string;
+  order?: number;
+}
+
+/** Depth-first reading order: directories 
alphabetical, pages within a
+ *  directory by `order` then title. */
+export function docsOrder(docs: DocEntry[])
: DocEntry[] {
+  return [...docs].sort((a, b) => {
+    const as = a.id.split("/");
+    const bs = b.id.split("/");
+    const dirs = Math.max(as.length, bs.len
gth) - 1;
+    for (let i = 0; i < dirs; i++) {
+      const ad = as[i] ?? "";
+      const bd = bs[i] ?? "";
+      // a page sorts before its sibling di
rectories' contents
+      if (i === as.length - 1 && i < bs.len
gth - 1) return -1;
+      if (i === bs.length - 1 && i < as.len
gth - 1) return 1;
+      if (ad !== bd) return ad.localeCompar
e(bd);
+    }
+    const ao = a.order ?? Number.MAX_SAFE_I
NTEGER;
+    const bo = b.order ?? Number.MAX_SAFE_I
NTEGER;
+    if (ao !== bo) return ao - bo;
+    return a.title.localeCompare(b.title);
+  });
+}
+
+/** Nav tree: branches are path segments, l
eaves link to the doc. */
+export function docsTree(docs: DocEntry[]):
 TreeNode[] {
+  type Branch = { children: Map<string, Bra
nch>; leaves: DocEntry[] };
+  const root: Branch = { children: new Map(
), leaves: [] };
+
+  for (const d of docsOrder(docs)) {
+    const segments = d.id.split("/");
+    let branch = root;
+    for (const seg of segments.slice(0, -1)
) {
+      if (!branch.children.has(seg)) branch
.children.set(seg, { children: new Map(), le
aves: [] });
+      branch = branch.children.get(seg)!;
+    }
+    branch.leaves.push(d);
+  }
+
+  const toNodes = (b: Branch, depth: number
): TreeNode[] => [
+    ...b.leaves.map((d) => ({
+      label: d.title.toUpperCase(),
+      href: `/docs/${d.id}/`,
+    })),
+    ...[...b.children.entries()].map(([seg,
 child]) => ({
+      label: depth === 0 ? `/${seg.toUpperC
ase()}` : seg.toUpperCase(),
+      children: toNodes(child, depth + 1),
+    })),
+  ];
+  return toNodes(root, 0);
+}
 

diff --git a/ooknet-design/src/lib/remark-wikilinks.ts b/ooknet-design/src/lib/rem
ark-wikilinks.ts
new file mode 100644
index 0000000..b309b84
--- /dev/null
+++ b/ooknet-design/src/lib/remark-wikilinks.ts
@@ -0,0 +1,64 @@
+// [[target]] and [[target|alias]] ? links, resolved against the wiki
+// map at build time. Unresolved targets render as soft dead links, in
+// the wiki tradition, rather than failing the build mid-draft.
+import { buildWikiMap, resolveWikilink, type WikiMap } from "./wikilinks";
+
+const WIKI_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
+
+interface MdNode {
+  type: string;
+  value?: string;
+  children?: MdNode[];
+  [key: string]: unknown;
+}
+
+const esc = (s: string) =>
+  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+
+export function wikiNodes(text: string, map: WikiMap): MdNode[] | null {
+  if (!text.includes("[[")) return null;
+  const out: MdNode[] = [];
+  let last = 0;
+  for (const m of text.matchAll(WIKI_RE)) {
+    if (m.index! > last) out.push({ type: "text", value: text.slice(last, m.index
) });
+    const target = m[1].trim();
+    const label = (m[2] ?? m[1]).trim();
+    const url = resolveWikilink(target, map);
+    out.push(
+      url
+        ? {
+            type: "link",
+            url,
+            children: [{ type: "text", value: label }],
+            data: { hProperties: { className: ["wikilink"] } },
+          }
+        : { type: "html", value: `<span class="wikilink-dead" title="unresolved: 
${esc(target)}">${esc(label)}</span>` },
+    );
+    last = m.index! + m[0].length;
+  }
+  if (!out.length) return null;
+  if (last < text.length) out.push({ type: "text", value: text.slice(last) });
+  return out;
+}
+
+function walk(node: MdNode, map: WikiMap) {
+  if (!node.children) return;
+  if (node.type === "link") return; // don't rewrite inside real links
+  node.children = node.children.flatMap((child) => {
+    if (child.type === "text" && child.value) {
+      const replaced = wikiNodes(child.value, map);
+      if (replaced) return replaced;
+    }
+    walk(child, map);
+    return [child];
+  });
+}
+
+let cache: WikiMap | null = null;
+
+export function remarkWikilinks() {
+  return (tree: MdNode) => {
+    cache ??= buildWikiMap();
+    walk(tree, cache);
+  };
+}

diff --git a/ooknet-design/src/lib/remark-wi
kilinks.ts b/ooknet-design/src/lib/remark-wi
kilinks.ts
new file mode 100644
index 0000000..b309b84
--- /dev/null
+++ b/ooknet-design/src/lib/remark-wikilinks
.ts
@@ -0,0 +1,64 @@
+// [[target]] and [[target|alias]] ? links,
 resolved against the wiki
+// map at build time. Unresolved targets re
nder as soft dead links, in
+// the wiki tradition, rather than failing 
the build mid-draft.
+import { buildWikiMap, resolveWikilink, typ
e WikiMap } from "./wikilinks";
+
+const WIKI_RE = /\[\[([^\]|]+)(?:\|([^\]]+)
)?\]\]/g;
+
+interface MdNode {
+  type: string;
+  value?: string;
+  children?: MdNode[];
+  [key: string]: unknown;
+}
+
+const esc = (s: string) =>
+  s.replace(/&/g, "&amp;").replace(/</g, "&
lt;").replace(/>/g, "&gt;");
+
+export function wikiNodes(text: string, map
: WikiMap): MdNode[] | null {
+  if (!text.includes("[[")) return null;
+  const out: MdNode[] = [];
+  let last = 0;
+  for (const m of text.matchAll(WIKI_RE)) {
+    if (m.index! > last) out.push({ type: "
text", value: text.slice(last, m.index) });
+    const target = m[1].trim();
+    const label = (m[2] ?? m[1]).trim();
+    const url = resolveWikilink(target, map
);
+    out.push(
+      url
+        ? {
+            type: "link",
+            url,
+            children: [{ type: "text", valu
e: label }],
+            data: { hProperties: { classNam
e: ["wikilink"] } },
+          }
+        : { type: "html", value: `<span cla
ss="wikilink-dead" title="unresolved: ${esc(
target)}">${esc(label)}</span>` },
+    );
+    last = m.index! + m[0].length;
+  }
+  if (!out.length) return null;
+  if (last < text.length) out.push({ type: 
"text", value: text.slice(last) });
+  return out;
+}
+
+function walk(node: MdNode, map: WikiMap) {
+  if (!node.children) return;
+  if (node.type === "link") return; // don'
t rewrite inside real links
+  node.children = node.children.flatMap((ch
ild) => {
+    if (child.type === "text" && child.valu
e) {
+      const replaced = wikiNodes(child.valu
e, map);
+      if (replaced) return replaced;
+    }
+    walk(child, map);
+    return [child];
+  });
+}
+
+let cache: WikiMap | null = null;
+
+export function remarkWikilinks() {
+  return (tree: MdNode) => {
+    cache ??= buildWikiMap();
+    walk(tree, cache);
+  };
+}
 

diff --git a/ooknet-design/src/lib/wikilinks.test.ts b/ooknet-design/src/lib/wikil
inks.test.ts
new file mode 100644
index 0000000..7ee1d0f
--- /dev/null
+++ b/ooknet-design/src/lib/wikilinks.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { buildWikiMap, resolveWikilink } from "./wikilinks";
+import { wikiNodes } from "./remark-wikilinks";
+
+describe("buildWikiMap", () => {
+  const map = buildWikiMap();
+
+  it("indexes notes by id, note number, and title", () => {
+    expect(resolveWikilink("n0-style-sheet", map)).toBe("/notes/n0-style-sheet/")
;
+    expect(resolveWikilink("N0", map)).toBe("/notes/n0-style-sheet/");
+    expect(resolveWikilink("Style Sheet", map)).toBe("/notes/n0-style-sheet/");
+  });
+
+  it("is case-insensitive and trims", () => {
+    expect(resolveWikilink("  n217  ", map)).toBe("/notes/n217-entropy-bounds/");
+  });
+
+  it("returns null for unknown targets", () => {
+    expect(resolveWikilink("N999", map)).toBeNull();
+  });
+});
+
+describe("wikiNodes", () => {
+  const map = new Map([["n217", "/notes/n217-entropy-bounds/"]]);
+
+  it("leaves plain text untouched", () => {
+    expect(wikiNodes("no links here", map)).toBeNull();
+  });
+
+  it("splits text around a resolved link", () => {
+    const nodes = wikiNodes("see [[N217]] for bounds", map)!;
+    expect(nodes.map((n) => n.type)).toEqual(["text", "link", "text"]);
+    expect(nodes[1].url).toBe("/notes/n217-entropy-bounds/");
+  });
+
+  it("uses the alias as the label", () => {
+    const nodes = wikiNodes("[[N217|the entropy note]]", map)!;
+    expect((nodes[0].children as { value: string }[])[0].value).toBe("the entropy
 note");
+  });
+
+  it("renders unresolved targets as dead-link spans", () => {
+    const nodes = wikiNodes("[[N999]]", map)!;
+    expect(nodes[0].type).toBe("html");
+    expect(nodes[0].value).toContain("wikilink-dead");
+  });
+});

diff --git a/ooknet-design/src/lib/wikilinks
.test.ts b/ooknet-design/src/lib/wikilinks.t
est.ts
new file mode 100644
index 0000000..7ee1d0f
--- /dev/null
+++ b/ooknet-design/src/lib/wikilinks.test.t
s
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vites
t";
+import { buildWikiMap, resolveWikilink } fr
om "./wikilinks";
+import { wikiNodes } from "./remark-wikilin
ks";
+
+describe("buildWikiMap", () => {
+  const map = buildWikiMap();
+
+  it("indexes notes by id, note number, and
 title", () => {
+    expect(resolveWikilink("n0-style-sheet"
, map)).toBe("/notes/n0-style-sheet/");
+    expect(resolveWikilink("N0", map)).toBe
("/notes/n0-style-sheet/");
+    expect(resolveWikilink("Style Sheet", m
ap)).toBe("/notes/n0-style-sheet/");
+  });
+
+  it("is case-insensitive and trims", () =>
 {
+    expect(resolveWikilink("  n217  ", map)
).toBe("/notes/n217-entropy-bounds/");
+  });
+
+  it("returns null for unknown targets", ()
 => {
+    expect(resolveWikilink("N999", map)).to
BeNull();
+  });
+});
+
+describe("wikiNodes", () => {
+  const map = new Map([["n217", "/notes/n21
7-entropy-bounds/"]]);
+
+  it("leaves plain text untouched", () => {
+    expect(wikiNodes("no links here", map))
.toBeNull();
+  });
+
+  it("splits text around a resolved link", 
() => {
+    const nodes = wikiNodes("see [[N217]] f
or bounds", map)!;
+    expect(nodes.map((n) => n.type)).toEqua
l(["text", "link", "text"]);
+    expect(nodes[1].url).toBe("/notes/n217-
entropy-bounds/");
+  });
+
+  it("uses the alias as the label", () => {
+    const nodes = wikiNodes("[[N217|the ent
ropy note]]", map)!;
+    expect((nodes[0].children as { value: s
tring }[])[0].value).toBe("the entropy note"
);
+  });
+
+  it("renders unresolved targets as dead-li
nk spans", () => {
+    const nodes = wikiNodes("[[N999]]", map
)!;
+    expect(nodes[0].type).toBe("html");
+    expect(nodes[0].value).toContain("wikil
ink-dead");
+  });
+});
 

diff --git a/ooknet-design/src/lib/wikilinks.ts b/ooknet-design/src/lib/wikilinks.
ts
new file mode 100644
index 0000000..eecda2d
--- /dev/null
+++ b/ooknet-design/src/lib/wikilinks.ts
@@ -0,0 +1,55 @@
+// Wikilink resolution. The map is built by scanning content frontmatter
+// directly (remark plugins run outside astro:content), keyed by every
+// name an author might reach for: note number, id, title, doc path.
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+
+export type WikiMap = Map<string, string>;
+
+const FM_RE = /^---\n([\s\S]*?)\n---/;
+
+function fmField(fm: string, key: string): string | null {
+  const m = fm.match(new RegExp(`^${key}:\\s*["']?(.+?)["']?\\s*$`, "m"));
+  return m ? m[1] : null;
+}
+
+function mdFiles(dir: string, prefix = ""): string[] {
+  let out: string[] = [];
+  for (const entry of readdirSync(dir, { withFileTypes: true })) {
+    const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
+    if (entry.isDirectory()) out = out.concat(mdFiles(join(dir, entry.name), rel)
);
+    else if (entry.name.endsWith(".md")) out.push(rel);
+  }
+  return out;
+}
+
+function scan(map: WikiMap, dir: string, urlFor: (id: string) => string) {
+  let files: string[];
+  try {
+    files = mdFiles(dir);
+  } catch {
+    return; // collection dir may not exist yet
+  }
+  for (const file of files) {
+    const id = file.replace(/\.md$/, "");
+    const url = urlFor(id);
+    const fm = readFileSync(join(dir, file), "utf8").match(FM_RE)?.[1] ?? "";
+    if (fmField(fm, "draft") === "true") continue;
+    map.set(id.toLowerCase(), url);
+    const note = fmField(fm, "note");
+    if (note) map.set(note.toLowerCase(), url);
+    const title = fmField(fm, "title");
+    if (title) map.set(title.toLowerCase(), url);
+  }
+}
+
+export function buildWikiMap(contentDir = "src/content"): WikiMap {
+  const map: WikiMap = new Map();
+  scan(map, join(contentDir, "notes"), (id) => `/notes/${id}/`);
+  scan(map, join(contentDir, "docs"), (id) => `/docs/${id}/`);
+  return map;
+}
+
+export function resolveWikilink(target: string, map: WikiMap): string | null {
+  return map.get(target.trim().toLowerCase()) ?? null;
+}

diff --git a/ooknet-design/src/lib/wikilinks
.ts b/ooknet-design/src/lib/wikilinks.ts
new file mode 100644
index 0000000..eecda2d
--- /dev/null
+++ b/ooknet-design/src/lib/wikilinks.ts
@@ -0,0 +1,55 @@
+// Wikilink resolution. The map is built by
 scanning content frontmatter
+// directly (remark plugins run outside ast
ro:content), keyed by every
+// name an author might reach for: note num
ber, id, title, doc path.
+import { readdirSync, readFileSync } from "
node:fs";
+import { join } from "node:path";
+
+export type WikiMap = Map<string, string>;
+
+const FM_RE = /^---\n([\s\S]*?)\n---/;
+
+function fmField(fm: string, key: string): 
string | null {
+  const m = fm.match(new RegExp(`^${key}:\\
s*["']?(.+?)["']?\\s*$`, "m"));
+  return m ? m[1] : null;
+}
+
+function mdFiles(dir: string, prefix = ""):
 string[] {
+  let out: string[] = [];
+  for (const entry of readdirSync(dir, { wi
thFileTypes: true })) {
+    const rel = prefix ? `${prefix}/${entry
.name}` : entry.name;
+    if (entry.isDirectory()) out = out.conc
at(mdFiles(join(dir, entry.name), rel));
+    else if (entry.name.endsWith(".md")) ou
t.push(rel);
+  }
+  return out;
+}
+
+function scan(map: WikiMap, dir: string, ur
lFor: (id: string) => string) {
+  let files: string[];
+  try {
+    files = mdFiles(dir);
+  } catch {
+    return; // collection dir may not exist
 yet
+  }
+  for (const file of files) {
+    const id = file.replace(/\.md$/, "");
+    const url = urlFor(id);
+    const fm = readFileSync(join(dir, file)
, "utf8").match(FM_RE)?.[1] ?? "";
+    if (fmField(fm, "draft") === "true") co
ntinue;
+    map.set(id.toLowerCase(), url);
+    const note = fmField(fm, "note");
+    if (note) map.set(note.toLowerCase(), u
rl);
+    const title = fmField(fm, "title");
+    if (title) map.set(title.toLowerCase(),
 url);
+  }
+}
+
+export function buildWikiMap(contentDir = "
src/content"): WikiMap {
+  const map: WikiMap = new Map();
+  scan(map, join(contentDir, "notes"), (id)
 => `/notes/${id}/`);
+  scan(map, join(contentDir, "docs"), (id) 
=> `/docs/${id}/`);
+  return map;
+}
+
+export function resolveWikilink(target: str
ing, map: WikiMap): string | null {
+  return map.get(target.trim().toLowerCase(
)) ?? null;
+}
 

diff --git a/ooknet-design/src/pages/docs/[...slug].astro b/ooknet-design/src/page
s/docs/[...slug].astro
new file mode 100644
index 0000000..c2292bf
--- /dev/null
+++ b/ooknet-design/src/pages/docs/[...slug].astro
@@ -0,0 +1,73 @@
+---
+import { getCollection, render, type CollectionEntry } from "astro:content";
+import ArticleLayout from "../../layouts/ArticleLayout/ArticleLayout.astro";
+import TopHeader from "../../components/TopHeader/TopHeader.astro";
+import Frame from "../../components/Frame/Frame.astro";
+import Pre from "../../components/Pre/Pre.astro";
+import Breadcrumbs from "../../components/Breadcrumbs/Breadcrumbs.astro";
+import TableOfContents from "../../components/TableOfContents/TableOfContents.ast
ro";
+import Prose from "../../components/Prose/Prose.astro";
+import ArticlePagination from "../../components/ArticlePagination/ArticlePaginati
on.astro";
+import type { PageLink } from "../../components/ArticlePagination/ArticlePaginati
on";
+import { box, center, len, rule, ruleD, row2, wrap } from "../../lib/ascii";
+import { published } from "../../lib/notes";
+import { docsOrder } from "../../lib/docs";
+
+export async function getStaticPaths() {
+  const docs = await getCollection("docs", published);
+  const ordered = docsOrder(
+    docs.map((d) => ({ id: d.id, title: d.data.title, order: d.data.order })),
+  );
+  const byId = new Map(docs.map((d) => [d.id, d]));
+
+  // Pagination labels are titles, truncated to keep the buttons on the
+  // narrow frame.
+  const link = (id?: string): PageLink | null => {
+    if (!id) return null;
+    const title = byId.get(id)!.data.title.toUpperCase();
+    const note = len(title) > 18 ? [...title].slice(0, 17).join("") + "..." : tit
le;
+    return { id, note };
+  };
+
+  return ordered.map((d, i) => ({
+    params: { slug: d.id },
+    props: {
+      doc: byId.get(d.id)!,
+      prev: link(ordered[i - 1]?.id),
+      next: link(ordered[i + 1]?.id),
+    },
+  }));
+}
+
+interface Props {
+  doc: CollectionEntry<"docs">;
+  prev: PageLink | null;
+  next: PageLink | null;
+}
+const { doc, prev, next } = Astro.props as Props;
+const { Content, headings } = await render(doc);
+
+const buildTitleCard = (w: number) => {
+  const cardW = Math.min(46, w - 8);
+  const card = box({
+    width: cardW,
+    lines: ["OOKNET DOCS", ...wrap(doc.data.title.toUpperCase(), cardW - 4).split
("\n")],
+    align: "center",
+  });
+  return card.split("\n").map((line) => center(line, w)).join("\n");
+};
+---
+<ArticleLayout title={`DOCS - ${doc.data.title}`}>
+  <TopHeader />
+  <Frame build={(w) => [row2("OOKNET DOCUMENTATION", doc.id.toUpperCase(), w), ru
le(w)].join("\n")} />
+  <Pre>{" "}</Pre>
+  <Frame build={buildTitleCard} />
+  <Pre>{" "}</Pre>
+  <Breadcrumbs filed={`/DOCS/${doc.id.toUpperCase()}`} />
+  <Frame build={(w) => ruleD(w)} />
+  {headings.length >= 3 && <TableOfContents headings={headings} />}
+  <Prose><Content /></Prose>
+  <Pre>{" "}</Pre>
+  <ArticlePagination prev={prev} next={next} />
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>

diff --git a/ooknet-design/src/pages/docs/[.
..slug].astro b/ooknet-design/src/pages/docs
/[...slug].astro
new file mode 100644
index 0000000..c2292bf
--- /dev/null
+++ b/ooknet-design/src/pages/docs/[...slug]
.astro
@@ -0,0 +1,73 @@
+---
+import { getCollection, render, type Collec
tionEntry } from "astro:content";
+import ArticleLayout from "../../layouts/Ar
ticleLayout/ArticleLayout.astro";
+import TopHeader from "../../components/Top
Header/TopHeader.astro";
+import Frame from "../../components/Frame/F
rame.astro";
+import Pre from "../../components/Pre/Pre.a
stro";
+import Breadcrumbs from "../../components/B
readcrumbs/Breadcrumbs.astro";
+import TableOfContents from "../../componen
ts/TableOfContents/TableOfContents.astro";
+import Prose from "../../components/Prose/P
rose.astro";
+import ArticlePagination from "../../compon
ents/ArticlePagination/ArticlePagination.ast
ro";
+import type { PageLink } from "../../compon
ents/ArticlePagination/ArticlePagination";
+import { box, center, len, rule, ruleD, row
2, wrap } from "../../lib/ascii";
+import { published } from "../../lib/notes"
;
+import { docsOrder } from "../../lib/docs";
+
+export async function getStaticPaths() {
+  const docs = await getCollection("docs", 
published);
+  const ordered = docsOrder(
+    docs.map((d) => ({ id: d.id, title: d.d
ata.title, order: d.data.order })),
+  );
+  const byId = new Map(docs.map((d) => [d.i
d, d]));
+
+  // Pagination labels are titles, truncate
d to keep the buttons on the
+  // narrow frame.
+  const link = (id?: string): PageLink | nu
ll => {
+    if (!id) return null;
+    const title = byId.get(id)!.data.title.
toUpperCase();
+    const note = len(title) > 18 ? [...titl
e].slice(0, 17).join("") + "..." : title;
+    return { id, note };
+  };
+
+  return ordered.map((d, i) => ({
+    params: { slug: d.id },
+    props: {
+      doc: byId.get(d.id)!,
+      prev: link(ordered[i - 1]?.id),
+      next: link(ordered[i + 1]?.id),
+    },
+  }));
+}
+
+interface Props {
+  doc: CollectionEntry<"docs">;
+  prev: PageLink | null;
+  next: PageLink | null;
+}
+const { doc, prev, next } = Astro.props as 
Props;
+const { Content, headings } = await render(
doc);
+
+const buildTitleCard = (w: number) => {
+  const cardW = Math.min(46, w - 8);
+  const card = box({
+    width: cardW,
+    lines: ["OOKNET DOCS", ...wrap(doc.data
.title.toUpperCase(), cardW - 4).split("\n")
],
+    align: "center",
+  });
+  return card.split("\n").map((line) => cen
ter(line, w)).join("\n");
+};
+---
+<ArticleLayout title={`DOCS - ${doc.data.ti
tle}`}>
+  <TopHeader />
+  <Frame build={(w) => [row2("OOKNET DOCUME
NTATION", doc.id.toUpperCase(), w), rule(w)]
.join("\n")} />
+  <Pre>{" "}</Pre>
+  <Frame build={buildTitleCard} />
+  <Pre>{" "}</Pre>
+  <Breadcrumbs filed={`/DOCS/${doc.id.toUpp
erCase()}`} />
+  <Frame build={(w) => ruleD(w)} />
+  {headings.length >= 3 && <TableOfContents
 headings={headings} />}
+  <Prose><Content /></Prose>
+  <Pre>{" "}</Pre>
+  <ArticlePagination prev={prev} next={next
} />
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>
 

diff --git a/ooknet-design/src/pages/docs/index.astro b/ooknet-design/src/pages/do
cs/index.astro
new file mode 100644
index 0000000..fae3fd1
--- /dev/null
+++ b/ooknet-design/src/pages/docs/index.astro
@@ -0,0 +1,26 @@
+---
+import { getCollection } from "astro:content";
+import ArticleLayout from "../../layouts/ArticleLayout/ArticleLayout.astro";
+import TopHeader from "../../components/TopHeader/TopHeader.astro";
+import Frame from "../../components/Frame/Frame.astro";
+import Pre from "../../components/Pre/Pre.astro";
+import Tree from "../../components/Tree/Tree.astro";
+import { rule, ruleD, row2 } from "../../lib/ascii";
+import { published } from "../../lib/notes";
+import { docsTree } from "../../lib/docs";
+
+const docs = (await getCollection("docs", published)).map((d) => ({
+  id: d.id,
+  title: d.data.title,
+  order: d.data.order,
+}));
+const tree = docsTree(docs);
+---
+<ArticleLayout title="OOKNET KB - Docs">
+  <TopHeader />
+  <Frame build={(w) => [row2("DOCUMENTATION", `${docs.length} pages`, w), ruleD(w
)].join("\n")} />
+  <Pre>{" "}</Pre>
+  <Tree nodes={tree} />
+  <Pre>{" "}</Pre>
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>

diff --git a/ooknet-design/src/pages/docs/in
dex.astro b/ooknet-design/src/pages/docs/ind
ex.astro
new file mode 100644
index 0000000..fae3fd1
--- /dev/null
+++ b/ooknet-design/src/pages/docs/index.ast
ro
@@ -0,0 +1,26 @@
+---
+import { getCollection } from "astro:conten
t";
+import ArticleLayout from "../../layouts/Ar
ticleLayout/ArticleLayout.astro";
+import TopHeader from "../../components/Top
Header/TopHeader.astro";
+import Frame from "../../components/Frame/F
rame.astro";
+import Pre from "../../components/Pre/Pre.a
stro";
+import Tree from "../../components/Tree/Tre
e.astro";
+import { rule, ruleD, row2 } from "../../li
b/ascii";
+import { published } from "../../lib/notes"
;
+import { docsTree } from "../../lib/docs";
+
+const docs = (await getCollection("docs", p
ublished)).map((d) => ({
+  id: d.id,
+  title: d.data.title,
+  order: d.data.order,
+}));
+const tree = docsTree(docs);
+---
+<ArticleLayout title="OOKNET KB - Docs">
+  <TopHeader />
+  <Frame build={(w) => [row2("DOCUMENTATION
", `${docs.length} pages`, w), ruleD(w)].joi
n("\n")} />
+  <Pre>{" "}</Pre>
+  <Tree nodes={tree} />
+  <Pre>{" "}</Pre>
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>
 

diff --git a/ooknet-design/src/pages/search.json.ts b/ooknet-design/src/pages/sear
ch.json.ts
index c4bec1c..cc5aeed 100644
--- a/ooknet-design/src/pages/search.json.ts
+++ b/ooknet-design/src/pages/search.json.ts
@@ -5,14 +5,25 @@ import { published, byNoteNum } from "../lib/notes";
 
 export const GET: APIRoute = async () => {
   const notes = (await getCollection("notes", published)).sort(byNoteNum).reverse
();
-  const data = notes.map((n) => ({
-    id: n.id,
-    note: n.data.note,
-    title: n.data.title,
-    category: n.data.meta.category,
-    tags: n.data.meta.tags,
-    filed: n.data.filed,
-  }));
+  const docs = await getCollection("docs", published);
+  const data = [
+    ...notes.map((n) => ({
+      note: n.data.note,
+      title: n.data.title,
+      category: n.data.meta.category,
+      tags: n.data.meta.tags,
+      filed: n.data.filed,
+      url: `/notes/${n.id}/`,
+    })),
+    ...docs.map((d) => ({
+      note: "DOC",
+      title: d.data.title,
+      category: "docs",
+      tags: [],
+      filed: `/DOCS/${d.id.toUpperCase()}`,
+      url: `/docs/${d.id}/`,
+    })),
+  ];
   return new Response(JSON.stringify(data), {
     headers: { "Content-Type": "application/json" },
   });

diff --git a/ooknet-design/src/pages/search.
json.ts b/ooknet-design/src/pages/search.jso
n.ts
index c4bec1c..cc5aeed 100644
--- a/ooknet-design/src/pages/search.json.ts
+++ b/ooknet-design/src/pages/search.json.ts
@@ -5,14 +5,25 @@ import { published, byNote
Num } from "../lib/notes";
 
 export const GET: APIRoute = async () => {
   const notes = (await getCollection("notes
", published)).sort(byNoteNum).reverse();
-  const data = notes.map((n) => ({
-    id: n.id,
-    note: n.data.note,
-    title: n.data.title,
-    category: n.data.meta.category,
-    tags: n.data.meta.tags,
-    filed: n.data.filed,
-  }));
+  const docs = await getCollection("docs", 
published);
+  const data = [
+    ...notes.map((n) => ({
+      note: n.data.note,
+      title: n.data.title,
+      category: n.data.meta.category,
+      tags: n.data.meta.tags,
+      filed: n.data.filed,
+      url: `/notes/${n.id}/`,
+    })),
+    ...docs.map((d) => ({
+      note: "DOC",
+      title: d.data.title,
+      category: "docs",
+      tags: [],
+      filed: `/DOCS/${d.id.toUpperCase()}`,
+      url: `/docs/${d.id}/`,
+    })),
+  ];
   return new Response(JSON.stringify(data),
 {
     headers: { "Content-Type": "application
/json" },
   });
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET