OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      54be7ca867e8
DATE      2026-07-17
SUBJECT   web: projects, resources, and about pages
FILES     10 CHANGED
HASH      54be7ca867e8
DATE      2026-07-17
SUBJECT   web: projects, resources, and about
          pages
FILES     10 CHANGED
 

diff --git a/ooknet-design/src/components/Table/Table.astro b/ooknet-design/src/co
mponents/Table/Table.astro
new file mode 100644
index 0000000..7dc85b6
--- /dev/null
+++ b/ooknet-design/src/components/Table/Table.astro
@@ -0,0 +1,19 @@
+---
+// Data-driven ASCII table: the frames are presentation, the sr-only
+// table carries the semantics.
+import Frame from "../Frame/Frame.astro";
+import { buildAsciiTable, type Cell } from "../../lib/ascii-table";
+import type { Props } from "./Table";
+
+const { head, rows, align = [] } = Astro.props as Props;
+const cell = (text: string, i: number): Cell => ({ text, align: align[i] ?? "end"
 });
+const cells: Cell[][] = [
+  ...(head ? [head.map(cell)] : []),
+  ...rows.map((r) => r.map(cell)),
+];
+---
+<div aria-hidden="true"><Frame class="table-frame" build={(w) => buildAsciiTable(
cells, head ? 1 : 0, w)} /></div>
+<table class="sr-only">
+  {head && <thead><tr>{head.map((h) => <th>{h}</th>)}</tr></thead>}
+  <tbody>{rows.map((r) => <tr>{r.map((c) => <td>{c}</td>)}</tr>)}</tbody>
+</table>

diff --git a/ooknet-design/src/components/Ta
ble/Table.astro b/ooknet-design/src/componen
ts/Table/Table.astro
new file mode 100644
index 0000000..7dc85b6
--- /dev/null
+++ b/ooknet-design/src/components/Table/Tab
le.astro
@@ -0,0 +1,19 @@
+---
+// Data-driven ASCII table: the frames are 
presentation, the sr-only
+// table carries the semantics.
+import Frame from "../Frame/Frame.astro";
+import { buildAsciiTable, type Cell } from 
"../../lib/ascii-table";
+import type { Props } from "./Table";
+
+const { head, rows, align = [] } = Astro.pr
ops as Props;
+const cell = (text: string, i: number): Cel
l => ({ text, align: align[i] ?? "end" });
+const cells: Cell[][] = [
+  ...(head ? [head.map(cell)] : []),
+  ...rows.map((r) => r.map(cell)),
+];
+---
+<div aria-hidden="true"><Frame class="table
-frame" build={(w) => buildAsciiTable(cells,
 head ? 1 : 0, w)} /></div>
+<table class="sr-only">
+  {head && <thead><tr>{head.map((h) => <th>
{h}</th>)}</tr></thead>}
+  <tbody>{rows.map((r) => <tr>{r.map((c) =>
 <td>{c}</td>)}</tr>)}</tbody>
+</table>
 

diff --git a/ooknet-design/src/components/Table/Table.ts b/ooknet-design/src/compo
nents/Table/Table.ts
new file mode 100644
index 0000000..60913e5
--- /dev/null
+++ b/ooknet-design/src/components/Table/Table.ts
@@ -0,0 +1,8 @@
+import type { PadSide } from "../../lib/ascii";
+
+export interface Props {
+  head?: string[];
+  rows: string[][];
+  /** Per-column alignment; "start" right-aligns. Defaults to left. */
+  align?: PadSide[];
+}

diff --git a/ooknet-design/src/components/Ta
ble/Table.ts b/ooknet-design/src/components/
Table/Table.ts
new file mode 100644
index 0000000..60913e5
--- /dev/null
+++ b/ooknet-design/src/components/Table/Tab
le.ts
@@ -0,0 +1,8 @@
+import type { PadSide } from "../../lib/asc
ii";
+
+export interface Props {
+  head?: string[];
+  rows: string[][];
+  /** Per-column alignment; "start" right-a
ligns. Defaults to left. */
+  align?: PadSide[];
+}
 

diff --git a/ooknet-design/src/lib/rehype-ascii.test.ts b/ooknet-design/src/lib/as
cii-table.test.ts
similarity index 97%
rename from ooknet-design/src/lib/rehype-ascii.test.ts
rename to ooknet-design/src/lib/ascii-table.test.ts
index b06a99e..5720cb1 100644
--- a/ooknet-design/src/lib/rehype-ascii.test.ts
+++ b/ooknet-design/src/lib/ascii-table.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { buildAsciiTable, type Cell } from "./rehype-ascii";
+import { buildAsciiTable, type Cell } from "./ascii-table";
 import { len } from "./ascii";
 
 const cell = (text: string, align: Cell["align"] = "end"): Cell => ({ text, align
 });

diff --git a/ooknet-design/src/lib/rehype-as
cii.test.ts b/ooknet-design/src/lib/ascii-ta
ble.test.ts
similarity index 97%
rename from ooknet-design/src/lib/rehype-asc
ii.test.ts
rename to ooknet-design/src/lib/ascii-table.
test.ts
index b06a99e..5720cb1 100644
--- a/ooknet-design/src/lib/rehype-ascii.tes
t.ts
+++ b/ooknet-design/src/lib/ascii-table.test
.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vites
t";
-import { buildAsciiTable, type Cell } from 
"./rehype-ascii";
+import { buildAsciiTable, type Cell } from 
"./ascii-table";
 import { len } from "./ascii";
 
 const cell = (text: string, align: Cell["al
ign"] = "end"): Cell => ({ text, align });
 

diff --git a/ooknet-design/src/lib/ascii-table.ts b/ooknet-design/src/lib/ascii-ta
ble.ts
new file mode 100644
index 0000000..c287a06
--- /dev/null
+++ b/ooknet-design/src/lib/ascii-table.ts
@@ -0,0 +1,49 @@
+// Data-driven ASCII table shared by the markdown transform and the
+// Table component.
+import { G, checkGrid, len, pad, wrap, type PadSide } from "./ascii";
+
+export type Cell = { text: string; align: PadSide };
+
+export function buildAsciiTable(rows: Cell[][], headerRows: number, width: number
): string {
+  const nCols = Math.max(...rows.map((r) => r.length));
+  const norm = rows.map((r) => {
+    const out = [...r];
+    while (out.length < nCols) out.push({ text: "", align: "end" });
+    return out;
+  });
+
+  // Natural column widths, then shave the widest until the table fits.
+  // Cells wrap to their final column width.
+  const colW = Array.from({ length: nCols }, (_, i) =>
+    Math.max(1, ...norm.map((r) => len(r[i].text))),
+  );
+  const total = () => 1 + colW.reduce((s, w) => s + w + 3, 0);
+  const MIN_COL = 5;
+  while (total() > width && colW.some((w) => w > MIN_COL)) {
+    colW[colW.indexOf(Math.max(...colW))]--;
+  }
+
+  const edge = (l: string, m: string, r: string, h = G.H) =>
+    l + colW.map((w) => h.repeat(w + 2)).join(m) + r;
+
+  // When any row wraps, dashed separators between body rows keep the
+  // logical rows readable; all-single-line tables stay compact.
+  const cellLines = norm.map((row) => row.map((c, i) => wrap(c.text, colW[i]).spl
it("\n")));
+  const anyWrapped = cellLines.some((row) => row.some((l) => l.length > 1));
+
+  const out: string[] = [edge(G.TL, G.TJ, G.TR)];
+  norm.forEach((row, ri) => {
+    if (anyWrapped && ri > headerRows) out.push(edge(G.LJ, G.X, G.RJ, G.HD));
+    const h = Math.max(...cellLines[ri].map((l) => l.length));
+    for (let k = 0; k < h; k++) {
+      out.push(
+        G.V +
+          row.map((c, i) => ` ${pad(cellLines[ri][i][k] ?? "", colW[i], c.align)}
 `).join(G.V) +
+          G.V,
+      );
+    }
+    if (ri === headerRows - 1) out.push(edge(G.LJ, G.X, G.RJ));
+  });
+  out.push(edge(G.BL, G.BJ, G.BR));
+  return checkGrid(out.join("\n"), width, "table");
+}

diff --git a/ooknet-design/src/lib/ascii-tab
le.ts b/ooknet-design/src/lib/ascii-table.ts
new file mode 100644
index 0000000..c287a06
--- /dev/null
+++ b/ooknet-design/src/lib/ascii-table.ts
@@ -0,0 +1,49 @@
+// Data-driven ASCII table shared by the ma
rkdown transform and the
+// Table component.
+import { G, checkGrid, len, pad, wrap, type
 PadSide } from "./ascii";
+
+export type Cell = { text: string; align: P
adSide };
+
+export function buildAsciiTable(rows: Cell[
][], headerRows: number, width: number): str
ing {
+  const nCols = Math.max(...rows.map((r) =>
 r.length));
+  const norm = rows.map((r) => {
+    const out = [...r];
+    while (out.length < nCols) out.push({ t
ext: "", align: "end" });
+    return out;
+  });
+
+  // Natural column widths, then shave the 
widest until the table fits.
+  // Cells wrap to their final column width
.
+  const colW = Array.from({ length: nCols }
, (_, i) =>
+    Math.max(1, ...norm.map((r) => len(r[i]
.text))),
+  );
+  const total = () => 1 + colW.reduce((s, w
) => s + w + 3, 0);
+  const MIN_COL = 5;
+  while (total() > width && colW.some((w) =
> w > MIN_COL)) {
+    colW[colW.indexOf(Math.max(...colW))]--
;
+  }
+
+  const edge = (l: string, m: string, r: st
ring, h = G.H) =>
+    l + colW.map((w) => h.repeat(w + 2)).jo
in(m) + r;
+
+  // When any row wraps, dashed separators 
between body rows keep the
+  // logical rows readable; all-single-line
 tables stay compact.
+  const cellLines = norm.map((row) => row.m
ap((c, i) => wrap(c.text, colW[i]).split("\n
")));
+  const anyWrapped = cellLines.some((row) =
> row.some((l) => l.length > 1));
+
+  const out: string[] = [edge(G.TL, G.TJ, G
.TR)];
+  norm.forEach((row, ri) => {
+    if (anyWrapped && ri > headerRows) out.
push(edge(G.LJ, G.X, G.RJ, G.HD));
+    const h = Math.max(...cellLines[ri].map
((l) => l.length));
+    for (let k = 0; k < h; k++) {
+      out.push(
+        G.V +
+          row.map((c, i) => ` ${pad(cellLin
es[ri][i][k] ?? "", colW[i], c.align)} `).jo
in(G.V) +
+          G.V,
+      );
+    }
+    if (ri === headerRows - 1) out.push(edg
e(G.LJ, G.X, G.RJ));
+  });
+  out.push(edge(G.BL, G.BJ, G.BR));
+  return checkGrid(out.join("\n"), width, "
table");
+}
 

diff --git a/ooknet-design/src/lib/notes.test.ts b/ooknet-design/src/lib/notes.tes
t.ts
index cf0bcea..77e5674 100644
--- a/ooknet-design/src/lib/notes.test.ts
+++ b/ooknet-design/src/lib/notes.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { byNoteNum, noteNum, published } from "./notes";
+import { byNoteNum, filedTree, noteNum, published } from "./notes";
 
 describe("noteNum", () => {
   it("extracts the number from a note id", () => {
@@ -26,3 +26,23 @@ describe("published", () => {
     expect(published({ data: {} })).toBe(true);
   });
 });
+
+describe("filedTree", () => {
+  it("nests path segments and links leaves to notes", () => {
+    const tree = filedTree([
+      { id: "n217-entropy-bounds", note: "N217", filed: "/INFO/GRAPH/ENTROPY/BOUN
D" },
+      { id: "n0-style-sheet", note: "N0", filed: "/META/STYLE/SHEET" },
+      { id: "n219-ai", note: "N219", filed: "/INFO/AI/TROUBLESHOOTING" },
+    ]);
+    expect(tree.map((n) => n.label)).toEqual(["/INFO", "/META"]);
+    const info = tree[0];
+    expect(info.children!.map((n) => n.label)).toEqual(["AI", "GRAPH"]);
+    const leaf = info.children![0].children![0];
+    expect(leaf).toEqual({ label: "TROUBLESHOOTING - N219", href: "/notes/n219-ai
/" });
+  });
+
+  it("files pathless notes under UNFILED", () => {
+    const tree = filedTree([{ id: "x", note: "N1", filed: "/" }]);
+    expect(tree[0].label).toBe("UNFILED - N1");
+  });
+});

diff --git a/ooknet-design/src/lib/notes.tes
t.ts b/ooknet-design/src/lib/notes.test.ts
index cf0bcea..77e5674 100644
--- a/ooknet-design/src/lib/notes.test.ts
+++ b/ooknet-design/src/lib/notes.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vites
t";
-import { byNoteNum, noteNum, published } fr
om "./notes";
+import { byNoteNum, filedTree, noteNum, pub
lished } from "./notes";
 
 describe("noteNum", () => {
   it("extracts the number from a note id", 
() => {
@@ -26,3 +26,23 @@ describe("published", () 
=> {
     expect(published({ data: {} })).toBe(tr
ue);
   });
 });
+
+describe("filedTree", () => {
+  it("nests path segments and links leaves 
to notes", () => {
+    const tree = filedTree([
+      { id: "n217-entropy-bounds", note: "N
217", filed: "/INFO/GRAPH/ENTROPY/BOUND" },
+      { id: "n0-style-sheet", note: "N0", f
iled: "/META/STYLE/SHEET" },
+      { id: "n219-ai", note: "N219", filed:
 "/INFO/AI/TROUBLESHOOTING" },
+    ]);
+    expect(tree.map((n) => n.label)).toEqua
l(["/INFO", "/META"]);
+    const info = tree[0];
+    expect(info.children!.map((n) => n.labe
l)).toEqual(["AI", "GRAPH"]);
+    const leaf = info.children![0].children
![0];
+    expect(leaf).toEqual({ label: "TROUBLES
HOOTING - N219", href: "/notes/n219-ai/" });
+  });
+
+  it("files pathless notes under UNFILED", 
() => {
+    const tree = filedTree([{ id: "x", note
: "N1", filed: "/" }]);
+    expect(tree[0].label).toBe("UNFILED - N
1");
+  });
+});
 

diff --git a/ooknet-design/src/lib/notes.ts b/ooknet-design/src/lib/notes.ts
index 8cb12cb..2ce3edc 100644
--- a/ooknet-design/src/lib/notes.ts
+++ b/ooknet-design/src/lib/notes.ts
@@ -1,5 +1,6 @@
 // Pure note helpers. astro:content stays in the pages so this module
 // is unit-testable.
+import type { TreeNode } from "../components/Tree/Tree";
 
 export const published = ({ data }: { data: { draft?: boolean } }) => !data.draft
;
 
@@ -11,3 +12,41 @@ type HasNote = { data: { note: string } };
 /** Ascending by note number: N0, N1, ..., N217. */
 export const byNoteNum = (a: HasNote, b: HasNote): number =>
   noteNum(a.data.note) - noteNum(b.data.note);
+
+export interface FiledNote {
+  id: string;
+  note: string;
+  filed: string;
+}
+
+/** Builds a Tree from the notes' filed paths: branches are path
+ *  segments, leaves link to the note. */
+export function filedTree(notes: FiledNote[]): TreeNode[] {
+  type Branch = { children: Map<string, Branch>; leaves: TreeNode[] };
+  const root: Branch = { children: new Map(), leaves: [] };
+
+  for (const n of notes) {
+    const segments = n.filed.split("/").filter(Boolean);
+    if (!segments.length) segments.push("UNFILED");
+    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({
+      label: `${segments[segments.length - 1]} - ${n.note}`,
+      href: `/notes/${n.id}/`,
+    });
+  }
+
+  const toNodes = (b: Branch, depth: number): TreeNode[] => [
+    ...[...b.children.entries()]
+      .sort(([a], [c]) => a.localeCompare(c))
+      .map(([seg, child]) => ({
+        label: depth === 0 ? `/${seg}` : seg,
+        children: toNodes(child, depth + 1),
+      })),
+    ...b.leaves.sort((a, c) => a.label.localeCompare(c.label)),
+  ];
+  return toNodes(root, 0);
+}

diff --git a/ooknet-design/src/lib/notes.ts 
b/ooknet-design/src/lib/notes.ts
index 8cb12cb..2ce3edc 100644
--- a/ooknet-design/src/lib/notes.ts
+++ b/ooknet-design/src/lib/notes.ts
@@ -1,5 +1,6 @@
 // Pure note helpers. astro:content stays i
n the pages so this module
 // is unit-testable.
+import type { TreeNode } from "../component
s/Tree/Tree";
 
 export const published = ({ data }: { data:
 { draft?: boolean } }) => !data.draft;
 
@@ -11,3 +12,41 @@ type HasNote = { data: { 
note: string } };
 /** Ascending by note number: N0, N1, ..., 
N217. */
 export const byNoteNum = (a: HasNote, b: Ha
sNote): number =>
   noteNum(a.data.note) - noteNum(b.data.not
e);
+
+export interface FiledNote {
+  id: string;
+  note: string;
+  filed: string;
+}
+
+/** Builds a Tree from the notes' filed pat
hs: branches are path
+ *  segments, leaves link to the note. */
+export function filedTree(notes: FiledNote[
]): TreeNode[] {
+  type Branch = { children: Map<string, Bra
nch>; leaves: TreeNode[] };
+  const root: Branch = { children: new Map(
), leaves: [] };
+
+  for (const n of notes) {
+    const segments = n.filed.split("/").fil
ter(Boolean);
+    if (!segments.length) segments.push("UN
FILED");
+    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({
+      label: `${segments[segments.length - 
1]} - ${n.note}`,
+      href: `/notes/${n.id}/`,
+    });
+  }
+
+  const toNodes = (b: Branch, depth: number
): TreeNode[] => [
+    ...[...b.children.entries()]
+      .sort(([a], [c]) => a.localeCompare(c
))
+      .map(([seg, child]) => ({
+        label: depth === 0 ? `/${seg}` : se
g,
+        children: toNodes(child, depth + 1)
,
+      })),
+    ...b.leaves.sort((a, c) => a.label.loca
leCompare(c.label)),
+  ];
+  return toNodes(root, 0);
+}
 

diff --git a/ooknet-design/src/lib/rehype-ascii.ts b/ooknet-design/src/lib/rehype-
ascii.ts
index c781d6d..4e8152a 100644
--- a/ooknet-design/src/lib/rehype-ascii.ts
+++ b/ooknet-design/src/lib/rehype-ascii.ts
@@ -4,7 +4,8 @@
 // gets a head-rule. Everything emits both the desktop and mobile
 // variants in the same tree; CSS toggles visibility per viewport.
 import type { Root, Element, ElementContent, RootContent, Properties } from "hast
";
-import { G, checkGrid, len, pad, wrap, type PadSide } from "./ascii";
+import { G, checkGrid, len, wrap, type PadSide } from "./ascii";
+import { buildAsciiTable, type Cell } from "./ascii-table";
 import { FRAME_W, MOBILE_FRAME_W } from "./config";
 import { mermaidToAscii } from "./mermaid";
 
@@ -122,8 +123,6 @@ function makeCodePre(text: string, lang: string, frameW: numbe
r, extraClass: str
 
 // --------------------------------------------------------------- table
 
-export type Cell = { text: string; align: PadSide };
-
 function tableCells(tbl: Element): { rows: Cell[][]; headerRows: number } {
   const rows: Cell[][] = [];
   let headerRows = 0;
@@ -146,50 +145,6 @@ function tableCells(tbl: Element): { rows: Cell[][]; headerRo
ws: number } {
   return { rows, headerRows };
 }
 
-export function buildAsciiTable(rows: Cell[][], headerRows: number, width: number
): string {
-  const nCols = Math.max(...rows.map((r) => r.length));
-  const norm = rows.map((r) => {
-    const out = [...r];
-    while (out.length < nCols) out.push({ text: "", align: "end" });
-    return out;
-  });
-
-  // Natural column widths, then shave the widest until the table fits.
-  // Cells wrap to their final column width.
-  const colW = Array.from({ length: nCols }, (_, i) =>
-    Math.max(1, ...norm.map((r) => len(r[i].text))),
-  );
-  const total = () => 1 + colW.reduce((s, w) => s + w + 3, 0);
-  const MIN_COL = 5;
-  while (total() > width && colW.some((w) => w > MIN_COL)) {
-    colW[colW.indexOf(Math.max(...colW))]--;
-  }
-
-  const edge = (l: string, m: string, r: string, h = G.H) =>
-    l + colW.map((w) => h.repeat(w + 2)).join(m) + r;
-
-  // When any row wraps, dashed separators between body rows keep the
-  // logical rows readable; all-single-line tables stay compact.
-  const cellLines = norm.map((row) => row.map((c, i) => wrap(c.text, colW[i]).spl
it("\n")));
-  const anyWrapped = cellLines.some((row) => row.some((l) => l.length > 1));
-
-  const out: string[] = [edge(G.TL, G.TJ, G.TR)];
-  norm.forEach((row, ri) => {
-    if (anyWrapped && ri > headerRows) out.push(edge(G.LJ, G.X, G.RJ, G.HD));
-    const h = Math.max(...cellLines[ri].map((l) => l.length));
-    for (let k = 0; k < h; k++) {
-      out.push(
-        G.V +
-          row.map((c, i) => ` ${pad(cellLines[ri][i][k] ?? "", colW[i], c.align)}
 `).join(G.V) +
-          G.V,
-      );
-    }
-    if (ri === headerRows - 1) out.push(edge(G.LJ, G.X, G.RJ));
-  });
-  out.push(edge(G.BL, G.BJ, G.BR));
-  return checkGrid(out.join("\n"), width, "table");
-}
-
 function makeTablePre(text: string, extraClass: string): Element {
   return {
     type: "element",

diff --git a/ooknet-design/src/lib/rehype-as
cii.ts b/ooknet-design/src/lib/rehype-ascii.
ts
index c781d6d..4e8152a 100644
--- a/ooknet-design/src/lib/rehype-ascii.ts
+++ b/ooknet-design/src/lib/rehype-ascii.ts
@@ -4,7 +4,8 @@
 // gets a head-rule. Everything emits both 
the desktop and mobile
 // variants in the same tree; CSS toggles v
isibility per viewport.
 import type { Root, Element, ElementContent
, RootContent, Properties } from "hast";
-import { G, checkGrid, len, pad, wrap, type
 PadSide } from "./ascii";
+import { G, checkGrid, len, wrap, type PadS
ide } from "./ascii";
+import { buildAsciiTable, type Cell } from 
"./ascii-table";
 import { FRAME_W, MOBILE_FRAME_W } from "./
config";
 import { mermaidToAscii } from "./mermaid";
 
@@ -122,8 +123,6 @@ function makeCodePre(tex
t: string, lang: string, frameW: number, ext
raClass: str
 
 // ----------------------------------------
----------------------- table
 
-export type Cell = { text: string; align: P
adSide };
-
 function tableCells(tbl: Element): { rows: 
Cell[][]; headerRows: number } {
   const rows: Cell[][] = [];
   let headerRows = 0;
@@ -146,50 +145,6 @@ function tableCells(tbl
: Element): { rows: Cell[][]; headerRows: nu
mber } {
   return { rows, headerRows };
 }
 
-export function buildAsciiTable(rows: Cell[
][], headerRows: number, width: number): str
ing {
-  const nCols = Math.max(...rows.map((r) =>
 r.length));
-  const norm = rows.map((r) => {
-    const out = [...r];
-    while (out.length < nCols) out.push({ t
ext: "", align: "end" });
-    return out;
-  });
-
-  // Natural column widths, then shave the 
widest until the table fits.
-  // Cells wrap to their final column width
.
-  const colW = Array.from({ length: nCols }
, (_, i) =>
-    Math.max(1, ...norm.map((r) => len(r[i]
.text))),
-  );
-  const total = () => 1 + colW.reduce((s, w
) => s + w + 3, 0);
-  const MIN_COL = 5;
-  while (total() > width && colW.some((w) =
> w > MIN_COL)) {
-    colW[colW.indexOf(Math.max(...colW))]--
;
-  }
-
-  const edge = (l: string, m: string, r: st
ring, h = G.H) =>
-    l + colW.map((w) => h.repeat(w + 2)).jo
in(m) + r;
-
-  // When any row wraps, dashed separators 
between body rows keep the
-  // logical rows readable; all-single-line
 tables stay compact.
-  const cellLines = norm.map((row) => row.m
ap((c, i) => wrap(c.text, colW[i]).split("\n
")));
-  const anyWrapped = cellLines.some((row) =
> row.some((l) => l.length > 1));
-
-  const out: string[] = [edge(G.TL, G.TJ, G
.TR)];
-  norm.forEach((row, ri) => {
-    if (anyWrapped && ri > headerRows) out.
push(edge(G.LJ, G.X, G.RJ, G.HD));
-    const h = Math.max(...cellLines[ri].map
((l) => l.length));
-    for (let k = 0; k < h; k++) {
-      out.push(
-        G.V +
-          row.map((c, i) => ` ${pad(cellLin
es[ri][i][k] ?? "", colW[i], c.align)} `).jo
in(G.V) +
-          G.V,
-      );
-    }
-    if (ri === headerRows - 1) out.push(edg
e(G.LJ, G.X, G.RJ));
-  });
-  out.push(edge(G.BL, G.BJ, G.BR));
-  return checkGrid(out.join("\n"), width, "
table");
-}
-
 function makeTablePre(text: string, extraCl
ass: string): Element {
   return {
     type: "element",
 

diff --git a/ooknet-design/src/pages/about.astro b/ooknet-design/src/pages/about.a
stro
new file mode 100644
index 0000000..95c4cda
--- /dev/null
+++ b/ooknet-design/src/pages/about.astro
@@ -0,0 +1,49 @@
+---
+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 Prose from "../components/Prose/Prose.astro";
+import ArticleFooter from "../components/ArticleFooter/ArticleFooter.astro";
+import { kv, row2, rule, ruleD } from "../lib/ascii";
+
+const buildHeader = (w: number) =>
+  [row2("ABOUT THIS INSTALLATION", "OOKNET", w), ruleD(w)].join("\n");
+
+const buildColophon = (w: number) =>
+  [
+    row2("COLOPHON", "", w),
+    rule(w),
+    kv("OPERATOR",   "LIAM W",                          12, w),
+    kv("ENGINE",     "astro, rendered static",          12, w),
+    kv("FRAMES",     "strings, built at compile time",  12, w),
+    kv("BODY TYPE",  "IBM Plex Mono",                   12, w),
+    kv("FRAME TYPE", "Cascadia Mono",                   12, w),
+    kv("GRID",       "86 cells / 48 on paper tape",     12, w),
+    ruleD(w),
+  ].join("\n");
+---
+<ArticleLayout title="OOKNET KB - About">
+  <TopHeader />
+  <Frame build={buildHeader} />
+  <Prose>
+    <p>
+      OOKNET is a personal knowledge base kept the way records used to
+      be kept: as numbered technical sheets, filed under a taxonomy,
+      printed on an endless roll of tractor-feed paper. Notes get a
+      number, a status, and a place in the filing system; the archive
+      grows from the bottom of the index.
+    </p>
+    <p>
+      The site is also its own subject. Every frame on these pages is a
+      string composed at build time on a strict monospace grid - no
+      client-side rendering, no images of boxes, just characters. A
+      validator walks every line at compile time and refuses to ship a
+      bent border. The whole apparatus is documented on the style sheet
+      at note N0 and demonstrated on the components page.
+    </p>
+  </Prose>
+  <Frame build={buildColophon} />
+  <Pre>{" "}</Pre>
+  <ArticleFooter page={4} />
+</ArticleLayout>

diff --git a/ooknet-design/src/pages/about.a
stro b/ooknet-design/src/pages/about.astro
new file mode 100644
index 0000000..95c4cda
--- /dev/null
+++ b/ooknet-design/src/pages/about.astro
@@ -0,0 +1,49 @@
+---
+import ArticleLayout from "../layouts/Artic
leLayout/ArticleLayout.astro";
+import TopHeader from "../components/TopHea
der/TopHeader.astro";
+import Frame from "../components/Frame/Fram
e.astro";
+import Pre from "../components/Pre/Pre.astr
o";
+import Prose from "../components/Prose/Pros
e.astro";
+import ArticleFooter from "../components/Ar
ticleFooter/ArticleFooter.astro";
+import { kv, row2, rule, ruleD } from "../l
ib/ascii";
+
+const buildHeader = (w: number) =>
+  [row2("ABOUT THIS INSTALLATION", "OOKNET"
, w), ruleD(w)].join("\n");
+
+const buildColophon = (w: number) =>
+  [
+    row2("COLOPHON", "", w),
+    rule(w),
+    kv("OPERATOR",   "LIAM W",             
             12, w),
+    kv("ENGINE",     "astro, rendered stati
c",          12, w),
+    kv("FRAMES",     "strings, built at com
pile time",  12, w),
+    kv("BODY TYPE",  "IBM Plex Mono",      
             12, w),
+    kv("FRAME TYPE", "Cascadia Mono",      
             12, w),
+    kv("GRID",       "86 cells / 48 on pape
r tape",     12, w),
+    ruleD(w),
+  ].join("\n");
+---
+<ArticleLayout title="OOKNET KB - About">
+  <TopHeader />
+  <Frame build={buildHeader} />
+  <Prose>
+    <p>
+      OOKNET is a personal knowledge base k
ept the way records used to
+      be kept: as numbered technical sheets
, filed under a taxonomy,
+      printed on an endless roll of tractor
-feed paper. Notes get a
+      number, a status, and a place in the 
filing system; the archive
+      grows from the bottom of the index.
+    </p>
+    <p>
+      The site is also its own subject. Eve
ry frame on these pages is a
+      string composed at build time on a st
rict monospace grid - no
+      client-side rendering, no images of b
oxes, just characters. A
+      validator walks every line at compile
 time and refuses to ship a
+      bent border. The whole apparatus is d
ocumented on the style sheet
+      at note N0 and demonstrated on the co
mponents page.
+    </p>
+  </Prose>
+  <Frame build={buildColophon} />
+  <Pre>{" "}</Pre>
+  <ArticleFooter page={4} />
+</ArticleLayout>
 

diff --git a/ooknet-design/src/pages/projects.astro b/ooknet-design/src/pages/proj
ects.astro
new file mode 100644
index 0000000..78b36b1
--- /dev/null
+++ b/ooknet-design/src/pages/projects.astro
@@ -0,0 +1,33 @@
+---
+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 Prose from "../components/Prose/Prose.astro";
+import Table from "../components/Table/Table.astro";
+import ArticleFooter from "../components/ArticleFooter/ArticleFooter.astro";
+import { row2, ruleD } from "../lib/ascii";
+
+const buildHeader = (w: number) =>
+  [row2("PROJECT LEDGER", "DEPT. OF OOKS", w), ruleD(w)].join("\n");
+
+const PROJECTS = [
+  ["OOKNET-DESIGN", "this site and the ascii design system behind it", "ACTIVE"],
+  ["OOKNET-KB", "the notes archive: numbered technical sheets", "ONGOING"],
+  ["NIX-FIELD-GUIDE", "climbing the NixOS mountain without a sherpa", "DRAFT"],
+];
+---
+<ArticleLayout title="OOKNET KB - Projects">
+  <TopHeader />
+  <Frame build={buildHeader} />
+  <Pre>{" "}</Pre>
+  <Table head={["PROJECT", "DESCRIPTION", "STATUS"]} rows={PROJECTS} />
+  <Prose>
+    <p>
+      Entries move DRAFT ? ACTIVE ? ONGOING as they stabilise. Completed
+      work is retired into the archive as numbered notes rather than
+      kept on the ledger.
+    </p>
+  </Prose>
+  <ArticleFooter page={2} />
+</ArticleLayout>

diff --git a/ooknet-design/src/pages/project
s.astro b/ooknet-design/src/pages/projects.a
stro
new file mode 100644
index 0000000..78b36b1
--- /dev/null
+++ b/ooknet-design/src/pages/projects.astro
@@ -0,0 +1,33 @@
+---
+import ArticleLayout from "../layouts/Artic
leLayout/ArticleLayout.astro";
+import TopHeader from "../components/TopHea
der/TopHeader.astro";
+import Frame from "../components/Frame/Fram
e.astro";
+import Pre from "../components/Pre/Pre.astr
o";
+import Prose from "../components/Prose/Pros
e.astro";
+import Table from "../components/Table/Tabl
e.astro";
+import ArticleFooter from "../components/Ar
ticleFooter/ArticleFooter.astro";
+import { row2, ruleD } from "../lib/ascii";
+
+const buildHeader = (w: number) =>
+  [row2("PROJECT LEDGER", "DEPT. OF OOKS", 
w), ruleD(w)].join("\n");
+
+const PROJECTS = [
+  ["OOKNET-DESIGN", "this site and the asci
i design system behind it", "ACTIVE"],
+  ["OOKNET-KB", "the notes archive: numbere
d technical sheets", "ONGOING"],
+  ["NIX-FIELD-GUIDE", "climbing the NixOS m
ountain without a sherpa", "DRAFT"],
+];
+---
+<ArticleLayout title="OOKNET KB - Projects"
>
+  <TopHeader />
+  <Frame build={buildHeader} />
+  <Pre>{" "}</Pre>
+  <Table head={["PROJECT", "DESCRIPTION", "
STATUS"]} rows={PROJECTS} />
+  <Prose>
+    <p>
+      Entries move DRAFT ? ACTIVE ? ONGOING
 as they stabilise. Completed
+      work is retired into the archive as n
umbered notes rather than
+      kept on the ledger.
+    </p>
+  </Prose>
+  <ArticleFooter page={2} />
+</ArticleLayout>
 

diff --git a/ooknet-design/src/pages/resources.astro b/ooknet-design/src/pages/res
ources.astro
new file mode 100644
index 0000000..da0d82e
--- /dev/null
+++ b/ooknet-design/src/pages/resources.astro
@@ -0,0 +1,43 @@
+---
+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 Badge from "../components/Badge/Badge.astro";
+import ArticleFooter from "../components/ArticleFooter/ArticleFooter.astro";
+import { pad, row2, ruleD } from "../lib/ascii";
+import { byNoteNum, filedTree, published } from "../lib/notes";
+
+const buildSection = (title: string, right: string) => (w: number) =>
+  [row2(title, right, w), ruleD(w)].join("\n");
+
+const notes = (await getCollection("notes", published)).sort(byNoteNum);
+const taxonomy = filedTree(
+  notes.map((n) => ({ id: n.id, note: n.data.note, filed: n.data.filed })),
+);
+
+const TOOLING: [string, string, string][] = [
+  ["FRAMEWORK", "ASTRO.BUILD", "https://astro.build"],
+  ["FRAME FONT", "CASCADIA-MONO", "https://github.com/microsoft/cascadia-code"],
+  ["BODY FONT", "IBM-PLEX-MONO", "https://github.com/IBM/plex"],
+  ["DIAGRAMS", "MERMAID-ASCII", "https://github.com/AlexanderGrooff/mermaid-ascii
"],
+  ["SEARCH", "FUSE.JS", "https://www.fusejs.io"],
+];
+---
+<ArticleLayout title="OOKNET KB - Resources">
+  <TopHeader />
+  <Frame build={buildSection("FILING TAXONOMY", `${notes.length} SHEETS`)} />
+  <Pre>{" "}</Pre>
+  <Tree nodes={taxonomy} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={buildSection("TOOLING", "EXTERNAL")} />
+  <Pre>{" "}</Pre>
+  {TOOLING.map(([label, name, href]) => (
+    <Pre>{pad(label, 12)}<Badge label={name} href={href} /></Pre>
+  ))}
+  <Pre>{" "}</Pre>
+  <ArticleFooter page={3} />
+</ArticleLayout>

diff --git a/ooknet-design/src/pages/resourc
es.astro b/ooknet-design/src/pages/resources
.astro
new file mode 100644
index 0000000..da0d82e
--- /dev/null
+++ b/ooknet-design/src/pages/resources.astr
o
@@ -0,0 +1,43 @@
+---
+import { getCollection } from "astro:conten
t";
+import ArticleLayout from "../layouts/Artic
leLayout/ArticleLayout.astro";
+import TopHeader from "../components/TopHea
der/TopHeader.astro";
+import Frame from "../components/Frame/Fram
e.astro";
+import Pre from "../components/Pre/Pre.astr
o";
+import Tree from "../components/Tree/Tree.a
stro";
+import Badge from "../components/Badge/Badg
e.astro";
+import ArticleFooter from "../components/Ar
ticleFooter/ArticleFooter.astro";
+import { pad, row2, ruleD } from "../lib/as
cii";
+import { byNoteNum, filedTree, published } 
from "../lib/notes";
+
+const buildSection = (title: string, right:
 string) => (w: number) =>
+  [row2(title, right, w), ruleD(w)].join("\
n");
+
+const notes = (await getCollection("notes",
 published)).sort(byNoteNum);
+const taxonomy = filedTree(
+  notes.map((n) => ({ id: n.id, note: n.dat
a.note, filed: n.data.filed })),
+);
+
+const TOOLING: [string, string, string][] =
 [
+  ["FRAMEWORK", "ASTRO.BUILD", "https://ast
ro.build"],
+  ["FRAME FONT", "CASCADIA-MONO", "https://
github.com/microsoft/cascadia-code"],
+  ["BODY FONT", "IBM-PLEX-MONO", "https://g
ithub.com/IBM/plex"],
+  ["DIAGRAMS", "MERMAID-ASCII", "https://gi
thub.com/AlexanderGrooff/mermaid-ascii"],
+  ["SEARCH", "FUSE.JS", "https://www.fusejs
.io"],
+];
+---
+<ArticleLayout title="OOKNET KB - Resources
">
+  <TopHeader />
+  <Frame build={buildSection("FILING TAXONO
MY", `${notes.length} SHEETS`)} />
+  <Pre>{" "}</Pre>
+  <Tree nodes={taxonomy} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={buildSection("TOOLING", "EX
TERNAL")} />
+  <Pre>{" "}</Pre>
+  {TOOLING.map(([label, name, href]) => (
+    <Pre>{pad(label, 12)}<Badge label={name
} href={href} /></Pre>
+  ))}
+  <Pre>{" "}</Pre>
+  <ArticleFooter page={3} />
+</ArticleLayout>
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET