OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      e6c9be6188fd
DATE      2026-07-17
SUBJECT   web: docs sidebar, margin index card with in-flow accordion fallback
FILES     8 CHANGED
HASH      e6c9be6188fd
DATE      2026-07-17
SUBJECT   web: docs sidebar, margin index card
          with in-flow accordion fallback
FILES     8 CHANGED
 

diff --git a/ooknet-design/src/components/DocsNav/DocsNav.astro b/ooknet-design/sr
c/components/DocsNav/DocsNav.astro
new file mode 100644
index 0000000..f0465e6
--- /dev/null
+++ b/ooknet-design/src/components/DocsNav/DocsNav.astro
@@ -0,0 +1,30 @@
+---
+// Docs navigation, rendered twice from one tree: a fixed index card in
+// the backdrop margin when the viewport has room beside the paper, and
+// a collapsed accordion in the content flow everywhere else.
+import Pre from "../Pre/Pre.astro";
+import Tree from "../Tree/Tree.astro";
+import Accordion from "../Accordion/Accordion.astro";
+import { layoutTree } from "../Tree/Tree";
+import { len, rule } from "../../lib/ascii";
+import { docsTree, type DocEntry } from "../../lib/docs";
+import "./DocsNav.scss";
+
+interface Props {
+  docs: DocEntry[];
+  currentId?: string;
+}
+const { docs, currentId } = Astro.props as Props;
+const nodes = docsTree(docs, currentId);
+const cardCells = Math.max(12, ...layoutTree(nodes).map((r) => len(r.prefix) + le
n(r.label)));
+---
+<aside class="docs-nav-card" aria-label="Documentation index" style={`--card-cell
s: ${cardCells}`}>
+  <Pre class="docs-nav-title">INDEX</Pre>
+  <Pre class="docs-nav-rule">{rule(cardCells)}</Pre>
+  <Tree nodes={nodes} />
+</aside>
+<div class="docs-nav-inline">
+  <Accordion label="DOC INDEX">
+    <Tree nodes={nodes} />
+  </Accordion>
+</div>

diff --git a/ooknet-design/src/components/Do
csNav/DocsNav.astro b/ooknet-design/src/comp
onents/DocsNav/DocsNav.astro
new file mode 100644
index 0000000..f0465e6
--- /dev/null
+++ b/ooknet-design/src/components/DocsNav/D
ocsNav.astro
@@ -0,0 +1,30 @@
+---
+// Docs navigation, rendered twice from one
 tree: a fixed index card in
+// the backdrop margin when the viewport ha
s room beside the paper, and
+// a collapsed accordion in the content flo
w everywhere else.
+import Pre from "../Pre/Pre.astro";
+import Tree from "../Tree/Tree.astro";
+import Accordion from "../Accordion/Accordi
on.astro";
+import { layoutTree } from "../Tree/Tree";
+import { len, rule } from "../../lib/ascii"
;
+import { docsTree, type DocEntry } from "..
/../lib/docs";
+import "./DocsNav.scss";
+
+interface Props {
+  docs: DocEntry[];
+  currentId?: string;
+}
+const { docs, currentId } = Astro.props as 
Props;
+const nodes = docsTree(docs, currentId);
+const cardCells = Math.max(12, ...layoutTre
e(nodes).map((r) => len(r.prefix) + len(r.la
bel)));
+---
+<aside class="docs-nav-card" aria-label="Do
cumentation index" style={`--card-cells: ${c
ardCells}`}>
+  <Pre class="docs-nav-title">INDEX</Pre>
+  <Pre class="docs-nav-rule">{rule(cardCell
s)}</Pre>
+  <Tree nodes={nodes} />
+</aside>
+<div class="docs-nav-inline">
+  <Accordion label="DOC INDEX">
+    <Tree nodes={nodes} />
+  </Accordion>
+</div>
 

diff --git a/ooknet-design/src/components/DocsNav/DocsNav.scss b/ooknet-design/src
/components/DocsNav/DocsNav.scss
new file mode 100644
index 0000000..5580904
--- /dev/null
+++ b/ooknet-design/src/components/DocsNav/DocsNav.scss
@@ -0,0 +1,57 @@
+// Below the breakpoint the accordion carries the nav; above it, the
+// margin card does. The card needs (100vw - page) / 2 to fit itself
+// plus a gutter, which lands around 1260px at the 13px cap.
+.docs-nav-card {
+  display: none;
+}
+
+.docs-nav-inline {
+  margin: 0.8em 0;
+
+  pre.ascii {
+    width: auto;
+  }
+}
+
+@media (min-width: 1260px) {
+  .docs-nav-inline {
+    display: none;
+  }
+
+  .docs-nav-card {
+    display: block;
+    position: fixed;
+    top: 2.15em;
+    --card-w: calc(var(--cell-w) * var(--card-cells) + 2.4em);
+    left: calc((100vw - var(--page-w)) / 2 - var(--card-w) - 1.5em);
+    width: var(--card-w);
+    max-height: calc(100vh - 4.3em);
+    overflow-y: auto;
+    padding: 1em 1.2em 1.2em;
+    background: var(--paper);
+    color: var(--ink);
+    font-size: var(--fs);
+    line-height: var(--lh);
+    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 8px 24px -12px rgba(0, 0, 0, 0.1
8);
+
+    pre.ascii {
+      width: auto;
+    }
+
+    .docs-nav-title {
+      font-weight: 700;
+    }
+
+    .docs-nav-rule {
+      margin-bottom: 0.5em;
+      color: var(--ink-soft);
+    }
+  }
+}
+
+@media print {
+  .docs-nav-card,
+  .docs-nav-inline {
+    display: none !important;
+  }
+}

diff --git a/ooknet-design/src/components/Do
csNav/DocsNav.scss b/ooknet-design/src/compo
nents/DocsNav/DocsNav.scss
new file mode 100644
index 0000000..5580904
--- /dev/null
+++ b/ooknet-design/src/components/DocsNav/D
ocsNav.scss
@@ -0,0 +1,57 @@
+// Below the breakpoint the accordion carri
es the nav; above it, the
+// margin card does. The card needs (100vw 
- page) / 2 to fit itself
+// plus a gutter, which lands around 1260px
 at the 13px cap.
+.docs-nav-card {
+  display: none;
+}
+
+.docs-nav-inline {
+  margin: 0.8em 0;
+
+  pre.ascii {
+    width: auto;
+  }
+}
+
+@media (min-width: 1260px) {
+  .docs-nav-inline {
+    display: none;
+  }
+
+  .docs-nav-card {
+    display: block;
+    position: fixed;
+    top: 2.15em;
+    --card-w: calc(var(--cell-w) * var(--ca
rd-cells) + 2.4em);
+    left: calc((100vw - var(--page-w)) / 2 
- var(--card-w) - 1.5em);
+    width: var(--card-w);
+    max-height: calc(100vh - 4.3em);
+    overflow-y: auto;
+    padding: 1em 1.2em 1.2em;
+    background: var(--paper);
+    color: var(--ink);
+    font-size: var(--fs);
+    line-height: var(--lh);
+    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.0
8), 0 8px 24px -12px rgba(0, 0, 0, 0.18);
+
+    pre.ascii {
+      width: auto;
+    }
+
+    .docs-nav-title {
+      font-weight: 700;
+    }
+
+    .docs-nav-rule {
+      margin-bottom: 0.5em;
+      color: var(--ink-soft);
+    }
+  }
+}
+
+@media print {
+  .docs-nav-card,
+  .docs-nav-inline {
+    display: none !important;
+  }
+}
 

diff --git a/ooknet-design/src/components/Tree/Tree.astro b/ooknet-design/src/comp
onents/Tree/Tree.astro
index 1dc4c54..27e3c98 100644
--- a/ooknet-design/src/components/Tree/Tree.astro
+++ b/ooknet-design/src/components/Tree/Tree.astro
@@ -7,5 +7,7 @@ const { nodes } = Astro.props as Props;
 const rows = layoutTree(nodes);
 ---
 {rows.map((r) => (
-  <Pre class="tree-row">{r.prefix}{r.href ? <a class="tree-link" href={r.href}>{r
.label}</a> : r.label}</Pre>
+  <Pre class="tree-row">{r.prefix}{r.href
+    ? <a class:list={["tree-link", { "tree-current": r.current }]} href={r.href} 
aria-current={r.current ? "page" : undefined}>{r.label}</a>
+    : r.label}</Pre>
 ))}

diff --git a/ooknet-design/src/components/Tr
ee/Tree.astro b/ooknet-design/src/components
/Tree/Tree.astro
index 1dc4c54..27e3c98 100644
--- a/ooknet-design/src/components/Tree/Tree
.astro
+++ b/ooknet-design/src/components/Tree/Tree
.astro
@@ -7,5 +7,7 @@ const { nodes } = Astro.prop
s as Props;
 const rows = layoutTree(nodes);
 ---
 {rows.map((r) => (
-  <Pre class="tree-row">{r.prefix}{r.href ?
 <a class="tree-link" href={r.href}>{r.label
}</a> : r.label}</Pre>
+  <Pre class="tree-row">{r.prefix}{r.href
+    ? <a class:list={["tree-link", { "tree-
current": r.current }]} href={r.href} aria-c
urrent={r.current ? "page" : undefined}>{r.l
abel}</a>
+    : r.label}</Pre>
 ))}
 

diff --git a/ooknet-design/src/components/Tree/Tree.scss b/ooknet-design/src/compo
nents/Tree/Tree.scss
index 38aeccf..15ee1b4 100644
--- a/ooknet-design/src/components/Tree/Tree.scss
+++ b/ooknet-design/src/components/Tree/Tree.scss
@@ -11,4 +11,10 @@
     outline: 1px dashed var(--ink);
     outline-offset: 1px;
   }
+
+  &.tree-current {
+    background: var(--ink);
+    color: var(--paper);
+    font-weight: 700;
+  }
 }

diff --git a/ooknet-design/src/components/Tr
ee/Tree.scss b/ooknet-design/src/components/
Tree/Tree.scss
index 38aeccf..15ee1b4 100644
--- a/ooknet-design/src/components/Tree/Tree
.scss
+++ b/ooknet-design/src/components/Tree/Tree
.scss
@@ -11,4 +11,10 @@
     outline: 1px dashed var(--ink);
     outline-offset: 1px;
   }
+
+  &.tree-current {
+    background: var(--ink);
+    color: var(--paper);
+    font-weight: 700;
+  }
 }
 

diff --git a/ooknet-design/src/components/Tree/Tree.ts b/ooknet-design/src/compone
nts/Tree/Tree.ts
index 07be091..3dad05c 100644
--- a/ooknet-design/src/components/Tree/Tree.ts
+++ b/ooknet-design/src/components/Tree/Tree.ts
@@ -2,12 +2,15 @@ export interface TreeNode {
   label: string;
   href?: string;
   children?: TreeNode[];
+  /** Marks the row for the page being viewed. */
+  current?: boolean;
 }
 
 export interface TreeRow {
   prefix: string;
   label: string;
   href?: string;
+  current?: boolean;
 }
 
 export interface Props {
@@ -24,6 +27,7 @@ export function layoutTree(nodes: TreeNode[]): TreeRow[] {
         prefix: depth === 0 ? "" : prefix + (last ? "└── " : "├── "),
         label: n.label,
         href: n.href,
+        current: n.current,
       });
       if (n.children?.length) {
         walk(n.children, depth === 0 ? "" : prefix + (last ? "    " : "│   "), de
pth + 1);

diff --git a/ooknet-design/src/components/Tr
ee/Tree.ts b/ooknet-design/src/components/Tr
ee/Tree.ts
index 07be091..3dad05c 100644
--- a/ooknet-design/src/components/Tree/Tree
.ts
+++ b/ooknet-design/src/components/Tree/Tree
.ts
@@ -2,12 +2,15 @@ export interface TreeNode 
{
   label: string;
   href?: string;
   children?: TreeNode[];
+  /** Marks the row for the page being view
ed. */
+  current?: boolean;
 }
 
 export interface TreeRow {
   prefix: string;
   label: string;
   href?: string;
+  current?: boolean;
 }
 
 export interface Props {
@@ -24,6 +27,7 @@ export function layoutTree
(nodes: TreeNode[]): TreeRow[] {
         prefix: depth === 0 ? "" : prefix +
 (last ? "└── " : "├── "),
         label: n.label,
         href: n.href,
+        current: n.current,
       });
       if (n.children?.length) {
         walk(n.children, depth === 0 ? "" :
 prefix + (last ? "    " : "│   "), depth + 
1);
 

diff --git a/ooknet-design/src/lib/docs.test.ts b/ooknet-design/src/lib/docs.test.
ts
index 78c6460..296188f 100644
--- a/ooknet-design/src/lib/docs.test.ts
+++ b/ooknet-design/src/lib/docs.test.ts
@@ -34,4 +34,11 @@ describe("docsTree", () => {
     expect(design.children!.map((n) => n.label)).toEqual(["FRAMES", "PIPELINE", "
ZETA"]);
     expect(design.children![0].href).toBe("/docs/design/frames/");
   });
+
+  it("marks the current page", () => {
+    const tree = docsTree(DOCS, "design/frames");
+    const frames = tree[1].children![0];
+    expect(frames.current).toBe(true);
+    expect(tree[0].children![0].current).toBeUndefined();
+  });
 });

diff --git a/ooknet-design/src/lib/docs.test
.ts b/ooknet-design/src/lib/docs.test.ts
index 78c6460..296188f 100644
--- a/ooknet-design/src/lib/docs.test.ts
+++ b/ooknet-design/src/lib/docs.test.ts
@@ -34,4 +34,11 @@ describe("docsTree", () =
> {
     expect(design.children!.map((n) => n.la
bel)).toEqual(["FRAMES", "PIPELINE", "ZETA"]
);
     expect(design.children![0].href).toBe("
/docs/design/frames/");
   });
+
+  it("marks the current page", () => {
+    const tree = docsTree(DOCS, "design/fra
mes");
+    const frames = tree[1].children![0];
+    expect(frames.current).toBe(true);
+    expect(tree[0].children![0].current).to
BeUndefined();
+  });
 });
 

diff --git a/ooknet-design/src/lib/docs.ts b/ooknet-design/src/lib/docs.ts
index 59ee60d..b7ff341 100644
--- a/ooknet-design/src/lib/docs.ts
+++ b/ooknet-design/src/lib/docs.ts
@@ -30,8 +30,9 @@ export function docsOrder(docs: DocEntry[]): DocEntry[] {
   });
 }
 
-/** Nav tree: branches are path segments, leaves link to the doc. */
-export function docsTree(docs: DocEntry[]): TreeNode[] {
+/** Nav tree: branches are path segments, leaves link to the doc.
+ *  `currentId` marks the page being viewed. */
+export function docsTree(docs: DocEntry[], currentId?: string): TreeNode[] {
   type Branch = { children: Map<string, Branch>; leaves: DocEntry[] };
   const root: Branch = { children: new Map(), leaves: [] };
 
@@ -49,6 +50,7 @@ export function docsTree(docs: DocEntry[]): TreeNode[] {
     ...b.leaves.map((d) => ({
       label: d.title.toUpperCase(),
       href: `/docs/${d.id}/`,
+      current: d.id === currentId || undefined,
     })),
     ...[...b.children.entries()].map(([seg, child]) => ({
       label: depth === 0 ? `/${seg.toUpperCase()}` : seg.toUpperCase(),

diff --git a/ooknet-design/src/lib/docs.ts b
/ooknet-design/src/lib/docs.ts
index 59ee60d..b7ff341 100644
--- a/ooknet-design/src/lib/docs.ts
+++ b/ooknet-design/src/lib/docs.ts
@@ -30,8 +30,9 @@ export function docsOrder(
docs: DocEntry[]): DocEntry[] {
   });
 }
 
-/** Nav tree: branches are path segments, l
eaves link to the doc. */
-export function docsTree(docs: DocEntry[]):
 TreeNode[] {
+/** Nav tree: branches are path segments, l
eaves link to the doc.
+ *  `currentId` marks the page being viewed
. */
+export function docsTree(docs: DocEntry[], 
currentId?: string): TreeNode[] {
   type Branch = { children: Map<string, Bra
nch>; leaves: DocEntry[] };
   const root: Branch = { children: new Map(
), leaves: [] };
 
@@ -49,6 +50,7 @@ export function docsTree(d
ocs: DocEntry[]): TreeNode[] {
     ...b.leaves.map((d) => ({
       label: d.title.toUpperCase(),
       href: `/docs/${d.id}/`,
+      current: d.id === currentId || undefi
ned,
     })),
     ...[...b.children.entries()].map(([seg,
 child]) => ({
       label: depth === 0 ? `/${seg.toUpperC
ase()}` : seg.toUpperCase(),
 

diff --git a/ooknet-design/src/pages/docs/[...slug].astro b/ooknet-design/src/page
s/docs/[...slug].astro
index c2292bf..c4e1332 100644
--- a/ooknet-design/src/pages/docs/[...slug].astro
+++ b/ooknet-design/src/pages/docs/[...slug].astro
@@ -9,9 +9,10 @@ import TableOfContents from "../../components/TableOfContents/Tab
leOfContents.as
 import Prose from "../../components/Prose/Prose.astro";
 import ArticlePagination from "../../components/ArticlePagination/ArticlePaginati
on.astro";
 import type { PageLink } from "../../components/ArticlePagination/ArticlePaginati
on";
+import DocsNav from "../../components/DocsNav/DocsNav.astro";
 import { box, center, len, rule, ruleD, row2, wrap } from "../../lib/ascii";
 import { published } from "../../lib/notes";
-import { docsOrder } from "../../lib/docs";
+import { docsOrder, type DocEntry } from "../../lib/docs";
 
 export async function getStaticPaths() {
   const docs = await getCollection("docs", published);
@@ -35,6 +36,7 @@ export async function getStaticPaths() {
       doc: byId.get(d.id)!,
       prev: link(ordered[i - 1]?.id),
       next: link(ordered[i + 1]?.id),
+      all: ordered,
     },
   }));
 }
@@ -43,8 +45,9 @@ interface Props {
   doc: CollectionEntry<"docs">;
   prev: PageLink | null;
   next: PageLink | null;
+  all: DocEntry[];
 }
-const { doc, prev, next } = Astro.props as Props;
+const { doc, prev, next, all } = Astro.props as Props;
 const { Content, headings } = await render(doc);
 
 const buildTitleCard = (w: number) => {
@@ -65,6 +68,7 @@ const buildTitleCard = (w: number) => {
   <Pre>{" "}</Pre>
   <Breadcrumbs filed={`/DOCS/${doc.id.toUpperCase()}`} />
   <Frame build={(w) => ruleD(w)} />
+  <DocsNav docs={all} currentId={doc.id} />
   {headings.length >= 3 && <TableOfContents headings={headings} />}
   <Prose><Content /></Prose>
   <Pre>{" "}</Pre>

diff --git a/ooknet-design/src/pages/docs/[.
..slug].astro b/ooknet-design/src/pages/docs
/[...slug].astro
index c2292bf..c4e1332 100644
--- a/ooknet-design/src/pages/docs/[...slug]
.astro
+++ b/ooknet-design/src/pages/docs/[...slug]
.astro
@@ -9,9 +9,10 @@ import TableOfContents from
 "../../components/TableOfContents/TableOfCo
ntents.as
 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 DocsNav from "../../components/DocsN
av/DocsNav.astro";
 import { box, center, len, rule, ruleD, row
2, wrap } from "../../lib/ascii";
 import { published } from "../../lib/notes"
;
-import { docsOrder } from "../../lib/docs";
+import { docsOrder, type DocEntry } from ".
./../lib/docs";
 
 export async function getStaticPaths() {
   const docs = await getCollection("docs", 
published);
@@ -35,6 +36,7 @@ export async function getS
taticPaths() {
       doc: byId.get(d.id)!,
       prev: link(ordered[i - 1]?.id),
       next: link(ordered[i + 1]?.id),
+      all: ordered,
     },
   }));
 }
@@ -43,8 +45,9 @@ interface Props {
   doc: CollectionEntry<"docs">;
   prev: PageLink | null;
   next: PageLink | null;
+  all: DocEntry[];
 }
-const { doc, prev, next } = Astro.props as 
Props;
+const { doc, prev, next, all } = Astro.prop
s as Props;
 const { Content, headings } = await render(
doc);
 
 const buildTitleCard = (w: number) => {
@@ -65,6 +68,7 @@ const buildTitleCard = (w:
 number) => {
   <Pre>{" "}</Pre>
   <Breadcrumbs filed={`/DOCS/${doc.id.toUpp
erCase()}`} />
   <Frame build={(w) => ruleD(w)} />
+  <DocsNav docs={all} currentId={doc.id} />
   {headings.length >= 3 && <TableOfContents
 headings={headings} />}
   <Prose><Content /></Prose>
   <Pre>{" "}</Pre>
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET