OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      a04c936afe8e
DATE      2026-07-17
SUBJECT   web: menu, tooltip, slider, skeleton, diff component, print styles, and
          status page
FILES     22 CHANGED
HASH      a04c936afe8e
DATE      2026-07-17
SUBJECT   web: menu, tooltip, slider, skeleton,
          diff component, print styles, and
          status page
FILES     22 CHANGED
 

diff --git a/ooknet-design/src/components/ArticlePagination/ArticlePagination.astr
o b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro
index 78e78ce..b9e557d 100644
--- a/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro
+++ b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro
@@ -15,8 +15,8 @@ const gap = (w: number) =>
   Math.max(1, w - (prevLabel ? buttonWidth(prevLabel) : 0) - (nextLabel ? buttonW
idth(nextLabel) : 0));
 ---
 {(prev || next) && (
-  <Fragment>
+  <nav class="article-pagination" aria-label="Adjacent notes">
     <Pre class="frame-wide">{prevLabel && prev && <Button label={prevLabel} href=
{`/notes/${prev.id}/`} />}{" ".repeat(gap(FRAME_W))}{nextLabel && next && <Button 
label={nextLabel} href={`/notes/${next.id}/`} />}</Pre>
     <Pre class="frame-narrow">{prevLabel && prev && <Button label={prevLabel} hre
f={`/notes/${prev.id}/`} />}{" ".repeat(gap(MOBILE_FRAME_W))}{nextLabel && next &&
 <Button label={nextLabel} href={`/notes/${next.id}/`} />}</Pre>
-  </Fragment>
+  </nav>
 )}

diff --git a/ooknet-design/src/components/Ar
ticlePagination/ArticlePagination.astro b/oo
knet-design/src/components/ArticlePagination
/ArticlePagination.astro
index 78e78ce..b9e557d 100644
--- a/ooknet-design/src/components/ArticlePa
gination/ArticlePagination.astro
+++ b/ooknet-design/src/components/ArticlePa
gination/ArticlePagination.astro
@@ -15,8 +15,8 @@ const gap = (w: number) =>
   Math.max(1, w - (prevLabel ? buttonWidth(
prevLabel) : 0) - (nextLabel ? buttonWidth(n
extLabel) : 0));
 ---
 {(prev || next) && (
-  <Fragment>
+  <nav class="article-pagination" aria-labe
l="Adjacent notes">
     <Pre class="frame-wide">{prevLabel && p
rev && <Button label={prevLabel} href={`/not
es/${prev.id}/`} />}{" ".repeat(gap(FRAME_W)
)}{nextLabel && next && <Button label={nextL
abel} href={`/notes/${next.id}/`} />}</Pre>
     <Pre class="frame-narrow">{prevLabel &&
 prev && <Button label={prevLabel} href={`/n
otes/${prev.id}/`} />}{" ".repeat(gap(MOBILE
_FRAME_W))}{nextLabel && next && <Button lab
el={nextLabel} href={`/notes/${next.id}/`} /
>}</Pre>
-  </Fragment>
+  </nav>
 )}
 

diff --git a/ooknet-design/src/components/Diff/Diff.astro b/ooknet-design/src/comp
onents/Diff/Diff.astro
new file mode 100644
index 0000000..75072ce
--- /dev/null
+++ b/ooknet-design/src/components/Diff/Diff.astro
@@ -0,0 +1,44 @@
+---
+// Emits the same markup as the ```diff pipeline output, so Prose's
+// code-block styles and the layout's copy handler apply unchanged.
+// Assembled as an HTML string: the Astro compiler can't nest expression
+// children inside a raw <pre> tag.
+import { G } from "../../lib/ascii";
+import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config";
+import { layoutDiff, type Props } from "./Diff";
+
+const { code } = Astro.props as Props;
+const raw = code.replace(/\n+$/, "");
+
+const esc = (s: string) =>
+  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+const frame = (s: string) =>
+  `<span class="code-frame" aria-hidden="true">${esc(s)}</span>`;
+
+function renderCode(w: number): string {
+  const parts: string[] = [
+    frame(G.TL + G.H + " DIFF " + G.H.repeat(w - 12 - 6)),
+    '<button type="button" class="code-copy" aria-label="Copy code">[ COPY ]</but
ton>',
+    frame(G.H + G.TR),
+    "\n",
+  ];
+  for (const r of layoutDiff(code, w)) {
+    parts.push(frame(G.V + " "));
+    parts.push(r.cls ? `<span class="${r.cls}">${esc(r.text)}</span>` : esc(r.tex
t));
+    parts.push(frame(" ".repeat(r.pad) + " " + G.V));
+    parts.push("\n");
+  }
+  parts.push(frame(G.BL + G.H.repeat(w - 2) + G.BR));
+  return `<code>${parts.join("")}</code>`;
+}
+
+const variants = [
+  { cls: "frame-wide", w: FRAME_W },
+  { cls: "frame-narrow", w: MOBILE_FRAME_W },
+];
+---
+<div class="code-frames prose">
+  {variants.map(({ cls, w }) => (
+    <pre class={"ascii code-block " + cls} data-raw-code={raw} set:html={renderCo
de(w)} />
+  ))}
+</div>

diff --git a/ooknet-design/src/components/Di
ff/Diff.astro b/ooknet-design/src/components
/Diff/Diff.astro
new file mode 100644
index 0000000..75072ce
--- /dev/null
+++ b/ooknet-design/src/components/Diff/Diff
.astro
@@ -0,0 +1,44 @@
+---
+// Emits the same markup as the ```diff pip
eline output, so Prose's
+// code-block styles and the layout's copy 
handler apply unchanged.
+// Assembled as an HTML string: the Astro c
ompiler can't nest expression
+// children inside a raw <pre> tag.
+import { G } from "../../lib/ascii";
+import { FRAME_W, MOBILE_FRAME_W } from "..
/../lib/config";
+import { layoutDiff, type Props } from "./D
iff";
+
+const { code } = Astro.props as Props;
+const raw = code.replace(/\n+$/, "");
+
+const esc = (s: string) =>
+  s.replace(/&/g, "&amp;").replace(/</g, "&
lt;").replace(/>/g, "&gt;");
+const frame = (s: string) =>
+  `<span class="code-frame" aria-hidden="tr
ue">${esc(s)}</span>`;
+
+function renderCode(w: number): string {
+  const parts: string[] = [
+    frame(G.TL + G.H + " DIFF " + G.H.repea
t(w - 12 - 6)),
+    '<button type="button" class="code-copy
" aria-label="Copy code">[ COPY ]</button>',
+    frame(G.H + G.TR),
+    "\n",
+  ];
+  for (const r of layoutDiff(code, w)) {
+    parts.push(frame(G.V + " "));
+    parts.push(r.cls ? `<span class="${r.cl
s}">${esc(r.text)}</span>` : esc(r.text));
+    parts.push(frame(" ".repeat(r.pad) + " 
" + G.V));
+    parts.push("\n");
+  }
+  parts.push(frame(G.BL + G.H.repeat(w - 2)
 + G.BR));
+  return `<code>${parts.join("")}</code>`;
+}
+
+const variants = [
+  { cls: "frame-wide", w: FRAME_W },
+  { cls: "frame-narrow", w: MOBILE_FRAME_W 
},
+];
+---
+<div class="code-frames prose">
+  {variants.map(({ cls, w }) => (
+    <pre class={"ascii code-block " + cls} 
data-raw-code={raw} set:html={renderCode(w)}
 />
+  ))}
+</div>
 

diff --git a/ooknet-design/src/components/Diff/Diff.test.ts b/ooknet-design/src/co
mponents/Diff/Diff.test.ts
new file mode 100644
index 0000000..d9bbc43
--- /dev/null
+++ b/ooknet-design/src/components/Diff/Diff.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { layoutDiff } from "./Diff";
+import { len } from "../../lib/ascii";
+
+const DIFF = "@@ -1,2 +1,2 @@\n-old line\n+new line\n context";
+
+describe("layoutDiff", () => {
+  it("classifies rows through the shared pipeline classifier", () => {
+    const rows = layoutDiff(DIFF, 48);
+    expect(rows.map((r) => r.cls)).toEqual(["diff-hunk", "diff-del", "diff-add", 
null]);
+  });
+
+  it("pads every row to the inner width", () => {
+    for (const r of layoutDiff(DIFF, 48)) {
+      expect(len(r.text) + r.pad).toBe(44);
+    }
+  });
+
+  it("keeps the class on wrapped continuation chunks", () => {
+    const rows = layoutDiff(`+${"x".repeat(100)}`, 48);
+    expect(rows.length).toBeGreaterThan(1);
+    expect(rows.every((r) => r.cls === "diff-add")).toBe(true);
+  });
+});

diff --git a/ooknet-design/src/components/Di
ff/Diff.test.ts b/ooknet-design/src/componen
ts/Diff/Diff.test.ts
new file mode 100644
index 0000000..d9bbc43
--- /dev/null
+++ b/ooknet-design/src/components/Diff/Diff
.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vites
t";
+import { layoutDiff } from "./Diff";
+import { len } from "../../lib/ascii";
+
+const DIFF = "@@ -1,2 +1,2 @@\n-old line\n+
new line\n context";
+
+describe("layoutDiff", () => {
+  it("classifies rows through the shared pi
peline classifier", () => {
+    const rows = layoutDiff(DIFF, 48);
+    expect(rows.map((r) => r.cls)).toEqual(
["diff-hunk", "diff-del", "diff-add", null])
;
+  });
+
+  it("pads every row to the inner width", (
) => {
+    for (const r of layoutDiff(DIFF, 48)) {
+      expect(len(r.text) + r.pad).toBe(44);
+    }
+  });
+
+  it("keeps the class on wrapped continuati
on chunks", () => {
+    const rows = layoutDiff(`+${"x".repeat(
100)}`, 48);
+    expect(rows.length).toBeGreaterThan(1);
+    expect(rows.every((r) => r.cls === "dif
f-add")).toBe(true);
+  });
+});
 

diff --git a/ooknet-design/src/components/Diff/Diff.ts b/ooknet-design/src/compone
nts/Diff/Diff.ts
new file mode 100644
index 0000000..48eea27
--- /dev/null
+++ b/ooknet-design/src/components/Diff/Diff.ts
@@ -0,0 +1,34 @@
+// Framed unified diff for use outside markdown - the component sibling
+// of the ```diff fence. Shares the line classifier with the pipeline.
+import { checkGrid, len } from "../../lib/ascii";
+import { diffLineClass } from "../../lib/rehype-ascii";
+
+export interface Props {
+  code: string;
+}
+
+export interface DiffRow {
+  text: string;
+  cls: string | null;
+  /** Trailing pad cells before the right border. */
+  pad: number;
+}
+
+export function layoutDiff(code: string, width: number): DiffRow[] {
+  const inner = width - 4;
+  return code
+    .replace(/\n+$/, "")
+    .split("\n")
+    .flatMap((line) => {
+      const cls = diffLineClass(line);
+      const cps = [...line];
+      const chunks = cps.length <= inner
+        ? [line]
+        : Array.from({ length: Math.ceil(cps.length / inner) }, (_, i) =>
+            cps.slice(i * inner, (i + 1) * inner).join(""));
+      return chunks.map((text) => {
+        checkGrid(text, inner, "diff row");
+        return { text, cls, pad: inner - len(text) };
+      });
+    });
+}

diff --git a/ooknet-design/src/components/Di
ff/Diff.ts b/ooknet-design/src/components/Di
ff/Diff.ts
new file mode 100644
index 0000000..48eea27
--- /dev/null
+++ b/ooknet-design/src/components/Diff/Diff
.ts
@@ -0,0 +1,34 @@
+// Framed unified diff for use outside mark
down - the component sibling
+// of the ```diff fence. Shares the line cl
assifier with the pipeline.
+import { checkGrid, len } from "../../lib/a
scii";
+import { diffLineClass } from "../../lib/re
hype-ascii";
+
+export interface Props {
+  code: string;
+}
+
+export interface DiffRow {
+  text: string;
+  cls: string | null;
+  /** Trailing pad cells before the right b
order. */
+  pad: number;
+}
+
+export function layoutDiff(code: string, wi
dth: number): DiffRow[] {
+  const inner = width - 4;
+  return code
+    .replace(/\n+$/, "")
+    .split("\n")
+    .flatMap((line) => {
+      const cls = diffLineClass(line);
+      const cps = [...line];
+      const chunks = cps.length <= inner
+        ? [line]
+        : Array.from({ length: Math.ceil(cp
s.length / inner) }, (_, i) =>
+            cps.slice(i * inner, (i + 1) * 
inner).join(""));
+      return chunks.map((text) => {
+        checkGrid(text, inner, "diff row");
+        return { text, cls, pad: inner - le
n(text) };
+      });
+    });
+}
 

diff --git a/ooknet-design/src/components/Menu/Menu.astro b/ooknet-design/src/comp
onents/Menu/Menu.astro
new file mode 100644
index 0000000..28798f8
--- /dev/null
+++ b/ooknet-design/src/components/Menu/Menu.astro
@@ -0,0 +1,81 @@
+---
+// Dropdown menu: [ LABEL ↓ ] trigger over a framed popover of actions.
+// ↓ instead of ? - it's the arrow the frame font ships (see Select).
+import Pre from "../Pre/Pre.astro";
+import { G, pad, rule, checkGrid } from "../../lib/ascii";
+import { menuCells, type Props } from "./Menu";
+import "./Menu.scss";
+
+const { label, items, cells } = Astro.props as Props;
+const inner = cells ?? menuCells(items);
+const ctx = `Menu(${label})`;
+checkGrid(items.map((it) => it.label).join("\n"), inner, ctx);
+---
+<span class="menu">
+  <button type="button" class="menu-trigger" aria-haspopup="menu" aria-expanded="
false">[ {label} ↓ ]</button>
+  <div class="menu-pop" role="menu" hidden>
+    <Pre>{G.TL}{rule(inner + 2)}{G.TR}</Pre>
+    {items.map((it) => (
+      <Pre>{G.V}{" "}{it.href
+        ? <a class="menu-item" role="menuitem" href={it.href}>{pad(it.label, inne
r)}</a>
+        : <button type="button" class="menu-item" role="menuitem">{pad(it.label, 
inner)}</button>
+      }{" "}{G.V}</Pre>
+    ))}
+    <Pre>{G.BL}{rule(inner + 2)}{G.BR}</Pre>
+  </div>
+</span>
+
+<script>
+  function close(menu: HTMLElement) {
+    menu.querySelector<HTMLElement>(".menu-pop")!.hidden = true;
+    menu.querySelector(".menu-trigger")!.setAttribute("aria-expanded", "false");
+  }
+
+  function closeAll(except?: HTMLElement) {
+    document.querySelectorAll<HTMLElement>(".menu").forEach((m) => {
+      if (m !== except) close(m);
+    });
+  }
+
+  document.addEventListener("click", (e) => {
+    const target = e.target as HTMLElement;
+    const trigger = target.closest<HTMLElement>(".menu-trigger");
+    if (trigger) {
+      const menu = trigger.closest<HTMLElement>(".menu")!;
+      const pop = menu.querySelector<HTMLElement>(".menu-pop")!;
+      closeAll(menu);
+      pop.hidden = !pop.hidden;
+      trigger.setAttribute("aria-expanded", String(!pop.hidden));
+      if (!pop.hidden) pop.querySelector<HTMLElement>(".menu-item")?.focus();
+      return;
+    }
+    if (target.closest(".menu-item")) {
+      const menu = target.closest<HTMLElement>(".menu");
+      if (menu) close(menu);
+      return;
+    }
+    closeAll();
+  });
+
+  document.addEventListener("keydown", (e) => {
+    const menu = (e.target as HTMLElement).closest?.<HTMLElement>(".menu");
+    if (!menu) return;
+    const pop = menu.querySelector<HTMLElement>(".menu-pop")!;
+    if (pop.hidden) return;
+
+    if (e.key === "Escape") {
+      e.preventDefault();
+      close(menu);
+      menu.querySelector<HTMLElement>(".menu-trigger")?.focus();
+      return;
+    }
+    if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
+    e.preventDefault();
+    const menuItems = Array.from(pop.querySelectorAll<HTMLElement>(".menu-item"))
;
+    const idx = menuItems.indexOf(document.activeElement as HTMLElement);
+    const next = e.key === "ArrowDown"
+      ? Math.min(menuItems.length - 1, idx + 1)
+      : Math.max(0, idx - 1);
+    menuItems[next]?.focus();
+  });
+</script>

diff --git a/ooknet-design/src/components/Me
nu/Menu.astro b/ooknet-design/src/components
/Menu/Menu.astro
new file mode 100644
index 0000000..28798f8
--- /dev/null
+++ b/ooknet-design/src/components/Menu/Menu
.astro
@@ -0,0 +1,81 @@
+---
+// Dropdown menu: [ LABEL ↓ ] trigger over 
a framed popover of actions.
+// ↓ instead of ? - it's the arrow the fram
e font ships (see Select).
+import Pre from "../Pre/Pre.astro";
+import { G, pad, rule, checkGrid } from "..
/../lib/ascii";
+import { menuCells, type Props } from "./Me
nu";
+import "./Menu.scss";
+
+const { label, items, cells } = Astro.props
 as Props;
+const inner = cells ?? menuCells(items);
+const ctx = `Menu(${label})`;
+checkGrid(items.map((it) => it.label).join(
"\n"), inner, ctx);
+---
+<span class="menu">
+  <button type="button" class="menu-trigger
" aria-haspopup="menu" aria-expanded="false"
>[ {label} ↓ ]</button>
+  <div class="menu-pop" role="menu" hidden>
+    <Pre>{G.TL}{rule(inner + 2)}{G.TR}</Pre
>
+    {items.map((it) => (
+      <Pre>{G.V}{" "}{it.href
+        ? <a class="menu-item" role="menuit
em" href={it.href}>{pad(it.label, inner)}</a
>
+        : <button type="button" class="menu
-item" role="menuitem">{pad(it.label, inner)
}</button>
+      }{" "}{G.V}</Pre>
+    ))}
+    <Pre>{G.BL}{rule(inner + 2)}{G.BR}</Pre
>
+  </div>
+</span>
+
+<script>
+  function close(menu: HTMLElement) {
+    menu.querySelector<HTMLElement>(".menu-
pop")!.hidden = true;
+    menu.querySelector(".menu-trigger")!.se
tAttribute("aria-expanded", "false");
+  }
+
+  function closeAll(except?: HTMLElement) {
+    document.querySelectorAll<HTMLElement>(
".menu").forEach((m) => {
+      if (m !== except) close(m);
+    });
+  }
+
+  document.addEventListener("click", (e) =>
 {
+    const target = e.target as HTMLElement;
+    const trigger = target.closest<HTMLElem
ent>(".menu-trigger");
+    if (trigger) {
+      const menu = trigger.closest<HTMLElem
ent>(".menu")!;
+      const pop = menu.querySelector<HTMLEl
ement>(".menu-pop")!;
+      closeAll(menu);
+      pop.hidden = !pop.hidden;
+      trigger.setAttribute("aria-expanded",
 String(!pop.hidden));
+      if (!pop.hidden) pop.querySelector<HT
MLElement>(".menu-item")?.focus();
+      return;
+    }
+    if (target.closest(".menu-item")) {
+      const menu = target.closest<HTMLEleme
nt>(".menu");
+      if (menu) close(menu);
+      return;
+    }
+    closeAll();
+  });
+
+  document.addEventListener("keydown", (e) 
=> {
+    const menu = (e.target as HTMLElement).
closest?.<HTMLElement>(".menu");
+    if (!menu) return;
+    const pop = menu.querySelector<HTMLElem
ent>(".menu-pop")!;
+    if (pop.hidden) return;
+
+    if (e.key === "Escape") {
+      e.preventDefault();
+      close(menu);
+      menu.querySelector<HTMLElement>(".men
u-trigger")?.focus();
+      return;
+    }
+    if (e.key !== "ArrowDown" && e.key !== 
"ArrowUp") return;
+    e.preventDefault();
+    const menuItems = Array.from(pop.queryS
electorAll<HTMLElement>(".menu-item"));
+    const idx = menuItems.indexOf(document.
activeElement as HTMLElement);
+    const next = e.key === "ArrowDown"
+      ? Math.min(menuItems.length - 1, idx 
+ 1)
+      : Math.max(0, idx - 1);
+    menuItems[next]?.focus();
+  });
+</script>
 

diff --git a/ooknet-design/src/components/Menu/Menu.scss b/ooknet-design/src/compo
nents/Menu/Menu.scss
new file mode 100644
index 0000000..b903642
--- /dev/null
+++ b/ooknet-design/src/components/Menu/Menu.scss
@@ -0,0 +1,65 @@
+.menu {
+  position: relative;
+  display: inline-block;
+
+  // Not on .menu itself: template newlines between the popover's rows
+  // would render as blank lines inside the box.
+  .menu-trigger {
+    font: inherit;
+    color: var(--ink);
+    background: none;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: pointer;
+    white-space: pre;
+
+    &:hover {
+      text-decoration: underline;
+      text-underline-offset: 3px;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed var(--ink);
+      outline-offset: 1px;
+    }
+
+    &[aria-expanded="true"] {
+      font-weight: 700;
+      background: var(--ink);
+      color: var(--paper);
+    }
+  }
+
+  .menu-pop {
+    position: absolute;
+    top: 100%;
+    left: 0;
+    z-index: 10;
+    background: var(--paper);
+    box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.3);
+
+    pre.ascii {
+      width: auto;
+    }
+  }
+
+  .menu-item {
+    font: inherit;
+    color: var(--ink);
+    background: none;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: pointer;
+    white-space: pre;
+    text-decoration: none;
+
+    &:hover,
+    &:focus-visible {
+      outline: 0;
+      background: var(--ink);
+      color: var(--paper);
+    }
+  }
+}

diff --git a/ooknet-design/src/components/Me
nu/Menu.scss b/ooknet-design/src/components/
Menu/Menu.scss
new file mode 100644
index 0000000..b903642
--- /dev/null
+++ b/ooknet-design/src/components/Menu/Menu
.scss
@@ -0,0 +1,65 @@
+.menu {
+  position: relative;
+  display: inline-block;
+
+  // Not on .menu itself: template newlines
 between the popover's rows
+  // would render as blank lines inside the
 box.
+  .menu-trigger {
+    font: inherit;
+    color: var(--ink);
+    background: none;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: pointer;
+    white-space: pre;
+
+    &:hover {
+      text-decoration: underline;
+      text-underline-offset: 3px;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed var(--ink);
+      outline-offset: 1px;
+    }
+
+    &[aria-expanded="true"] {
+      font-weight: 700;
+      background: var(--ink);
+      color: var(--paper);
+    }
+  }
+
+  .menu-pop {
+    position: absolute;
+    top: 100%;
+    left: 0;
+    z-index: 10;
+    background: var(--paper);
+    box-shadow: 0 8px 24px -12px rgba(0, 0,
 0, 0.3);
+
+    pre.ascii {
+      width: auto;
+    }
+  }
+
+  .menu-item {
+    font: inherit;
+    color: var(--ink);
+    background: none;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: pointer;
+    white-space: pre;
+    text-decoration: none;
+
+    &:hover,
+    &:focus-visible {
+      outline: 0;
+      background: var(--ink);
+      color: var(--paper);
+    }
+  }
+}
 

diff --git a/ooknet-design/src/components/Menu/Menu.ts b/ooknet-design/src/compone
nts/Menu/Menu.ts
new file mode 100644
index 0000000..6aa88b7
--- /dev/null
+++ b/ooknet-design/src/components/Menu/Menu.ts
@@ -0,0 +1,16 @@
+import { len } from "../../lib/ascii";
+
+export interface MenuItem {
+  label: string;
+  href?: string;
+}
+
+export interface Props {
+  label: string;
+  items: MenuItem[];
+  /** Inner width in cells; defaults to the widest item label. */
+  cells?: number;
+}
+
+export const menuCells = (items: MenuItem[]) =>
+  Math.max(1, ...items.map((it) => len(it.label)));

diff --git a/ooknet-design/src/components/Me
nu/Menu.ts b/ooknet-design/src/components/Me
nu/Menu.ts
new file mode 100644
index 0000000..6aa88b7
--- /dev/null
+++ b/ooknet-design/src/components/Menu/Menu
.ts
@@ -0,0 +1,16 @@
+import { len } from "../../lib/ascii";
+
+export interface MenuItem {
+  label: string;
+  href?: string;
+}
+
+export interface Props {
+  label: string;
+  items: MenuItem[];
+  /** Inner width in cells; defaults to the
 widest item label. */
+  cells?: number;
+}
+
+export const menuCells = (items: MenuItem[]
) =>
+  Math.max(1, ...items.map((it) => len(it.l
abel)));
 

diff --git a/ooknet-design/src/components/Skeleton/Skeleton.astro b/ooknet-design/
src/components/Skeleton/Skeleton.astro
new file mode 100644
index 0000000..80bdead
--- /dev/null
+++ b/ooknet-design/src/components/Skeleton/Skeleton.astro
@@ -0,0 +1,12 @@
+---
+// Loading placeholder: pulsing rows of ░. Static-site pages rarely
+// need it; client-fetched panels do.
+import Pre from "../Pre/Pre.astro";
+import type { Props } from "./Skeleton";
+import "./Skeleton.scss";
+
+const { rows = [24, 18, 21] } = Astro.props as Props;
+---
+<div class="skeleton" aria-hidden="true">
+  {rows.map((w) => <Pre class="skeleton-row">{"░".repeat(w)}</Pre>)}
+</div>

diff --git a/ooknet-design/src/components/Sk
eleton/Skeleton.astro b/ooknet-design/src/co
mponents/Skeleton/Skeleton.astro
new file mode 100644
index 0000000..80bdead
--- /dev/null
+++ b/ooknet-design/src/components/Skeleton/
Skeleton.astro
@@ -0,0 +1,12 @@
+---
+// Loading placeholder: pulsing rows of ░. 
Static-site pages rarely
+// need it; client-fetched panels do.
+import Pre from "../Pre/Pre.astro";
+import type { Props } from "./Skeleton";
+import "./Skeleton.scss";
+
+const { rows = [24, 18, 21] } = Astro.props
 as Props;
+---
+<div class="skeleton" aria-hidden="true">
+  {rows.map((w) => <Pre class="skeleton-row
">{"░".repeat(w)}</Pre>)}
+</div>
 

diff --git a/ooknet-design/src/components/Skeleton/Skeleton.scss b/ooknet-design/s
rc/components/Skeleton/Skeleton.scss
new file mode 100644
index 0000000..675b512
--- /dev/null
+++ b/ooknet-design/src/components/Skeleton/Skeleton.scss
@@ -0,0 +1,18 @@
+.skeleton {
+  .skeleton-row {
+    color: var(--ink-soft);
+    animation: skeleton-pulse 1.4s ease-in-out infinite;
+  }
+
+  @media (prefers-reduced-motion: reduce) {
+    .skeleton-row {
+      animation: none;
+      opacity: 0.6;
+    }
+  }
+}
+
+@keyframes skeleton-pulse {
+  0%, 100% { opacity: 0.35; }
+  50% { opacity: 0.8; }
+}

diff --git a/ooknet-design/src/components/Sk
eleton/Skeleton.scss b/ooknet-design/src/com
ponents/Skeleton/Skeleton.scss
new file mode 100644
index 0000000..675b512
--- /dev/null
+++ b/ooknet-design/src/components/Skeleton/
Skeleton.scss
@@ -0,0 +1,18 @@
+.skeleton {
+  .skeleton-row {
+    color: var(--ink-soft);
+    animation: skeleton-pulse 1.4s ease-in-
out infinite;
+  }
+
+  @media (prefers-reduced-motion: reduce) {
+    .skeleton-row {
+      animation: none;
+      opacity: 0.6;
+    }
+  }
+}
+
+@keyframes skeleton-pulse {
+  0%, 100% { opacity: 0.35; }
+  50% { opacity: 0.8; }
+}
 

diff --git a/ooknet-design/src/components/Skeleton/Skeleton.ts b/ooknet-design/src
/components/Skeleton/Skeleton.ts
new file mode 100644
index 0000000..aa08145
--- /dev/null
+++ b/ooknet-design/src/components/Skeleton/Skeleton.ts
@@ -0,0 +1,4 @@
+export interface Props {
+  /** Cell width of each placeholder row. */
+  rows?: number[];
+}

diff --git a/ooknet-design/src/components/Sk
eleton/Skeleton.ts b/ooknet-design/src/compo
nents/Skeleton/Skeleton.ts
new file mode 100644
index 0000000..aa08145
--- /dev/null
+++ b/ooknet-design/src/components/Skeleton/
Skeleton.ts
@@ -0,0 +1,4 @@
+export interface Props {
+  /** Cell width of each placeholder row. *
/
+  rows?: number[];
+}
 

diff --git a/ooknet-design/src/components/Slider/Slider.astro b/ooknet-design/src/
components/Slider/Slider.astro
new file mode 100644
index 0000000..6181f0f
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Slider.astro
@@ -0,0 +1,32 @@
+---
+// Native range input overlaid on an ASCII track; the script re-renders
+// the track text on input.
+import { buildSliderTrack, type Props } from "./Slider";
+import "./Slider.scss";
+
+const { name, value = 50, min = 0, max = 100, step = 1, cells = 20, showValue = t
rue } = Astro.props as Props;
+---
+<span class="slider" style={`--slider-cells: ${cells}`}><span class="slider-track
" aria-hidden="true" data-cells={cells}>{buildSliderTrack(value, min, max, cells)}
</span><input
+  type="range"
+  name={name}
+  value={value}
+  min={min}
+  max={max}
+  step={step}
+  aria-label={name}
+/>{showValue && <span class="slider-value">{" " + String(value).padStart(3)}</spa
n>}</span>
+
+<script>
+  import { buildSliderTrack } from "./Slider";
+
+  document.addEventListener("input", (e) => {
+    const input = (e.target as HTMLElement).closest?.<HTMLInputElement>(".slider 
input[type=range]");
+    if (!input) return;
+    const slider = input.closest(".slider")!;
+    const track = slider.querySelector<HTMLElement>(".slider-track")!;
+    const cells = Number(track.dataset.cells) || 20;
+    track.textContent = buildSliderTrack(Number(input.value), Number(input.min), 
Number(input.max), cells);
+    const readout = slider.querySelector(".slider-value");
+    if (readout) readout.textContent = " " + String(input.value).padStart(3);
+  });
+</script>

diff --git a/ooknet-design/src/components/Sl
ider/Slider.astro b/ooknet-design/src/compon
ents/Slider/Slider.astro
new file mode 100644
index 0000000..6181f0f
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Sl
ider.astro
@@ -0,0 +1,32 @@
+---
+// Native range input overlaid on an ASCII 
track; the script re-renders
+// the track text on input.
+import { buildSliderTrack, type Props } fro
m "./Slider";
+import "./Slider.scss";
+
+const { name, value = 50, min = 0, max = 10
0, step = 1, cells = 20, showValue = true } 
= Astro.props as Props;
+---
+<span class="slider" style={`--slider-cells
: ${cells}`}><span class="slider-track" aria
-hidden="true" data-cells={cells}>{buildSlid
erTrack(value, min, max, cells)}</span><inpu
t
+  type="range"
+  name={name}
+  value={value}
+  min={min}
+  max={max}
+  step={step}
+  aria-label={name}
+/>{showValue && <span class="slider-value">
{" " + String(value).padStart(3)}</span>}</s
pan>
+
+<script>
+  import { buildSliderTrack } from "./Slide
r";
+
+  document.addEventListener("input", (e) =>
 {
+    const input = (e.target as HTMLElement)
.closest?.<HTMLInputElement>(".slider input[
type=range]");
+    if (!input) return;
+    const slider = input.closest(".slider")
!;
+    const track = slider.querySelector<HTML
Element>(".slider-track")!;
+    const cells = Number(track.dataset.cell
s) || 20;
+    track.textContent = buildSliderTrack(Nu
mber(input.value), Number(input.min), Number
(input.max), cells);
+    const readout = slider.querySelector(".
slider-value");
+    if (readout) readout.textContent = " " 
+ String(input.value).padStart(3);
+  });
+</script>
 

diff --git a/ooknet-design/src/components/Slider/Slider.scss b/ooknet-design/src/c
omponents/Slider/Slider.scss
new file mode 100644
index 0000000..b908760
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Slider.scss
@@ -0,0 +1,29 @@
+.slider {
+  position: relative;
+  white-space: pre;
+
+  .slider-track {
+    font-family: var(--mono-frame);
+  }
+
+  // The native input sits invisibly over the track for interaction.
+  input[type="range"] {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: calc(var(--cell-w) * var(--slider-cells));
+    height: 100%;
+    margin: 0;
+    opacity: 0;
+    cursor: pointer;
+  }
+
+  &:has(input:focus-visible) .slider-track {
+    outline: 1px dashed var(--ink);
+    outline-offset: 2px;
+  }
+
+  .slider-value {
+    font-variant-numeric: tabular-nums;
+  }
+}

diff --git a/ooknet-design/src/components/Sl
ider/Slider.scss b/ooknet-design/src/compone
nts/Slider/Slider.scss
new file mode 100644
index 0000000..b908760
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Sl
ider.scss
@@ -0,0 +1,29 @@
+.slider {
+  position: relative;
+  white-space: pre;
+
+  .slider-track {
+    font-family: var(--mono-frame);
+  }
+
+  // The native input sits invisibly over t
he track for interaction.
+  input[type="range"] {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: calc(var(--cell-w) * var(--slide
r-cells));
+    height: 100%;
+    margin: 0;
+    opacity: 0;
+    cursor: pointer;
+  }
+
+  &:has(input:focus-visible) .slider-track 
{
+    outline: 1px dashed var(--ink);
+    outline-offset: 2px;
+  }
+
+  .slider-value {
+    font-variant-numeric: tabular-nums;
+  }
+}
 

diff --git a/ooknet-design/src/components/Slider/Slider.test.ts b/ooknet-design/sr
c/components/Slider/Slider.test.ts
new file mode 100644
index 0000000..acfe790
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Slider.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { buildSliderTrack } from "./Slider";
+import { len } from "../../lib/ascii";
+
+describe("buildSliderTrack", () => {
+  it("renders the exact cell width", () => {
+    expect(len(buildSliderTrack(50, 0, 100, 20))).toBe(20);
+  });
+
+  it("pins the thumb to the ends at min and max", () => {
+    expect(buildSliderTrack(0, 0, 100, 12)).toBe("├█─────────┤");
+    expect(buildSliderTrack(100, 0, 100, 12)).toBe("├─────────█┤");
+  });
+
+  it("centers the thumb at the midpoint", () => {
+    const t = [...buildSliderTrack(50, 0, 100, 13)];
+    expect(t[6]).toBe("█");
+  });
+
+  it("clamps out-of-range values", () => {
+    expect(buildSliderTrack(-10, 0, 100, 12)).toBe(buildSliderTrack(0, 0, 100, 12
));
+    expect(buildSliderTrack(250, 0, 100, 12)).toBe(buildSliderTrack(100, 0, 100, 
12));
+  });
+});

diff --git a/ooknet-design/src/components/Sl
ider/Slider.test.ts b/ooknet-design/src/comp
onents/Slider/Slider.test.ts
new file mode 100644
index 0000000..acfe790
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Sl
ider.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vites
t";
+import { buildSliderTrack } from "./Slider"
;
+import { len } from "../../lib/ascii";
+
+describe("buildSliderTrack", () => {
+  it("renders the exact cell width", () => 
{
+    expect(len(buildSliderTrack(50, 0, 100,
 20))).toBe(20);
+  });
+
+  it("pins the thumb to the ends at min and
 max", () => {
+    expect(buildSliderTrack(0, 0, 100, 12))
.toBe("├█─────────┤");
+    expect(buildSliderTrack(100, 0, 100, 12
)).toBe("├─────────█┤");
+  });
+
+  it("centers the thumb at the midpoint", (
) => {
+    const t = [...buildSliderTrack(50, 0, 1
00, 13)];
+    expect(t[6]).toBe("█");
+  });
+
+  it("clamps out-of-range values", () => {
+    expect(buildSliderTrack(-10, 0, 100, 12
)).toBe(buildSliderTrack(0, 0, 100, 12));
+    expect(buildSliderTrack(250, 0, 100, 12
)).toBe(buildSliderTrack(100, 0, 100, 12));
+  });
+});
 

diff --git a/ooknet-design/src/components/Slider/Slider.ts b/ooknet-design/src/com
ponents/Slider/Slider.ts
new file mode 100644
index 0000000..e4c7251
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Slider.ts
@@ -0,0 +1,22 @@
+// Track text for the range slider: ├────█────┤. Runs in the browser
+// too (the component script re-renders it on input).
+export interface Props {
+  name: string;
+  value?: number;
+  min?: number;
+  max?: number;
+  step?: number;
+  /** Track width in cells, including the ├ ┤ ends. */
+  cells?: number;
+  /** Render the numeric value after the track. */
+  showValue?: boolean;
+}
+
+export function buildSliderTrack(value: number, min: number, max: number, cells =
 20): string {
+  const inner = Math.max(1, cells - 2);
+  const span = max - min || 1;
+  const ratio = Math.max(0, Math.min(1, (value - min) / span));
+  const pos = Math.round(ratio * (inner - 1));
+  const track = Array.from({ length: inner }, (_, i) => (i === pos ? "█" : "─"));
+  return `├${track.join("")}┤`;
+}

diff --git a/ooknet-design/src/components/Sl
ider/Slider.ts b/ooknet-design/src/component
s/Slider/Slider.ts
new file mode 100644
index 0000000..e4c7251
--- /dev/null
+++ b/ooknet-design/src/components/Slider/Sl
ider.ts
@@ -0,0 +1,22 @@
+// Track text for the range slider: ├────█─
───┤. Runs in the browser
+// too (the component script re-renders it 
on input).
+export interface Props {
+  name: string;
+  value?: number;
+  min?: number;
+  max?: number;
+  step?: number;
+  /** Track width in cells, including the ├
 ┤ ends. */
+  cells?: number;
+  /** Render the numeric value after the tr
ack. */
+  showValue?: boolean;
+}
+
+export function buildSliderTrack(value: num
ber, min: number, max: number, cells = 20): 
string {
+  const inner = Math.max(1, cells - 2);
+  const span = max - min || 1;
+  const ratio = Math.max(0, Math.min(1, (va
lue - min) / span));
+  const pos = Math.round(ratio * (inner - 1
));
+  const track = Array.from({ length: inner 
}, (_, i) => (i === pos ? "█" : "─"));
+  return `├${track.join("")}┤`;
+}
 

diff --git a/ooknet-design/src/components/Tooltip/Tooltip.astro b/ooknet-design/sr
c/components/Tooltip/Tooltip.astro
new file mode 100644
index 0000000..48c218a
--- /dev/null
+++ b/ooknet-design/src/components/Tooltip/Tooltip.astro
@@ -0,0 +1,15 @@
+---
+// Hover/focus tooltip. The text is known at build time, so the box is
+// framed exactly - no runtime measurement.
+import { box, len, wrap } from "../../lib/ascii";
+import { TOOLTIP_MAX_W, type Props } from "./Tooltip";
+import "./Tooltip.scss";
+
+const { text, label = "?" } = Astro.props as Props;
+const inner = Math.min(TOOLTIP_MAX_W - 4, len(text));
+const lines = wrap(text, inner).split("\n");
+const width = Math.max(...lines.map(len)) + 4;
+const frame = box({ width, lines: lines.map((l) => ` ${l}`), align: "end" });
+const id = `tip-${text.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 32)}`;
+---
+<span class="tooltip"><button type="button" class="tooltip-trigger" aria-describe
dby={id}>[{label}]</button><span class="tooltip-pop" role="tooltip" id={id}><pre c
lass="ascii tooltip-frame" aria-hidden="true">{frame}</pre><span class="sr-only">{
text}</span></span></span>

diff --git a/ooknet-design/src/components/To
oltip/Tooltip.astro b/ooknet-design/src/comp
onents/Tooltip/Tooltip.astro
new file mode 100644
index 0000000..48c218a
--- /dev/null
+++ b/ooknet-design/src/components/Tooltip/T
ooltip.astro
@@ -0,0 +1,15 @@
+---
+// Hover/focus tooltip. The text is known a
t build time, so the box is
+// framed exactly - no runtime measurement.
+import { box, len, wrap } from "../../lib/a
scii";
+import { TOOLTIP_MAX_W, type Props } from "
./Tooltip";
+import "./Tooltip.scss";
+
+const { text, label = "?" } = Astro.props a
s Props;
+const inner = Math.min(TOOLTIP_MAX_W - 4, l
en(text));
+const lines = wrap(text, inner).split("\n")
;
+const width = Math.max(...lines.map(len)) +
 4;
+const frame = box({ width, lines: lines.map
((l) => ` ${l}`), align: "end" });
+const id = `tip-${text.toLowerCase().replac
e(/[^a-z0-9]+/g, "-").slice(0, 32)}`;
+---
+<span class="tooltip"><button type="button"
 class="tooltip-trigger" aria-describedby={i
d}>[{label}]</button><span class="tooltip-po
p" role="tooltip" id={id}><pre class="ascii 
tooltip-frame" aria-hidden="true">{frame}</p
re><span class="sr-only">{text}</span></span
></span>
 

diff --git a/ooknet-design/src/components/Tooltip/Tooltip.scss b/ooknet-design/src
/components/Tooltip/Tooltip.scss
new file mode 100644
index 0000000..f85e1ca
--- /dev/null
+++ b/ooknet-design/src/components/Tooltip/Tooltip.scss
@@ -0,0 +1,44 @@
+.tooltip {
+  position: relative;
+  display: inline-block;
+  white-space: pre;
+
+  .tooltip-trigger {
+    font: inherit;
+    color: var(--ink-soft);
+    background: none;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: help;
+    white-space: pre;
+
+    &:hover {
+      color: var(--ink);
+    }
+
+    &:focus-visible {
+      outline: 1px dashed var(--ink);
+      outline-offset: 1px;
+    }
+  }
+
+  .tooltip-pop {
+    position: absolute;
+    bottom: 100%;
+    left: 0;
+    z-index: 10;
+    display: none;
+    background: var(--paper);
+    box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.3);
+
+    pre.ascii.tooltip-frame {
+      width: auto;
+    }
+  }
+
+  &:hover .tooltip-pop,
+  &:focus-within .tooltip-pop {
+    display: block;
+  }
+}

diff --git a/ooknet-design/src/components/To
oltip/Tooltip.scss b/ooknet-design/src/compo
nents/Tooltip/Tooltip.scss
new file mode 100644
index 0000000..f85e1ca
--- /dev/null
+++ b/ooknet-design/src/components/Tooltip/T
ooltip.scss
@@ -0,0 +1,44 @@
+.tooltip {
+  position: relative;
+  display: inline-block;
+  white-space: pre;
+
+  .tooltip-trigger {
+    font: inherit;
+    color: var(--ink-soft);
+    background: none;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: help;
+    white-space: pre;
+
+    &:hover {
+      color: var(--ink);
+    }
+
+    &:focus-visible {
+      outline: 1px dashed var(--ink);
+      outline-offset: 1px;
+    }
+  }
+
+  .tooltip-pop {
+    position: absolute;
+    bottom: 100%;
+    left: 0;
+    z-index: 10;
+    display: none;
+    background: var(--paper);
+    box-shadow: 0 8px 24px -12px rgba(0, 0,
 0, 0.3);
+
+    pre.ascii.tooltip-frame {
+      width: auto;
+    }
+  }
+
+  &:hover .tooltip-pop,
+  &:focus-within .tooltip-pop {
+    display: block;
+  }
+}
 

diff --git a/ooknet-design/src/components/Tooltip/Tooltip.ts b/ooknet-design/src/c
omponents/Tooltip/Tooltip.ts
new file mode 100644
index 0000000..4bc5585
--- /dev/null
+++ b/ooknet-design/src/components/Tooltip/Tooltip.ts
@@ -0,0 +1,8 @@
+export interface Props {
+  /** Tooltip body; wrapped into a box at build time. */
+  text: string;
+  /** Trigger label, defaults to "?" ? renders [?]. */
+  label?: string;
+}
+
+export const TOOLTIP_MAX_W = 40;

diff --git a/ooknet-design/src/components/To
oltip/Tooltip.ts b/ooknet-design/src/compone
nts/Tooltip/Tooltip.ts
new file mode 100644
index 0000000..4bc5585
--- /dev/null
+++ b/ooknet-design/src/components/Tooltip/T
ooltip.ts
@@ -0,0 +1,8 @@
+export interface Props {
+  /** Tooltip body; wrapped into a box at b
uild time. */
+  text: string;
+  /** Trigger label, defaults to "?" ? rend
ers [?]. */
+  label?: string;
+}
+
+export const TOOLTIP_MAX_W = 40;
 

diff --git a/ooknet-design/src/components/TopHeader/TopHeader.astro b/ooknet-desig
n/src/components/TopHeader/TopHeader.astro
index 0a19b5d..5b5c877 100644
--- a/ooknet-design/src/components/TopHeader/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader/TopHeader.astro
@@ -19,7 +19,7 @@ const toggleGap = " ".repeat(MASTHEAD_TOGGLE_GAP);
 const PLACEHOLDER_WIDE = "_".repeat(20);
 const PLACEHOLDER_NARROW = "_".repeat(18);
 ---
-<div class="frame-wide">
+<div class="frame-wide top-header">
   <div class="masthead">
     <Pre><a href="/" class="masthead-home">{MASTHEAD_LABEL}</a>{padW}<span class=
"masthead-search">[ /  search the index <input class="masthead-search-input" type=
"text" aria-label="Search notes" autocomplete="off" spellcheck="false" placeholder
={PLACEHOLDER_WIDE} maxlength="20" data-search-input data-cells="20" /> ]</span>{t
oggleGap}<ThemeToggle /></Pre>
     <ul class="masthead-search-results" hidden data-search-results></ul>
@@ -28,7 +28,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18);
   <NavBar width={FRAME_W} />
   <Pre>{ruleD(FRAME_W)}</Pre>
 </div>
-<div class="frame-narrow">
+<div class="frame-narrow top-header">
   <div class="masthead">
     <Pre><a href="/" class="masthead-home">{MASTHEAD_LABEL}</a>{padN}<span class=
"masthead-search">[ /  search <input class="masthead-search-input" type="text" ari
a-label="Search notes" autocomplete="off" spellcheck="false" placeholder={PLACEHOL
DER_NARROW} maxlength="18" data-search-input data-cells="18" /> ]</span>{toggleGap
}<ThemeToggle /></Pre>
     <ul class="masthead-search-results" hidden data-search-results></ul>

diff --git a/ooknet-design/src/components/To
pHeader/TopHeader.astro b/ooknet-design/src/
components/TopHeader/TopHeader.astro
index 0a19b5d..5b5c877 100644
--- a/ooknet-design/src/components/TopHeader
/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader
/TopHeader.astro
@@ -19,7 +19,7 @@ const toggleGap = " ".repe
at(MASTHEAD_TOGGLE_GAP);
 const PLACEHOLDER_WIDE = "_".repeat(20);
 const PLACEHOLDER_NARROW = "_".repeat(18);
 ---
-<div class="frame-wide">
+<div class="frame-wide top-header">
   <div class="masthead">
     <Pre><a href="/" class="masthead-home">
{MASTHEAD_LABEL}</a>{padW}<span class="masth
ead-search">[ /  search the index <input cla
ss="masthead-search-input" type="text" aria-
label="Search notes" autocomplete="off" spel
lcheck="false" placeholder={PLACEHOLDER_WIDE
} maxlength="20" data-search-input data-cell
s="20" /> ]</span>{toggleGap}<ThemeToggle />
</Pre>
     <ul class="masthead-search-results" hid
den data-search-results></ul>
@@ -28,7 +28,7 @@ const PLACEHOLDER_NARROW =
 "_".repeat(18);
   <NavBar width={FRAME_W} />
   <Pre>{ruleD(FRAME_W)}</Pre>
 </div>
-<div class="frame-narrow">
+<div class="frame-narrow top-header">
   <div class="masthead">
     <Pre><a href="/" class="masthead-home">
{MASTHEAD_LABEL}</a>{padN}<span class="masth
ead-search">[ /  search <input class="masthe
ad-search-input" type="text" aria-label="Sea
rch notes" autocomplete="off" spellcheck="fa
lse" placeholder={PLACEHOLDER_NARROW} maxlen
gth="18" data-search-input data-cells="18" /
> ]</span>{toggleGap}<ThemeToggle /></Pre>
     <ul class="masthead-search-results" hid
den data-search-results></ul>
 

diff --git a/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.scss b/ooknet-d
esign/src/layouts/ArticleLayout/ArticleLayout.scss
index 3f0b305..6976128 100644
--- a/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.scss
+++ b/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.scss
@@ -30,4 +30,11 @@
     &::before { display: none; }
     box-shadow: none;
   }
+
+  @media print {
+    box-shadow: none;
+    min-height: auto;
+    overflow: visible;
+    &::before { display: none; }
+  }
 }

diff --git a/ooknet-design/src/layouts/Artic
leLayout/ArticleLayout.scss b/ooknet-design/
src/layouts/ArticleLayout/ArticleLayout.scss
index 3f0b305..6976128 100644
--- a/ooknet-design/src/layouts/ArticleLayou
t/ArticleLayout.scss
+++ b/ooknet-design/src/layouts/ArticleLayou
t/ArticleLayout.scss
@@ -30,4 +30,11 @@
     &::before { display: none; }
     box-shadow: none;
   }
+
+  @media print {
+    box-shadow: none;
+    min-height: auto;
+    overflow: visible;
+    &::before { display: none; }
+  }
 }
 

diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co
mponents.astro
index 48cc70d..5b41820 100644
--- a/ooknet-design/src/pages/components.astro
+++ b/ooknet-design/src/pages/components.astro
@@ -32,6 +32,11 @@ import Pagination from "../components/Pagination/Pagination.ast
ro";
 import Switch from "../components/Switch/Switch.astro";
 import NumberStepper from "../components/NumberStepper/NumberStepper.astro";
 import Dialog from "../components/Dialog/Dialog.astro";
+import Menu from "../components/Menu/Menu.astro";
+import Tooltip from "../components/Tooltip/Tooltip.astro";
+import Slider from "../components/Slider/Slider.astro";
+import Skeleton from "../components/Skeleton/Skeleton.astro";
+import Diff from "../components/Diff/Diff.astro";
 import { rule, ruleD, row2, pad } from "../lib/ascii";
 
 const section = (title: string, right: string) => (w: number) =>
@@ -240,6 +245,37 @@ const FILED_TREE: TreeNode[] = [
   <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/BOUND" />
   <Pre>{" "}</Pre>
 
+  <Frame build={section("DIFF", "Diff.astro")} />
+  <Diff code={`@@ -1,3 +1,4 @@\n export function pad(s: unknown, n: number): stri
ng {\n-  return String(s).padEnd(n);\n+  const str = String(s ?? "");\n+  return s
tr + " ".repeat(Math.max(0, n - len(str)));\n }`} />
+
+  <Frame build={section("MENU", "Menu.astro")} />
+  <Pre>{" "}</Pre>
+  <Menu label="ACTIONS" items={[
+    { label: "COPY LINK" },
+    { label: "DOWNLOAD .MD" },
+    { label: "VIEW SOURCE", href: "https://github.com/" },
+    { label: "REPORT DRIFT" },
+  ]} />
+  <Pre>{" "}</Pre>
+  <Pre>{" "}</Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TOOLTIP", "Tooltip.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{"frame font "}<Tooltip text="Cascadia Mono - every glyph measured against
 the 0 advance before joining the grid" />{"   threshold "}<Tooltip text="the gaug
e flips its status word here" label="i" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SLIDER", "Slider.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("SQUELCH", LABEL_W)}<Slider name="demo-squelch" value={35} cells={24}
 /></Pre>
+  <Pre>{pad("GAIN", LABEL_W)}<Slider name="demo-gain" value={70} min={0} max={100
} step={5} cells={24} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SKELETON", "Skeleton.astro")} />
+  <Pre>{" "}</Pre>
+  <Skeleton rows={[32, 26, 29, 14]} />
+  <Pre>{" "}</Pre>
+
   <Frame build={section("SWITCH", "Switch.astro")} />
   <Pre>{" "}</Pre>
   <Pre><Switch label="AUTO-ACK INCOMING" name="demo-autoack" checked /></Pre>

diff --git a/ooknet-design/src/pages/compone
nts.astro b/ooknet-design/src/pages/componen
ts.astro
index 48cc70d..5b41820 100644
--- a/ooknet-design/src/pages/components.ast
ro
+++ b/ooknet-design/src/pages/components.ast
ro
@@ -32,6 +32,11 @@ import Pagination from ".
./components/Pagination/Pagination.astro";
 import Switch from "../components/Switch/Sw
itch.astro";
 import NumberStepper from "../components/Nu
mberStepper/NumberStepper.astro";
 import Dialog from "../components/Dialog/Di
alog.astro";
+import Menu from "../components/Menu/Menu.a
stro";
+import Tooltip from "../components/Tooltip/
Tooltip.astro";
+import Slider from "../components/Slider/Sl
ider.astro";
+import Skeleton from "../components/Skeleto
n/Skeleton.astro";
+import Diff from "../components/Diff/Diff.a
stro";
 import { rule, ruleD, row2, pad } from "../
lib/ascii";
 
 const section = (title: string, right: stri
ng) => (w: number) =>
@@ -240,6 +245,37 @@ const FILED_TREE: TreeN
ode[] = [
   <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/B
OUND" />
   <Pre>{" "}</Pre>
 
+  <Frame build={section("DIFF", "Diff.astro
")} />
+  <Diff code={`@@ -1,3 +1,4 @@\n export fun
ction pad(s: unknown, n: number): string {\n
-  return String(s).padEnd(n);\n+  const str
 = String(s ?? "");\n+  return str + " ".rep
eat(Math.max(0, n - len(str)));\n }`} />
+
+  <Frame build={section("MENU", "Menu.astro
")} />
+  <Pre>{" "}</Pre>
+  <Menu label="ACTIONS" items={[
+    { label: "COPY LINK" },
+    { label: "DOWNLOAD .MD" },
+    { label: "VIEW SOURCE", href: "https://
github.com/" },
+    { label: "REPORT DRIFT" },
+  ]} />
+  <Pre>{" "}</Pre>
+  <Pre>{" "}</Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TOOLTIP", "Tooltip
.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{"frame font "}<Tooltip text="Cascad
ia Mono - every glyph measured against the 0
 advance before joining the grid" />{"   thr
eshold "}<Tooltip text="the gauge flips its 
status word here" label="i" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SLIDER", "Slider.a
stro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("SQUELCH", LABEL_W)}<Slider nam
e="demo-squelch" value={35} cells={24} /></P
re>
+  <Pre>{pad("GAIN", LABEL_W)}<Slider name="
demo-gain" value={70} min={0} max={100} step
={5} cells={24} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SKELETON", "Skelet
on.astro")} />
+  <Pre>{" "}</Pre>
+  <Skeleton rows={[32, 26, 29, 14]} />
+  <Pre>{" "}</Pre>
+
   <Frame build={section("SWITCH", "Switch.a
stro")} />
   <Pre>{" "}</Pre>
   <Pre><Switch label="AUTO-ACK INCOMING" na
me="demo-autoack" checked /></Pre>
 

diff --git a/ooknet-design/src/pages/status.astro b/ooknet-design/src/pages/status
.astro
new file mode 100644
index 0000000..ccf2afd
--- /dev/null
+++ b/ooknet-design/src/pages/status.astro
@@ -0,0 +1,83 @@
+---
+// System telemetry - dogfoods the display components with real numbers
+// computed from the notes collection at build time.
+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 KeyValue from "../components/KeyValue/KeyValue.astro";
+import Gauge from "../components/Gauge/Gauge.astro";
+import Sparkline from "../components/Sparkline/Sparkline.astro";
+import Calendar from "../components/Calendar/Calendar.astro";
+import Timeline from "../components/Timeline/Timeline.astro";
+import type { TimelineEntry } from "../components/Timeline/Timeline";
+import Steps from "../components/Steps/Steps.astro";
+import { rule, ruleD, row2, pad } from "../lib/ascii";
+import { published, byNoteNum } from "../lib/notes";
+
+const section = (title: string, right: string) => (w: number) =>
+  [row2(title, right, w), ruleD(w)].join("\n");
+const LABEL_W = 12;
+
+const notes = (await getCollection("notes", published)).sort(byNoteNum);
+const words = notes.map((n) => (n.body ?? "").split(/\s+/).filter(Boolean).length
);
+const totalWords = words.reduce((a, b) => a + b, 0);
+const tagCount = new Set(notes.flatMap((n) => n.data.meta.tags)).size;
+
+const byDate = [...notes].sort((a, b) => (a.data.published < b.data.published ? 1
 : -1));
+const latestDate = byDate[0].data.published;
+const [year, month] = latestDate.split("-").map(Number);
+const monthPrefix = `${year}-${String(month).padStart(2, "0")}`;
+const marks = byDate
+  .filter((n) => n.data.published.startsWith(monthPrefix))
+  .map((n) => Number(n.data.published.slice(8)));
+
+// VOLUME 00 binds at 50 notes; the gauge tracks fill.
+const VOLUME_TARGET = 50;
+const capacity = (notes.length / VOLUME_TARGET) * 100;
+
+const log: TimelineEntry[] = byDate.map((n) => ({
+  date: n.data.published,
+  title: `${n.data.note} FILED`,
+  desc: n.data.title.toLowerCase(),
+  href: `/notes/${n.id}/`,
+}));
+---
+<ArticleLayout title="OOKNET KB - Status">
+  <TopHeader />
+  <Frame build={(w) => [row2("SYSTEM TELEMETRY", `AS OF ${latestDate}`, w), ruleD
(w)].join("\n")} />
+  <Pre>{" "}</Pre>
+
+  <KeyValue entries={[
+    ["NOTES", `${notes.length} FILED`],
+    ["WORDS", `${totalWords} TOTAL / ${Math.round(totalWords / notes.length)} AVG
`],
+    ["TAGS", `${tagCount} DISTINCT`],
+    ["LAST FILING", latestDate],
+  ]} keyW={LABEL_W + 2} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("VOLUME 00", "bind at 50 notes")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("CAPACITY", LABEL_W)}<Gauge value={capacity} threshold={80} low="ACCU
MULATING" high="BIND SOON" /></Pre>
+  <Pre>{" "}</Pre>
+  <Steps steps={["OPEN VOLUME", "ACCUMULATE", "BIND & ARCHIVE"]} current={1} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SIGNAL", "words per note, by note number")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("WORDS/NOTE", LABEL_W)}<Sparkline values={words} width={40} />{`  ${M
ath.max(...words)} PEAK`}</Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("FILINGS", `${monthPrefix} marked`)} />
+  <Pre>{" "}</Pre>
+  <Calendar year={year} month={month} marks={marks} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("FILING LOG", "most recent first")} />
+  <Pre>{" "}</Pre>
+  <Timeline entries={log} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>

diff --git a/ooknet-design/src/pages/status.
astro b/ooknet-design/src/pages/status.astro
new file mode 100644
index 0000000..ccf2afd
--- /dev/null
+++ b/ooknet-design/src/pages/status.astro
@@ -0,0 +1,83 @@
+---
+// System telemetry - dogfoods the display 
components with real numbers
+// computed from the notes collection at bu
ild time.
+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 KeyValue from "../components/KeyValu
e/KeyValue.astro";
+import Gauge from "../components/Gauge/Gaug
e.astro";
+import Sparkline from "../components/Sparkl
ine/Sparkline.astro";
+import Calendar from "../components/Calenda
r/Calendar.astro";
+import Timeline from "../components/Timelin
e/Timeline.astro";
+import type { TimelineEntry } from "../comp
onents/Timeline/Timeline";
+import Steps from "../components/Steps/Step
s.astro";
+import { rule, ruleD, row2, pad } from "../
lib/ascii";
+import { published, byNoteNum } from "../li
b/notes";
+
+const section = (title: string, right: stri
ng) => (w: number) =>
+  [row2(title, right, w), ruleD(w)].join("\
n");
+const LABEL_W = 12;
+
+const notes = (await getCollection("notes",
 published)).sort(byNoteNum);
+const words = notes.map((n) => (n.body ?? "
").split(/\s+/).filter(Boolean).length);
+const totalWords = words.reduce((a, b) => a
 + b, 0);
+const tagCount = new Set(notes.flatMap((n) 
=> n.data.meta.tags)).size;
+
+const byDate = [...notes].sort((a, b) => (a
.data.published < b.data.published ? 1 : -1)
);
+const latestDate = byDate[0].data.published
;
+const [year, month] = latestDate.split("-")
.map(Number);
+const monthPrefix = `${year}-${String(month
).padStart(2, "0")}`;
+const marks = byDate
+  .filter((n) => n.data.published.startsWit
h(monthPrefix))
+  .map((n) => Number(n.data.published.slice
(8)));
+
+// VOLUME 00 binds at 50 notes; the gauge t
racks fill.
+const VOLUME_TARGET = 50;
+const capacity = (notes.length / VOLUME_TAR
GET) * 100;
+
+const log: TimelineEntry[] = byDate.map((n)
 => ({
+  date: n.data.published,
+  title: `${n.data.note} FILED`,
+  desc: n.data.title.toLowerCase(),
+  href: `/notes/${n.id}/`,
+}));
+---
+<ArticleLayout title="OOKNET KB - Status">
+  <TopHeader />
+  <Frame build={(w) => [row2("SYSTEM TELEME
TRY", `AS OF ${latestDate}`, w), ruleD(w)].j
oin("\n")} />
+  <Pre>{" "}</Pre>
+
+  <KeyValue entries={[
+    ["NOTES", `${notes.length} FILED`],
+    ["WORDS", `${totalWords} TOTAL / ${Math
.round(totalWords / notes.length)} AVG`],
+    ["TAGS", `${tagCount} DISTINCT`],
+    ["LAST FILING", latestDate],
+  ]} keyW={LABEL_W + 2} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("VOLUME 00", "bind 
at 50 notes")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("CAPACITY", LABEL_W)}<Gauge val
ue={capacity} threshold={80} low="ACCUMULATI
NG" high="BIND SOON" /></Pre>
+  <Pre>{" "}</Pre>
+  <Steps steps={["OPEN VOLUME", "ACCUMULATE
", "BIND & ARCHIVE"]} current={1} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SIGNAL", "words pe
r note, by note number")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("WORDS/NOTE", LABEL_W)}<Sparkli
ne values={words} width={40} />{`  ${Math.ma
x(...words)} PEAK`}</Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("FILINGS", `${month
Prefix} marked`)} />
+  <Pre>{" "}</Pre>
+  <Calendar year={year} month={month} marks
={marks} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("FILING LOG", "most
 recent first")} />
+  <Pre>{" "}</Pre>
+  <Timeline entries={log} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>
 

diff --git a/ooknet-design/src/styles/global.scss b/ooknet-design/src/styles/globa
l.scss
index f7d4677..33a5fc8 100644
--- a/ooknet-design/src/styles/global.scss
+++ b/ooknet-design/src/styles/global.scss
@@ -109,3 +109,49 @@ body {
   display: flex;
   justify-content: center;
 }
+
+// Print: ink on paper, literally. Force the light palette and the wide
+// frame, drop the screen chrome (nav, search, punch column, toasts),
+// and keep frames from splitting across page breaks.
+@media print {
+  :root,
+  html[data-theme="dark"] {
+    --paper: #fff;
+    --ink: #000;
+    --ink-soft: #444;
+    --backdrop: #fff;
+    --fs: 11px;
+    --frame-cells: 86;
+    --page-pad-t: 0;
+    --page-pad-r: 0;
+    --page-pad-b: 0;
+    --page-pad-l: 0;
+    --page-w: auto;
+  }
+
+  body {
+    display: block;
+    background: #fff;
+  }
+
+  .frame-narrow {
+    display: none !important;
+  }
+  .frame-wide {
+    display: revert !important;
+  }
+
+  .top-header,
+  .article-pagination,
+  .toast-region {
+    display: none !important;
+  }
+
+  pre.ascii,
+  .figure-frame,
+  .table-frames,
+  .code-frames,
+  blockquote.callout {
+    break-inside: avoid;
+  }
+}

diff --git a/ooknet-design/src/styles/global
.scss b/ooknet-design/src/styles/global.scss
index f7d4677..33a5fc8 100644
--- a/ooknet-design/src/styles/global.scss
+++ b/ooknet-design/src/styles/global.scss
@@ -109,3 +109,49 @@ body {
   display: flex;
   justify-content: center;
 }
+
+// Print: ink on paper, literally. Force th
e light palette and the wide
+// frame, drop the screen chrome (nav, sear
ch, punch column, toasts),
+// and keep frames from splitting across pa
ge breaks.
+@media print {
+  :root,
+  html[data-theme="dark"] {
+    --paper: #fff;
+    --ink: #000;
+    --ink-soft: #444;
+    --backdrop: #fff;
+    --fs: 11px;
+    --frame-cells: 86;
+    --page-pad-t: 0;
+    --page-pad-r: 0;
+    --page-pad-b: 0;
+    --page-pad-l: 0;
+    --page-w: auto;
+  }
+
+  body {
+    display: block;
+    background: #fff;
+  }
+
+  .frame-narrow {
+    display: none !important;
+  }
+  .frame-wide {
+    display: revert !important;
+  }
+
+  .top-header,
+  .article-pagination,
+  .toast-region {
+    display: none !important;
+  }
+
+  pre.ascii,
+  .figure-frame,
+  .table-frames,
+  .code-frames,
+  blockquote.callout {
+    break-inside: avoid;
+  }
+}
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET