OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      63dfaae77353
DATE      2026-07-16
SUBJECT   web: form components, markdown table/callout/figure transforms
FILES     31 CHANGED
HASH      63dfaae77353
DATE      2026-07-16
SUBJECT   web: form components, markdown
          table/callout/figure transforms
FILES     31 CHANGED
 

diff --git a/ooknet-design/public/figures/n0-frame-schematic.svg b/ooknet-design/p
ublic/figures/n0-frame-schematic.svg
new file mode 100644
index 0000000..7b49833
--- /dev/null
+++ b/ooknet-design/public/figures/n0-frame-schematic.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 240" font-family="monosp
ace" font-size="14">
+  <g fill="none" stroke="#8a8378" stroke-width="1.5">
+    <rect x="20" y="20" width="600" height="200" stroke-dasharray="6 4"/>
+    <rect x="60" y="55" width="240" height="130"/>
+    <rect x="340" y="55" width="240" height="130"/>
+    <line x1="300" y1="120" x2="340" y2="120"/>
+  </g>
+  <g fill="#8a8378">
+    <text x="40" y="44">VIEWPORT</text>
+    <text x="80" y="80">FRAME_W = 86</text>
+    <text x="80" y="102">build(width)</text>
+    <text x="360" y="80">MOBILE_FRAME_W = 48</text>
+    <text x="360" y="102">build(width)</text>
+    <text x="80" y="165">.frame-wide</text>
+    <text x="360" y="165">.frame-narrow</text>
+  </g>
+</svg>

diff --git a/ooknet-design/public/figures/n0
-frame-schematic.svg b/ooknet-design/public/
figures/n0-frame-schematic.svg
new file mode 100644
index 0000000..7b49833
--- /dev/null
+++ b/ooknet-design/public/figures/n0-frame-
schematic.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" vie
wBox="0 0 640 240" font-family="monospace" f
ont-size="14">
+  <g fill="none" stroke="#8a8378" stroke-wi
dth="1.5">
+    <rect x="20" y="20" width="600" height=
"200" stroke-dasharray="6 4"/>
+    <rect x="60" y="55" width="240" height=
"130"/>
+    <rect x="340" y="55" width="240" height
="130"/>
+    <line x1="300" y1="120" x2="340" y2="12
0"/>
+  </g>
+  <g fill="#8a8378">
+    <text x="40" y="44">VIEWPORT</text>
+    <text x="80" y="80">FRAME_W = 86</text>
+    <text x="80" y="102">build(width)</text
>
+    <text x="360" y="80">MOBILE_FRAME_W = 4
8</text>
+    <text x="360" y="102">build(width)</tex
t>
+    <text x="80" y="165">.frame-wide</text>
+    <text x="360" y="165">.frame-narrow</te
xt>
+  </g>
+</svg>
 

diff --git a/ooknet-design/src/components/ArticlePagination/ArticlePagination.astr
o b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro
new file mode 100644
index 0000000..78e78ce
--- /dev/null
+++ b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro
@@ -0,0 +1,22 @@
+---
+import Pre from "../Pre/Pre.astro";
+import Button from "../Button/Button.astro";
+import { buttonWidth } from "../Button/Button";
+import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config";
+import type { Props } from "./ArticlePagination";
+
+const { prev = null, next = null } = Astro.props as Props;
+
+// ASCII < > rather than ?/?: the fontsource Cascadia latin subset only
+// ships U+2191/U+2193, so horizontal arrows fall off-grid.
+const prevLabel = prev ? `< ${prev.note}` : null;
+const nextLabel = next ? `${next.note} >` : null;
+const gap = (w: number) =>
+  Math.max(1, w - (prevLabel ? buttonWidth(prevLabel) : 0) - (nextLabel ? buttonW
idth(nextLabel) : 0));
+---
+{(prev || next) && (
+  <Fragment>
+    <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>
+)}

diff --git a/ooknet-design/src/components/Ar
ticlePagination/ArticlePagination.astro b/oo
knet-design/src/components/ArticlePagination
/ArticlePagination.astro
new file mode 100644
index 0000000..78e78ce
--- /dev/null
+++ b/ooknet-design/src/components/ArticlePa
gination/ArticlePagination.astro
@@ -0,0 +1,22 @@
+---
+import Pre from "../Pre/Pre.astro";
+import Button from "../Button/Button.astro"
;
+import { buttonWidth } from "../Button/Butt
on";
+import { FRAME_W, MOBILE_FRAME_W } from "..
/../lib/config";
+import type { Props } from "./ArticlePagina
tion";
+
+const { prev = null, next = null } = Astro.
props as Props;
+
+// ASCII < > rather than ?/?: the fontsourc
e Cascadia latin subset only
+// ships U+2191/U+2193, so horizontal arrow
s fall off-grid.
+const prevLabel = prev ? `< ${prev.note}` :
 null;
+const nextLabel = next ? `${next.note} >` :
 null;
+const gap = (w: number) =>
+  Math.max(1, w - (prevLabel ? buttonWidth(
prevLabel) : 0) - (nextLabel ? buttonWidth(n
extLabel) : 0));
+---
+{(prev || next) && (
+  <Fragment>
+    <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>
+)}
 

diff --git a/ooknet-design/src/components/ArticlePagination/ArticlePagination.ts b
/ooknet-design/src/components/ArticlePagination/ArticlePagination.ts
new file mode 100644
index 0000000..2136cfe
--- /dev/null
+++ b/ooknet-design/src/components/ArticlePagination/ArticlePagination.ts
@@ -0,0 +1,9 @@
+export interface PageLink {
+  id: string;
+  note: string;
+}
+
+export interface Props {
+  prev?: PageLink | null;
+  next?: PageLink | null;
+}

diff --git a/ooknet-design/src/components/Ar
ticlePagination/ArticlePagination.ts b/ookne
t-design/src/components/ArticlePagination/Ar
ticlePagination.ts
new file mode 100644
index 0000000..2136cfe
--- /dev/null
+++ b/ooknet-design/src/components/ArticlePa
gination/ArticlePagination.ts
@@ -0,0 +1,9 @@
+export interface PageLink {
+  id: string;
+  note: string;
+}
+
+export interface Props {
+  prev?: PageLink | null;
+  next?: PageLink | null;
+}
 

diff --git a/ooknet-design/src/components/ArticleTags/ArticleTags.astro b/ooknet-d
esign/src/components/ArticleTags/ArticleTags.astro
new file mode 100644
index 0000000..466d6f8
--- /dev/null
+++ b/ooknet-design/src/components/ArticleTags/ArticleTags.astro
@@ -0,0 +1,25 @@
+---
+import Pre from "../Pre/Pre.astro";
+import Badge from "../Badge/Badge.astro";
+import { pad } from "../../lib/ascii";
+import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config";
+import { layoutTags, TAGS_LABEL_W, TAG_GAP, type Props } from "./ArticleTags";
+
+const { fm } = Astro.props as Props;
+const tags = fm.meta.tags.map((t) => t.toUpperCase());
+const href = (t: string) => `/?q=${encodeURIComponent(t.toLowerCase())}`;
+const gapStr = " ".repeat(TAG_GAP);
+
+const wide = layoutTags(tags, FRAME_W);
+const narrow = layoutTags(tags, MOBILE_FRAME_W);
+---
+{tags.length > 0 && (
+  <Fragment>
+    <div class="frame-wide">{wide.map((row, i) => (
+      <Pre>{pad(i === 0 ? "TAGS" : "", TAGS_LABEL_W)}{row.map((t, j) => (<Fragmen
t>{j > 0 ? gapStr : ""}<Badge label={t} href={href(t)} /></Fragment>))}</Pre>
+    ))}</div>
+    <div class="frame-narrow">{narrow.map((row, i) => (
+      <Pre>{pad(i === 0 ? "TAGS" : "", TAGS_LABEL_W)}{row.map((t, j) => (<Fragmen
t>{j > 0 ? gapStr : ""}<Badge label={t} href={href(t)} /></Fragment>))}</Pre>
+    ))}</div>
+  </Fragment>
+)}

diff --git a/ooknet-design/src/components/Ar
ticleTags/ArticleTags.astro b/ooknet-design/
src/components/ArticleTags/ArticleTags.astro
new file mode 100644
index 0000000..466d6f8
--- /dev/null
+++ b/ooknet-design/src/components/ArticleTa
gs/ArticleTags.astro
@@ -0,0 +1,25 @@
+---
+import Pre from "../Pre/Pre.astro";
+import Badge from "../Badge/Badge.astro";
+import { pad } from "../../lib/ascii";
+import { FRAME_W, MOBILE_FRAME_W } from "..
/../lib/config";
+import { layoutTags, TAGS_LABEL_W, TAG_GAP,
 type Props } from "./ArticleTags";
+
+const { fm } = Astro.props as Props;
+const tags = fm.meta.tags.map((t) => t.toUp
perCase());
+const href = (t: string) => `/?q=${encodeUR
IComponent(t.toLowerCase())}`;
+const gapStr = " ".repeat(TAG_GAP);
+
+const wide = layoutTags(tags, FRAME_W);
+const narrow = layoutTags(tags, MOBILE_FRAM
E_W);
+---
+{tags.length > 0 && (
+  <Fragment>
+    <div class="frame-wide">{wide.map((row,
 i) => (
+      <Pre>{pad(i === 0 ? "TAGS" : "", TAGS
_LABEL_W)}{row.map((t, j) => (<Fragment>{j >
 0 ? gapStr : ""}<Badge label={t} href={href
(t)} /></Fragment>))}</Pre>
+    ))}</div>
+    <div class="frame-narrow">{narrow.map((
row, i) => (
+      <Pre>{pad(i === 0 ? "TAGS" : "", TAGS
_LABEL_W)}{row.map((t, j) => (<Fragment>{j >
 0 ? gapStr : ""}<Badge label={t} href={href
(t)} /></Fragment>))}</Pre>
+    ))}</div>
+  </Fragment>
+)}
 

diff --git a/ooknet-design/src/components/ArticleTags/ArticleTags.ts b/ooknet-desi
gn/src/components/ArticleTags/ArticleTags.ts
new file mode 100644
index 0000000..9aa85fa
--- /dev/null
+++ b/ooknet-design/src/components/ArticleTags/ArticleTags.ts
@@ -0,0 +1,31 @@
+import { badgeWidth } from "../Badge/Badge";
+import type { NoteFrontmatter } from "../../lib/types";
+
+export interface Props {
+  fm: NoteFrontmatter;
+}
+
+export const TAGS_LABEL_W = 12;
+export const TAG_GAP = 2;
+
+// Wrap badges into rows that fit the frame; continuation rows indent
+// under the first badge.
+export function layoutTags(tags: string[], width: number): string[][] {
+  const avail = width - TAGS_LABEL_W;
+  const rows: string[][] = [];
+  let row: string[] = [];
+  let used = 0;
+  for (const tag of tags) {
+    const w = badgeWidth(tag) + (row.length ? TAG_GAP : 0);
+    if (row.length && used + w > avail) {
+      rows.push(row);
+      row = [tag];
+      used = badgeWidth(tag);
+    } else {
+      row.push(tag);
+      used += w;
+    }
+  }
+  if (row.length) rows.push(row);
+  return rows;
+}

diff --git a/ooknet-design/src/components/Ar
ticleTags/ArticleTags.ts b/ooknet-design/src
/components/ArticleTags/ArticleTags.ts
new file mode 100644
index 0000000..9aa85fa
--- /dev/null
+++ b/ooknet-design/src/components/ArticleTa
gs/ArticleTags.ts
@@ -0,0 +1,31 @@
+import { badgeWidth } from "../Badge/Badge"
;
+import type { NoteFrontmatter } from "../..
/lib/types";
+
+export interface Props {
+  fm: NoteFrontmatter;
+}
+
+export const TAGS_LABEL_W = 12;
+export const TAG_GAP = 2;
+
+// Wrap badges into rows that fit the frame
; continuation rows indent
+// under the first badge.
+export function layoutTags(tags: string[], 
width: number): string[][] {
+  const avail = width - TAGS_LABEL_W;
+  const rows: string[][] = [];
+  let row: string[] = [];
+  let used = 0;
+  for (const tag of tags) {
+    const w = badgeWidth(tag) + (row.length
 ? TAG_GAP : 0);
+    if (row.length && used + w > avail) {
+      rows.push(row);
+      row = [tag];
+      used = badgeWidth(tag);
+    } else {
+      row.push(tag);
+      used += w;
+    }
+  }
+  if (row.length) rows.push(row);
+  return rows;
+}
 

diff --git a/ooknet-design/src/components/Badge/Badge.astro b/ooknet-design/src/co
mponents/Badge/Badge.astro
new file mode 100644
index 0000000..4f7319e
--- /dev/null
+++ b/ooknet-design/src/components/Badge/Badge.astro
@@ -0,0 +1,7 @@
+---
+import type { Props } from "./Badge";
+import "./Badge.scss";
+
+const { label, href } = Astro.props as Props;
+---
+{href ? <a class="badge" href={href}>[{label}]</a> : <span class="badge">[{label}
]</span>}

diff --git a/ooknet-design/src/components/Ba
dge/Badge.astro b/ooknet-design/src/componen
ts/Badge/Badge.astro
new file mode 100644
index 0000000..4f7319e
--- /dev/null
+++ b/ooknet-design/src/components/Badge/Bad
ge.astro
@@ -0,0 +1,7 @@
+---
+import type { Props } from "./Badge";
+import "./Badge.scss";
+
+const { label, href } = Astro.props as Prop
s;
+---
+{href ? <a class="badge" href={href}>[{labe
l}]</a> : <span class="badge">[{label}]</spa
n>}
 

diff --git a/ooknet-design/src/components/Badge/Badge.scss b/ooknet-design/src/com
ponents/Badge/Badge.scss
new file mode 100644
index 0000000..243b572
--- /dev/null
+++ b/ooknet-design/src/components/Badge/Badge.scss
@@ -0,0 +1,15 @@
+.badge {
+  color: var(--ink);
+  text-decoration: none;
+  white-space: pre;
+
+  &:hover {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:focus-visible {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+}

diff --git a/ooknet-design/src/components/Ba
dge/Badge.scss b/ooknet-design/src/component
s/Badge/Badge.scss
new file mode 100644
index 0000000..243b572
--- /dev/null
+++ b/ooknet-design/src/components/Badge/Bad
ge.scss
@@ -0,0 +1,15 @@
+.badge {
+  color: var(--ink);
+  text-decoration: none;
+  white-space: pre;
+
+  &:hover {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:focus-visible {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+}
 

diff --git a/ooknet-design/src/components/Badge/Badge.ts b/ooknet-design/src/compo
nents/Badge/Badge.ts
new file mode 100644
index 0000000..e73c0b9
--- /dev/null
+++ b/ooknet-design/src/components/Badge/Badge.ts
@@ -0,0 +1,9 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  href?: string;
+}
+
+// "[" + label + "]"
+export const badgeWidth = (label: string) => len(label) + 2;

diff --git a/ooknet-design/src/components/Ba
dge/Badge.ts b/ooknet-design/src/components/
Badge/Badge.ts
new file mode 100644
index 0000000..e73c0b9
--- /dev/null
+++ b/ooknet-design/src/components/Badge/Bad
ge.ts
@@ -0,0 +1,9 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  href?: string;
+}
+
+// "[" + label + "]"
+export const badgeWidth = (label: string) =
> len(label) + 2;
 

diff --git a/ooknet-design/src/components/Button/Button.astro b/ooknet-design/src/
components/Button/Button.astro
new file mode 100644
index 0000000..f6ac8a9
--- /dev/null
+++ b/ooknet-design/src/components/Button/Button.astro
@@ -0,0 +1,9 @@
+---
+import type { Props } from "./Button";
+import "./Button.scss";
+
+const { label, href, type = "button", variant = "default", disabled = false } = A
stro.props as Props;
+---
+{href && !disabled
+  ? <a href={href} class:list={["btn", variant]}>[ {label} ]</a>
+  : <button type={type} class:list={["btn", variant]} disabled={disabled}>[ {labe
l} ]</button>}

diff --git a/ooknet-design/src/components/Bu
tton/Button.astro b/ooknet-design/src/compon
ents/Button/Button.astro
new file mode 100644
index 0000000..f6ac8a9
--- /dev/null
+++ b/ooknet-design/src/components/Button/Bu
tton.astro
@@ -0,0 +1,9 @@
+---
+import type { Props } from "./Button";
+import "./Button.scss";
+
+const { label, href, type = "button", varia
nt = "default", disabled = false } = Astro.p
rops as Props;
+---
+{href && !disabled
+  ? <a href={href} class:list={["btn", vari
ant]}>[ {label} ]</a>
+  : <button type={type} class:list={["btn",
 variant]} disabled={disabled}>[ {label} ]</
button>}
 

diff --git a/ooknet-design/src/components/Button/Button.scss b/ooknet-design/src/c
omponents/Button/Button.scss
new file mode 100644
index 0000000..3c493f6
--- /dev/null
+++ b/ooknet-design/src/components/Button/Button.scss
@@ -0,0 +1,33 @@
+.btn {
+  font: inherit;
+  color: var(--ink);
+  background: none;
+  border: 0;
+  padding: 0;
+  margin: 0;
+  cursor: pointer;
+  white-space: pre;
+  text-decoration: none;
+
+  &:hover:not(:disabled) {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:focus-visible {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+
+  &.primary {
+    font-weight: 700;
+    background: var(--ink);
+    color: var(--paper);
+  }
+
+  &:disabled {
+    color: var(--ink-soft);
+    opacity: 0.55;
+    cursor: not-allowed;
+  }
+}

diff --git a/ooknet-design/src/components/Bu
tton/Button.scss b/ooknet-design/src/compone
nts/Button/Button.scss
new file mode 100644
index 0000000..3c493f6
--- /dev/null
+++ b/ooknet-design/src/components/Button/Bu
tton.scss
@@ -0,0 +1,33 @@
+.btn {
+  font: inherit;
+  color: var(--ink);
+  background: none;
+  border: 0;
+  padding: 0;
+  margin: 0;
+  cursor: pointer;
+  white-space: pre;
+  text-decoration: none;
+
+  &:hover:not(:disabled) {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:focus-visible {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+
+  &.primary {
+    font-weight: 700;
+    background: var(--ink);
+    color: var(--paper);
+  }
+
+  &:disabled {
+    color: var(--ink-soft);
+    opacity: 0.55;
+    cursor: not-allowed;
+  }
+}
 

diff --git a/ooknet-design/src/components/Button/Button.ts b/ooknet-design/src/com
ponents/Button/Button.ts
new file mode 100644
index 0000000..b2adbc9
--- /dev/null
+++ b/ooknet-design/src/components/Button/Button.ts
@@ -0,0 +1,12 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  href?: string;
+  type?: "button" | "submit";
+  variant?: "default" | "primary";
+  disabled?: boolean;
+}
+
+// "[ " + label + " ]"
+export const buttonWidth = (label: string) => len(label) + 4;

diff --git a/ooknet-design/src/components/Bu
tton/Button.ts b/ooknet-design/src/component
s/Button/Button.ts
new file mode 100644
index 0000000..b2adbc9
--- /dev/null
+++ b/ooknet-design/src/components/Button/Bu
tton.ts
@@ -0,0 +1,12 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  href?: string;
+  type?: "button" | "submit";
+  variant?: "default" | "primary";
+  disabled?: boolean;
+}
+
+// "[ " + label + " ]"
+export const buttonWidth = (label: string) 
=> len(label) + 4;
 

diff --git a/ooknet-design/src/components/Checkbox/Checkbox.astro b/ooknet-design/
src/components/Checkbox/Checkbox.astro
new file mode 100644
index 0000000..4b977ba
--- /dev/null
+++ b/ooknet-design/src/components/Checkbox/Checkbox.astro
@@ -0,0 +1,7 @@
+---
+import type { Props } from "./Checkbox";
+import "./Checkbox.scss";
+
+const { label, name, checked = false, disabled = false } = Astro.props as Props;
+---
+<label class="checkbox"><input type="checkbox" name={name} checked={checked} disa
bled={disabled} /><span class="checkbox-mark" aria-hidden="true"></span>{" " + lab
el}</label>

diff --git a/ooknet-design/src/components/Ch
eckbox/Checkbox.astro b/ooknet-design/src/co
mponents/Checkbox/Checkbox.astro
new file mode 100644
index 0000000..4b977ba
--- /dev/null
+++ b/ooknet-design/src/components/Checkbox/
Checkbox.astro
@@ -0,0 +1,7 @@
+---
+import type { Props } from "./Checkbox";
+import "./Checkbox.scss";
+
+const { label, name, checked = false, disab
led = false } = Astro.props as Props;
+---
+<label class="checkbox"><input type="checkb
ox" name={name} checked={checked} disabled={
disabled} /><span class="checkbox-mark" aria
-hidden="true"></span>{" " + label}</label>
 

diff --git a/ooknet-design/src/components/Checkbox/Checkbox.scss b/ooknet-design/s
rc/components/Checkbox/Checkbox.scss
new file mode 100644
index 0000000..636dee4
--- /dev/null
+++ b/ooknet-design/src/components/Checkbox/Checkbox.scss
@@ -0,0 +1,38 @@
+.checkbox {
+  cursor: pointer;
+  white-space: pre;
+
+  // The native input stays focusable but takes no grid cells; the mark
+  // renders its state.
+  input {
+    position: absolute;
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    margin: 0;
+    pointer-events: none;
+  }
+
+  .checkbox-mark::before {
+    content: "[ ]";
+  }
+  input:checked + .checkbox-mark::before {
+    content: "[x]";
+  }
+
+  input:focus-visible + .checkbox-mark {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+
+  &:hover:not(:has(input:disabled)) {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:has(input:disabled) {
+    color: var(--ink-soft);
+    opacity: 0.55;
+    cursor: not-allowed;
+  }
+}

diff --git a/ooknet-design/src/components/Ch
eckbox/Checkbox.scss b/ooknet-design/src/com
ponents/Checkbox/Checkbox.scss
new file mode 100644
index 0000000..636dee4
--- /dev/null
+++ b/ooknet-design/src/components/Checkbox/
Checkbox.scss
@@ -0,0 +1,38 @@
+.checkbox {
+  cursor: pointer;
+  white-space: pre;
+
+  // The native input stays focusable but t
akes no grid cells; the mark
+  // renders its state.
+  input {
+    position: absolute;
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    margin: 0;
+    pointer-events: none;
+  }
+
+  .checkbox-mark::before {
+    content: "[ ]";
+  }
+  input:checked + .checkbox-mark::before {
+    content: "[x]";
+  }
+
+  input:focus-visible + .checkbox-mark {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+
+  &:hover:not(:has(input:disabled)) {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:has(input:disabled) {
+    color: var(--ink-soft);
+    opacity: 0.55;
+    cursor: not-allowed;
+  }
+}
 

diff --git a/ooknet-design/src/components/Checkbox/Checkbox.ts b/ooknet-design/src
/components/Checkbox/Checkbox.ts
new file mode 100644
index 0000000..74cff37
--- /dev/null
+++ b/ooknet-design/src/components/Checkbox/Checkbox.ts
@@ -0,0 +1,11 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  name: string;
+  checked?: boolean;
+  disabled?: boolean;
+}
+
+// "[x] " + label
+export const checkboxWidth = (label: string) => len(label) + 4;

diff --git a/ooknet-design/src/components/Ch
eckbox/Checkbox.ts b/ooknet-design/src/compo
nents/Checkbox/Checkbox.ts
new file mode 100644
index 0000000..74cff37
--- /dev/null
+++ b/ooknet-design/src/components/Checkbox/
Checkbox.ts
@@ -0,0 +1,11 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  name: string;
+  checked?: boolean;
+  disabled?: boolean;
+}
+
+// "[x] " + label
+export const checkboxWidth = (label: string
) => len(label) + 4;
 

diff --git a/ooknet-design/src/components/Prose/Prose.scss b/ooknet-design/src/com
ponents/Prose/Prose.scss
index 9a1306b..5553e92 100644
--- a/ooknet-design/src/components/Prose/Prose.scss
+++ b/ooknet-design/src/components/Prose/Prose.scss
@@ -73,6 +73,49 @@
     }
   }
 
+  // [!NOTE]-style callouts. Still a blockquote (the left rail above
+  // applies); the build-time rule spans draw the top/bottom edges, so
+  // the rail is inset one line at each end and the indent uses cell
+  // units to keep the corner glyphs on the frame grid.
+  blockquote.callout {
+    font-style: normal;
+    color: var(--ink);
+    padding-left: calc(var(--cell-w) * 3);
+    padding-right: calc(var(--cell-w) * 3);
+
+    // Left rail from the base blockquote, inset one line at each end so
+    // it meets the build-time corner rules; ::after mirrors it on the
+    // right to close the box.
+    &::before {
+      top: var(--cell-h);
+      bottom: var(--cell-h);
+    }
+
+    &::after {
+      content: "│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\
A│\A│\A│\A│\A│\A│\A│\A│";
+      position: absolute;
+      right: 0;
+      top: var(--cell-h);
+      bottom: var(--cell-h);
+      font-family: var(--mono-frame);
+      font-style: normal;
+      color: var(--ink);
+      white-space: pre;
+      overflow: hidden;
+    }
+
+    .callout-rule {
+      display: block;
+      font-family: var(--mono-frame);
+      line-height: var(--lh);
+      margin-left: calc(var(--cell-w) * -3);
+      margin-right: calc(var(--cell-w) * -3);
+      white-space: pre;
+      overflow: hidden;
+      color: var(--ink);
+    }
+  }
+
   ul, ol {
     margin: 0 0 1em;
     padding-left: 0;
@@ -135,4 +178,31 @@
       }
     }
   }
+
+  pre.ascii.table-frame {
+    font-family: var(--mono-frame);
+    margin: 1.4em 0;
+  }
+
+  .figure-frame {
+    margin: 1.4em 0;
+
+    img {
+      display: block;
+      width: 100%;
+      max-width: var(--frame-w);
+      height: auto;
+      margin: 0.5em 0;
+    }
+
+    .fig-rule,
+    .fig-caption {
+      display: block;
+      font-family: var(--mono-frame);
+      white-space: pre;
+      overflow: hidden;
+      color: var(--ink);
+    }
+    .fig-caption { color: var(--ink-soft); }
+  }
 }

diff --git a/ooknet-design/src/components/Pr
ose/Prose.scss b/ooknet-design/src/component
s/Prose/Prose.scss
index 9a1306b..5553e92 100644
--- a/ooknet-design/src/components/Prose/Pro
se.scss
+++ b/ooknet-design/src/components/Prose/Pro
se.scss
@@ -73,6 +73,49 @@
     }
   }
 
+  // [!NOTE]-style callouts. Still a blockq
uote (the left rail above
+  // applies); the build-time rule spans dr
aw the top/bottom edges, so
+  // the rail is inset one line at each end
 and the indent uses cell
+  // units to keep the corner glyphs on the
 frame grid.
+  blockquote.callout {
+    font-style: normal;
+    color: var(--ink);
+    padding-left: calc(var(--cell-w) * 3);
+    padding-right: calc(var(--cell-w) * 3);
+
+    // Left rail from the base blockquote, 
inset one line at each end so
+    // it meets the build-time corner rules
; ::after mirrors it on the
+    // right to close the box.
+    &::before {
+      top: var(--cell-h);
+      bottom: var(--cell-h);
+    }
+
+    &::after {
+      content: "│\A│\A│\A│\A│\A│\A│\A│\A│\A
│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\A│\
A│\A│\A│\A│\A│\A│";
+      position: absolute;
+      right: 0;
+      top: var(--cell-h);
+      bottom: var(--cell-h);
+      font-family: var(--mono-frame);
+      font-style: normal;
+      color: var(--ink);
+      white-space: pre;
+      overflow: hidden;
+    }
+
+    .callout-rule {
+      display: block;
+      font-family: var(--mono-frame);
+      line-height: var(--lh);
+      margin-left: calc(var(--cell-w) * -3)
;
+      margin-right: calc(var(--cell-w) * -3
);
+      white-space: pre;
+      overflow: hidden;
+      color: var(--ink);
+    }
+  }
+
   ul, ol {
     margin: 0 0 1em;
     padding-left: 0;
@@ -135,4 +178,31 @@
       }
     }
   }
+
+  pre.ascii.table-frame {
+    font-family: var(--mono-frame);
+    margin: 1.4em 0;
+  }
+
+  .figure-frame {
+    margin: 1.4em 0;
+
+    img {
+      display: block;
+      width: 100%;
+      max-width: var(--frame-w);
+      height: auto;
+      margin: 0.5em 0;
+    }
+
+    .fig-rule,
+    .fig-caption {
+      display: block;
+      font-family: var(--mono-frame);
+      white-space: pre;
+      overflow: hidden;
+      color: var(--ink);
+    }
+    .fig-caption { color: var(--ink-soft); 
}
+  }
 }
 

diff --git a/ooknet-design/src/components/Radio/Radio.astro b/ooknet-design/src/co
mponents/Radio/Radio.astro
new file mode 100644
index 0000000..4db3db0
--- /dev/null
+++ b/ooknet-design/src/components/Radio/Radio.astro
@@ -0,0 +1,7 @@
+---
+import type { Props } from "./Radio";
+import "./Radio.scss";
+
+const { label, name, value, checked = false, disabled = false } = Astro.props as 
Props;
+---
+<label class="radio"><input type="radio" name={name} value={value} checked={check
ed} disabled={disabled} /><span class="radio-mark" aria-hidden="true"></span>{" " 
+ label}</label>

diff --git a/ooknet-design/src/components/Ra
dio/Radio.astro b/ooknet-design/src/componen
ts/Radio/Radio.astro
new file mode 100644
index 0000000..4db3db0
--- /dev/null
+++ b/ooknet-design/src/components/Radio/Rad
io.astro
@@ -0,0 +1,7 @@
+---
+import type { Props } from "./Radio";
+import "./Radio.scss";
+
+const { label, name, value, checked = false
, disabled = false } = Astro.props as Props;
+---
+<label class="radio"><input type="radio" na
me={name} value={value} checked={checked} di
sabled={disabled} /><span class="radio-mark"
 aria-hidden="true"></span>{" " + label}</la
bel>
 

diff --git a/ooknet-design/src/components/Radio/Radio.scss b/ooknet-design/src/com
ponents/Radio/Radio.scss
new file mode 100644
index 0000000..f2ae83c
--- /dev/null
+++ b/ooknet-design/src/components/Radio/Radio.scss
@@ -0,0 +1,36 @@
+.radio {
+  cursor: pointer;
+  white-space: pre;
+
+  input {
+    position: absolute;
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    margin: 0;
+    pointer-events: none;
+  }
+
+  .radio-mark::before {
+    content: "( )";
+  }
+  input:checked + .radio-mark::before {
+    content: "(-)";
+  }
+
+  input:focus-visible + .radio-mark {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+
+  &:hover:not(:has(input:disabled)) {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:has(input:disabled) {
+    color: var(--ink-soft);
+    opacity: 0.55;
+    cursor: not-allowed;
+  }
+}

diff --git a/ooknet-design/src/components/Ra
dio/Radio.scss b/ooknet-design/src/component
s/Radio/Radio.scss
new file mode 100644
index 0000000..f2ae83c
--- /dev/null
+++ b/ooknet-design/src/components/Radio/Rad
io.scss
@@ -0,0 +1,36 @@
+.radio {
+  cursor: pointer;
+  white-space: pre;
+
+  input {
+    position: absolute;
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    margin: 0;
+    pointer-events: none;
+  }
+
+  .radio-mark::before {
+    content: "( )";
+  }
+  input:checked + .radio-mark::before {
+    content: "(-)";
+  }
+
+  input:focus-visible + .radio-mark {
+    outline: 1px dashed var(--ink);
+    outline-offset: 1px;
+  }
+
+  &:hover:not(:has(input:disabled)) {
+    text-decoration: underline;
+    text-underline-offset: 3px;
+  }
+
+  &:has(input:disabled) {
+    color: var(--ink-soft);
+    opacity: 0.55;
+    cursor: not-allowed;
+  }
+}
 

diff --git a/ooknet-design/src/components/Radio/Radio.ts b/ooknet-design/src/compo
nents/Radio/Radio.ts
new file mode 100644
index 0000000..51ec8a6
--- /dev/null
+++ b/ooknet-design/src/components/Radio/Radio.ts
@@ -0,0 +1,12 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  name: string;
+  value: string;
+  checked?: boolean;
+  disabled?: boolean;
+}
+
+// "(-) " + label
+export const radioWidth = (label: string) => len(label) + 4;

diff --git a/ooknet-design/src/components/Ra
dio/Radio.ts b/ooknet-design/src/components/
Radio/Radio.ts
new file mode 100644
index 0000000..51ec8a6
--- /dev/null
+++ b/ooknet-design/src/components/Radio/Rad
io.ts
@@ -0,0 +1,12 @@
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  label: string;
+  name: string;
+  value: string;
+  checked?: boolean;
+  disabled?: boolean;
+}
+
+// "(-) " + label
+export const radioWidth = (label: string) =
> len(label) + 4;
 

diff --git a/ooknet-design/src/components/Select/Select.astro b/ooknet-design/src/
components/Select/Select.astro
new file mode 100644
index 0000000..d9d10eb
--- /dev/null
+++ b/ooknet-design/src/components/Select/Select.astro
@@ -0,0 +1,10 @@
+---
+import type { Props, SelectOption } from "./Select";
+import "./Select.scss";
+
+const { name, options, cells, value } = Astro.props as Props;
+const opts: SelectOption[] = options.map((o) =>
+  typeof o === "string" ? { value: o, label: o } : o,
+);
+---
+<span class="select">{"[ "}<select name={name} style={`width: calc(var(--cell-w) 
* ${cells})`}>{opts.map((o) => <option value={o.value} selected={o.value === value
}>{o.label}</option>)}</select>{" ↓ ]"}</span>

diff --git a/ooknet-design/src/components/Se
lect/Select.astro b/ooknet-design/src/compon
ents/Select/Select.astro
new file mode 100644
index 0000000..d9d10eb
--- /dev/null
+++ b/ooknet-design/src/components/Select/Se
lect.astro
@@ -0,0 +1,10 @@
+---
+import type { Props, SelectOption } from ".
/Select";
+import "./Select.scss";
+
+const { name, options, cells, value } = Ast
ro.props as Props;
+const opts: SelectOption[] = options.map((o
) =>
+  typeof o === "string" ? { value: o, label
: o } : o,
+);
+---
+<span class="select">{"[ "}<select name={na
me} style={`width: calc(var(--cell-w) * ${ce
lls})`}>{opts.map((o) => <option value={o.va
lue} selected={o.value === value}>{o.label}<
/option>)}</select>{" ↓ ]"}</span>
 

diff --git a/ooknet-design/src/components/Select/Select.scss b/ooknet-design/src/c
omponents/Select/Select.scss
new file mode 100644
index 0000000..2a42ca4
--- /dev/null
+++ b/ooknet-design/src/components/Select/Select.scss
@@ -0,0 +1,19 @@
+.select {
+  white-space: pre;
+
+  select {
+    appearance: none;
+    font: inherit;
+    color: inherit;
+    background: transparent;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: pointer;
+
+    &:focus-visible {
+      outline: 1px dashed currentColor;
+      outline-offset: 2px;
+    }
+  }
+}

diff --git a/ooknet-design/src/components/Se
lect/Select.scss b/ooknet-design/src/compone
nts/Select/Select.scss
new file mode 100644
index 0000000..2a42ca4
--- /dev/null
+++ b/ooknet-design/src/components/Select/Se
lect.scss
@@ -0,0 +1,19 @@
+.select {
+  white-space: pre;
+
+  select {
+    appearance: none;
+    font: inherit;
+    color: inherit;
+    background: transparent;
+    border: 0;
+    padding: 0;
+    margin: 0;
+    cursor: pointer;
+
+    &:focus-visible {
+      outline: 1px dashed currentColor;
+      outline-offset: 2px;
+    }
+  }
+}
 

diff --git a/ooknet-design/src/components/Select/Select.ts b/ooknet-design/src/com
ponents/Select/Select.ts
new file mode 100644
index 0000000..8e3599c
--- /dev/null
+++ b/ooknet-design/src/components/Select/Select.ts
@@ -0,0 +1,17 @@
+export interface SelectOption {
+  value: string;
+  label: string;
+}
+
+export interface Props {
+  name: string;
+  options: (SelectOption | string)[];
+  /** Width of the value area in monospace cells. */
+  cells: number;
+  value?: string;
+}
+
+// "[ " + value area + " ↓ ]". The arrow must be a glyph Cascadia Mono
+// actually ships (U+2193) - U+25BE falls back to a wider font and
+// breaks the grid.
+export const selectWidth = (cells: number) => cells + 6;

diff --git a/ooknet-design/src/components/Se
lect/Select.ts b/ooknet-design/src/component
s/Select/Select.ts
new file mode 100644
index 0000000..8e3599c
--- /dev/null
+++ b/ooknet-design/src/components/Select/Se
lect.ts
@@ -0,0 +1,17 @@
+export interface SelectOption {
+  value: string;
+  label: string;
+}
+
+export interface Props {
+  name: string;
+  options: (SelectOption | string)[];
+  /** Width of the value area in monospace 
cells. */
+  cells: number;
+  value?: string;
+}
+
+// "[ " + value area + " ↓ ]". The arrow mu
st be a glyph Cascadia Mono
+// actually ships (U+2193) - U+25BE falls b
ack to a wider font and
+// breaks the grid.
+export const selectWidth = (cells: number) 
=> cells + 6;
 

diff --git a/ooknet-design/src/components/TextField/TextField.astro b/ooknet-desig
n/src/components/TextField/TextField.astro
new file mode 100644
index 0000000..68b0f1a
--- /dev/null
+++ b/ooknet-design/src/components/TextField/TextField.astro
@@ -0,0 +1,8 @@
+---
+import type { Props } from "./TextField";
+import "./TextField.scss";
+
+const { name, cells, type = "text", value } = Astro.props as Props;
+const seat = "_".repeat(cells);
+---
+<input class="text-field" type={type} name={name} value={value} placeholder={seat
} maxlength={cells} autocomplete="off" spellcheck="false" style={`width: calc(var(
--cell-w) * ${cells})`} aria-label={name} />

diff --git a/ooknet-design/src/components/Te
xtField/TextField.astro b/ooknet-design/src/
components/TextField/TextField.astro
new file mode 100644
index 0000000..68b0f1a
--- /dev/null
+++ b/ooknet-design/src/components/TextField
/TextField.astro
@@ -0,0 +1,8 @@
+---
+import type { Props } from "./TextField";
+import "./TextField.scss";
+
+const { name, cells, type = "text", value }
 = Astro.props as Props;
+const seat = "_".repeat(cells);
+---
+<input class="text-field" type={type} name=
{name} value={value} placeholder={seat} maxl
ength={cells} autocomplete="off" spellcheck=
"false" style={`width: calc(var(--cell-w) * 
${cells})`} aria-label={name} />
 

diff --git a/ooknet-design/src/components/TextField/TextField.scss b/ooknet-design
/src/components/TextField/TextField.scss
new file mode 100644
index 0000000..d8ea7b3
--- /dev/null
+++ b/ooknet-design/src/components/TextField/TextField.scss
@@ -0,0 +1,22 @@
+// Occupies exactly `cells` monospace cells inside a pre.ascii row; the
+// underscore placeholder stands in for the empty seat.
+.text-field {
+  font: inherit;
+  color: inherit;
+  background: transparent;
+  border: 0;
+  outline: 0;
+  padding: 0;
+  margin: 0;
+  caret-color: currentColor;
+
+  &::placeholder {
+    color: var(--ink-soft);
+    opacity: 1;
+  }
+
+  &:focus-visible {
+    outline: 1px dashed currentColor;
+    outline-offset: 2px;
+  }
+}

diff --git a/ooknet-design/src/components/Te
xtField/TextField.scss b/ooknet-design/src/c
omponents/TextField/TextField.scss
new file mode 100644
index 0000000..d8ea7b3
--- /dev/null
+++ b/ooknet-design/src/components/TextField
/TextField.scss
@@ -0,0 +1,22 @@
+// Occupies exactly `cells` monospace cells
 inside a pre.ascii row; the
+// underscore placeholder stands in for the
 empty seat.
+.text-field {
+  font: inherit;
+  color: inherit;
+  background: transparent;
+  border: 0;
+  outline: 0;
+  padding: 0;
+  margin: 0;
+  caret-color: currentColor;
+
+  &::placeholder {
+    color: var(--ink-soft);
+    opacity: 1;
+  }
+
+  &:focus-visible {
+    outline: 1px dashed currentColor;
+    outline-offset: 2px;
+  }
+}
 

diff --git a/ooknet-design/src/components/TextField/TextField.ts b/ooknet-design/s
rc/components/TextField/TextField.ts
new file mode 100644
index 0000000..d624502
--- /dev/null
+++ b/ooknet-design/src/components/TextField/TextField.ts
@@ -0,0 +1,8 @@
+export interface Props {
+  name: string;
+  /** Input width in monospace cells. Also caps input length so typed
+   *  text can't overflow the grid. */
+  cells: number;
+  type?: string;
+  value?: string;
+}

diff --git a/ooknet-design/src/components/Te
xtField/TextField.ts b/ooknet-design/src/com
ponents/TextField/TextField.ts
new file mode 100644
index 0000000..d624502
--- /dev/null
+++ b/ooknet-design/src/components/TextField
/TextField.ts
@@ -0,0 +1,8 @@
+export interface Props {
+  name: string;
+  /** Input width in monospace cells. Also 
caps input length so typed
+   *  text can't overflow the grid. */
+  cells: number;
+  type?: string;
+  value?: string;
+}
 

diff --git a/ooknet-design/src/components/TopHeader/TopHeader.astro b/ooknet-desig
n/src/components/TopHeader/TopHeader.astro
index 1713dc7..1bb8d38 100644
--- a/ooknet-design/src/components/TopHeader/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader/TopHeader.astro
@@ -149,4 +149,16 @@ const PLACEHOLDER_NARROW = "_".repeat(18);
   document.addEventListener("input", onInput);
   document.addEventListener("keydown", onKeydown);
   document.addEventListener("click", onDocClick);
+
+  // Tag badges link to /?q=<tag>; prefill the search and show results.
+  const q = new URLSearchParams(window.location.search).get("q");
+  if (q) {
+    const visible = Array.from(document.querySelectorAll<HTMLInputElement>("[data
-search-input]"))
+      .find((el) => el.offsetParent !== null);
+    if (visible) {
+      visible.value = q.slice(0, Number(visible.dataset.cells) || 20);
+      visible.dispatchEvent(new Event("input", { bubbles: true }));
+      visible.focus();
+    }
+  }
 </script>

diff --git a/ooknet-design/src/components/To
pHeader/TopHeader.astro b/ooknet-design/src/
components/TopHeader/TopHeader.astro
index 1713dc7..1bb8d38 100644
--- a/ooknet-design/src/components/TopHeader
/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader
/TopHeader.astro
@@ -149,4 +149,16 @@ const PLACEHOLDER_NARRO
W = "_".repeat(18);
   document.addEventListener("input", onInpu
t);
   document.addEventListener("keydown", onKe
ydown);
   document.addEventListener("click", onDocC
lick);
+
+  // Tag badges link to /?q=<tag>; prefill 
the search and show results.
+  const q = new URLSearchParams(window.loca
tion.search).get("q");
+  if (q) {
+    const visible = Array.from(document.que
rySelectorAll<HTMLInputElement>("[data-searc
h-input]"))
+      .find((el) => el.offsetParent !== nul
l);
+    if (visible) {
+      visible.value = q.slice(0, Number(vis
ible.dataset.cells) || 20);
+      visible.dispatchEvent(new Event("inpu
t", { bubbles: true }));
+      visible.focus();
+    }
+  }
 </script>
 

diff --git a/ooknet-design/src/content/notes/n0-style-sheet.md b/ooknet-design/src
/content/notes/n0-style-sheet.md
new file mode 100644
index 0000000..e7f2cb0
--- /dev/null
+++ b/ooknet-design/src/content/notes/n0-style-sheet.md
@@ -0,0 +1,72 @@
+---
+title: "STYLE SHEET"
+note: "N0"
+volume: "VOLUME 00 / NOTE 000"
+date: "JUL 2026"
+authors: ["OOKNET"]
+published: "2026-07-16"
+status: "REFERENCE"
+meta:
+  source: "ooknet"
+  category: "design-system"
+  tags: ["design-system", "reference", "typography"]
+  version: "v0.1"
+filed: "/META/STYLE/SHEET"
+page: 0
+---
+
+# Purpose
+
+This sheet exercises every markdown construct the ASCII pipeline
+renders. If a change to the design system breaks layout, it should be
+visible on this page first.
+
+# Tables
+
+| GLYPH SET | CELLS | STATUS   |
+| --------- | ----: | -------- |
+| single    |    86 | ACTIVE   |
+| double    |    48 | RESERVED |
+| heavy     |    86 | DROPPED  |
+
+A wider table that must shrink and wrap on the narrow frame:
+
+| COMPONENT | RESPONSIBILITY                            | NOTES                  
      |
+| --------- | ----------------------------------------- | -----------------------
----- |
+| Frame     | renders a build function at both widths   | static content only    
      |
+| TextField | input seated on an underscore placeholder | occupies exact cells   
      |
+| Select    | native dropdown inside bracket chrome     | arrow glyph must be on-
grid  |
+
+# Callouts
+
+> [!NOTE]
+> The frame font must ship every glyph the renderer emits. Unknown
+> codepoints fall back to a wider face and break the cell grid.
+
+> [!WARNING]
+> Interactive elements cannot dual-render: duplicating an input
+> duplicates its name and splits radio state across variants.
+
+> A plain blockquote is unaffected by the callout transform and keeps
+> its italic rail styling.
+
+# Figures
+
+![Dual-width frame rendering](/figures/n0-frame-schematic.svg)
+
+# Code
+
+```ts
+export function pad(s: unknown, n: number): string {
+  const str = String(s ?? "");
+  return str + " ".repeat(Math.max(0, n - len(str)));
+}
+```
+
+# Lists
+
+- frames are strings, components are chrome
+- the grid is the contract
+  1. measure in cells
+  2. wrap at build time
+  3. never trust `ch`

diff --git a/ooknet-design/src/content/notes
/n0-style-sheet.md b/ooknet-design/src/conte
nt/notes/n0-style-sheet.md
new file mode 100644
index 0000000..e7f2cb0
--- /dev/null
+++ b/ooknet-design/src/content/notes/n0-sty
le-sheet.md
@@ -0,0 +1,72 @@
+---
+title: "STYLE SHEET"
+note: "N0"
+volume: "VOLUME 00 / NOTE 000"
+date: "JUL 2026"
+authors: ["OOKNET"]
+published: "2026-07-16"
+status: "REFERENCE"
+meta:
+  source: "ooknet"
+  category: "design-system"
+  tags: ["design-system", "reference", "typ
ography"]
+  version: "v0.1"
+filed: "/META/STYLE/SHEET"
+page: 0
+---
+
+# Purpose
+
+This sheet exercises every markdown constru
ct the ASCII pipeline
+renders. If a change to the design system b
reaks layout, it should be
+visible on this page first.
+
+# Tables
+
+| GLYPH SET | CELLS | STATUS   |
+| --------- | ----: | -------- |
+| single    |    86 | ACTIVE   |
+| double    |    48 | RESERVED |
+| heavy     |    86 | DROPPED  |
+
+A wider table that must shrink and wrap on 
the narrow frame:
+
+| COMPONENT | RESPONSIBILITY               
             | NOTES                        
|
+| --------- | -----------------------------
------------ | ---------------------------- 
|
+| Frame     | renders a build function at b
oth widths   | static content only          
|
+| TextField | input seated on an underscore
 placeholder | occupies exact cells         
|
+| Select    | native dropdown inside bracke
t chrome     | arrow glyph must be on-grid  
|
+
+# Callouts
+
+> [!NOTE]
+> The frame font must ship every glyph the 
renderer emits. Unknown
+> codepoints fall back to a wider face and 
break the cell grid.
+
+> [!WARNING]
+> Interactive elements cannot dual-render: 
duplicating an input
+> duplicates its name and splits radio stat
e across variants.
+
+> A plain blockquote is unaffected by the c
allout transform and keeps
+> its italic rail styling.
+
+# Figures
+
+![Dual-width frame rendering](/figures/n0-f
rame-schematic.svg)
+
+# Code
+
+```ts
+export function pad(s: unknown, n: number):
 string {
+  const str = String(s ?? "");
+  return str + " ".repeat(Math.max(0, n - l
en(str)));
+}
+```
+
+# Lists
+
+- frames are strings, components are chrome
+- the grid is the contract
+  1. measure in cells
+  2. wrap at build time
+  3. never trust `ch`
 

diff --git a/ooknet-design/src/lib/ascii.ts b/ooknet-design/src/lib/ascii.ts
index 4c2efa8..466add5 100644
--- a/ooknet-design/src/lib/ascii.ts
+++ b/ooknet-design/src/lib/ascii.ts
@@ -3,8 +3,8 @@
 export const G = {
   H: "─", V: "│",
   TL: "┌", TR: "┐", BL: "└", BR: "┘",
-  LJ: "├", RJ: "┤",
-  D: "═",
+  LJ: "├", RJ: "┤", TJ: "┬", BJ: "┴", X: "┼",
+  D: "═", HD: "╌",
 };
 
 // Codepoint length, not UTF-16 units - frame math breaks on surrogate

diff --git a/ooknet-design/src/lib/ascii.ts 
b/ooknet-design/src/lib/ascii.ts
index 4c2efa8..466add5 100644
--- a/ooknet-design/src/lib/ascii.ts
+++ b/ooknet-design/src/lib/ascii.ts
@@ -3,8 +3,8 @@
 export const G = {
   H: "─", V: "│",
   TL: "┌", TR: "┐", BL: "└", BR: "┘",
-  LJ: "├", RJ: "┤",
-  D: "═",
+  LJ: "├", RJ: "┤", TJ: "┬", BJ: "┴", X: "┼
",
+  D: "═", HD: "╌",
 };
 
 // Codepoint length, not UTF-16 units - fra
me math breaks on surrogate
 

diff --git a/ooknet-design/src/lib/rehype-ascii.ts b/ooknet-design/src/lib/rehype-
ascii.ts
index bfc7b18..c9a4a9c 100644
--- a/ooknet-design/src/lib/rehype-ascii.ts
+++ b/ooknet-design/src/lib/rehype-ascii.ts
@@ -1,11 +1,14 @@
-// Frames fenced code blocks in an ASCII box and appends a head-rule
-// span under each <h1>. Emits both the desktop and mobile variants in
-// the same tree; CSS toggles visibility per viewport.
+// Markdown ? ASCII chrome, applied at build time. Fenced code blocks
+// and tables become framed boxes, [!NOTE]-style blockquotes become
+// labeled callouts, lone images become numbered figures, and each <h1>
+// 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, len } from "./ascii";
+import { G, len, pad, wrap, type PadSide } from "./ascii";
 import { FRAME_W, MOBILE_FRAME_W } from "./config";
 
 const FN_RE = /\b([a-zA-Z_]\w*)(?=\s*\()/g;
+const CALLOUT_RE = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/;
 
 function visitElements(node: Root | RootContent, fn: (el: Element) => void): void
 {
   if ((node as Element).type === "element") fn(node as Element);
@@ -13,6 +16,17 @@ function visitElements(node: Root | RootContent, fn: (el: Eleme
nt) => void): voi
   if (children) for (const c of children) visitElements(c, fn);
 }
 
+function hasClass(el: Element, cls: string): boolean {
+  const c = (el.properties as Properties | undefined)?.className;
+  return Array.isArray(c) && c.includes(cls);
+}
+
+function textContent(node: RootContent): string {
+  if (node.type === "text") return node.value;
+  const kids = (node as { children?: RootContent[] }).children;
+  return kids ? kids.map(textContent).join("") : "";
+}
+
 function frame(value: string): Element {
   return {
     type: "element",
@@ -22,6 +36,15 @@ function frame(value: string): Element {
   };
 }
 
+function blockSpan(cls: string, variant: string, children: ElementContent[]): Ele
ment {
+  return {
+    type: "element",
+    tagName: "span",
+    properties: { className: [cls, variant], "aria-hidden": "true" },
+    children,
+  };
+}
+
 const copyBtn: Element = {
   type: "element",
   tagName: "button",
@@ -29,6 +52,8 @@ const copyBtn: Element = {
   children: [{ type: "text", value: "[ COPY ]" }],
 };
 
+// ---------------------------------------------------------------- code
+
 function buildBoxChildren(text: string, lang: string, frameW: number): ElementCon
tent[] {
   const inner = frameW - 4;
   const lines = text.replace(/\n+$/, "").split("\n").flatMap((line) => {
@@ -93,23 +118,125 @@ function makeCodePre(text: string, lang: string, frameW: num
ber, extraClass: str
   };
 }
 
-function ruleSpan(frameW: number, variant: "frame-wide" | "frame-narrow"): Elemen
t {
+// --------------------------------------------------------------- table
+
+type Cell = { text: string; align: PadSide };
+
+function tableCells(tbl: Element): { rows: Cell[][]; headerRows: number } {
+  const rows: Cell[][] = [];
+  let headerRows = 0;
+  visitElements(tbl, (el) => {
+    if (el.tagName !== "tr") return;
+    const cells: Cell[] = [];
+    let isHeader = false;
+    for (const c of el.children) {
+      if (c.type !== "element" || (c.tagName !== "td" && c.tagName !== "th")) con
tinue;
+      if (c.tagName === "th") isHeader = true;
+      const a = (c.properties as Properties | undefined)?.align;
+      const align: PadSide = a === "right" ? "start" : a === "center" ? "center" 
: "end";
+      cells.push({ text: textContent(c).replace(/\s+/g, " ").trim(), align });
+    }
+    if (cells.length) {
+      rows.push(cells);
+      if (isHeader) headerRows = rows.length;
+    }
+  });
+  return { rows, headerRows };
+}
+
+function buildAsciiTable(rows: Cell[][], headerRows: number, width: number): stri
ng {
+  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 out.join("\n");
+}
+
+function makeTablePre(text: string, extraClass: string): Element {
   return {
     type: "element",
-    tagName: "span",
-    properties: { className: ["head-rule", variant] },
-    children: [{ type: "text", value: G.H.repeat(frameW) }],
+    tagName: "pre",
+    properties: { className: ["ascii", "table-frame", extraClass], "aria-hidden":
 "true" },
+    children: [{ type: "text", value: text }],
   };
 }
 
+// ------------------------------------------------------------ callouts
+
+function calloutTop(label: string, frameW: number, variant: string): Element {
+  return blockSpan("callout-rule", variant, [
+    { type: "text", value: `${G.TL}${G.H} ` },
+    {
+      type: "element",
+      tagName: "strong",
+      properties: {},
+      children: [{ type: "text", value: label }],
+    },
+    { type: "text", value: ` ${G.H.repeat(Math.max(0, frameW - 5 - len(label)))}$
{G.TR}` },
+  ]);
+}
+
+function calloutBottom(frameW: number, variant: string): Element {
+  return blockSpan("callout-rule", variant, [
+    { type: "text", value: `${G.BL}${G.H.repeat(frameW - 2)}${G.BR}` },
+  ]);
+}
+
+// ------------------------------------------------------------- shared
+
+function ruleSpan(frameW: number, variant: "frame-wide" | "frame-narrow", cls = "
head-rule"): Element {
+  return blockSpan(cls, variant, [{ type: "text", value: G.H.repeat(frameW) }]);
+}
+
+function captionSpan(text: string, frameW: number, variant: string): Element {
+  return blockSpan("fig-caption", variant, [{ type: "text", value: wrap(text, fra
meW) }]);
+}
+
 export function rehypeAscii() {
   return (tree: Root) => {
+    let fig = 0;
+
     visitElements(tree, (node) => {
       if (node.tagName === "pre") {
         // Skip the dual-rendered pres we emit ourselves; otherwise the
         // visitor recurses into them and reprocesses indefinitely.
-        const cls = (node.properties as Properties | undefined)?.className;
-        if (Array.isArray(cls) && cls.includes("code-block")) return;
+        if (hasClass(node, "code-block") || hasClass(node, "table-frame")) return
;
 
         const codeEl = node.children.find(
           (c): c is Element => c.type === "element" && c.tagName === "code",
@@ -134,11 +261,79 @@ export function rehypeAscii() {
         return;
       }
 
+      if (node.tagName === "table") {
+        if (hasClass(node, "sr-only")) return;
+        const { rows, headerRows } = tableCells(node);
+        if (!rows.length) return;
+
+        // Keep the semantic table for screen readers; the ASCII pres
+        // are presentation only.
+        const original: Element = {
+          type: "element",
+          tagName: "table",
+          properties: { className: ["sr-only"] },
+          children: node.children,
+        };
+        node.tagName = "div";
+        node.properties = { className: ["table-frames"] };
+        node.children = [
+          makeTablePre(buildAsciiTable(rows, headerRows, FRAME_W), "frame-wide"),
+          makeTablePre(buildAsciiTable(rows, headerRows, MOBILE_FRAME_W), "frame-
narrow"),
+          original,
+        ];
+        return;
+      }
+
+      if (node.tagName === "blockquote" && !hasClass(node, "callout")) {
+        const firstP = node.children.find(
+          (c): c is Element => c.type === "element" && c.tagName === "p",
+        );
+        const firstText = firstP?.children[0];
+        if (!firstP || !firstText || firstText.type !== "text") return;
+        const m = firstText.value.match(CALLOUT_RE);
+        if (!m) return;
+
+        const label = m[1];
+        firstText.value = firstText.value.slice(m[0].length);
+        if (!firstText.value) firstP.children.shift();
+        if (!firstP.children.length) node.children = node.children.filter((c) => 
c !== firstP);
+
+        node.properties = { className: ["callout"] };
+        node.children = [
+          calloutTop(label, FRAME_W, "frame-wide"),
+          calloutTop(label, MOBILE_FRAME_W, "frame-narrow"),
+          ...node.children,
+          calloutBottom(FRAME_W, "frame-wide"),
+          calloutBottom(MOBILE_FRAME_W, "frame-narrow"),
+        ];
+        return;
+      }
+
+      if (node.tagName === "p") {
+        const kids = node.children.filter((c) => !(c.type === "text" && !c.value.
trim()));
+        const img = kids[0];
+        if (kids.length !== 1 || img.type !== "element" || img.tagName !== "img")
 return;
+
+        fig++;
+        const alt = String((img.properties as Properties | undefined)?.alt ?? "")
.trim();
+        const caption = alt ? `FIG. ${fig} - ${alt.toUpperCase()}` : `FIG. ${fig}
`;
+        node.tagName = "figure";
+        node.properties = { className: ["figure-frame"] };
+        node.children = [
+          ruleSpan(FRAME_W, "frame-wide", "fig-rule"),
+          ruleSpan(MOBILE_FRAME_W, "frame-narrow", "fig-rule"),
+          img,
+          captionSpan(caption, FRAME_W, "frame-wide"),
+          captionSpan(caption, MOBILE_FRAME_W, "frame-narrow"),
+          ruleSpan(FRAME_W, "frame-wide", "fig-rule"),
+          ruleSpan(MOBILE_FRAME_W, "frame-narrow", "fig-rule"),
+        ];
+        return;
+      }
+
       if (node.tagName === "h1") {
         const already = node.children.some(
-          (c) => c.type === "element" && c.tagName === "span"
-            && Array.isArray((c.properties as Properties | undefined)?.className)
-            && ((c.properties as Properties).className as unknown[]).includes("he
ad-rule"),
+          (c) => c.type === "element" && c.tagName === "span" && hasClass(c, "hea
d-rule"),
         );
         if (already) return;
         node.children = [

diff --git a/ooknet-design/src/lib/rehype-as
cii.ts b/ooknet-design/src/lib/rehype-ascii.
ts
index bfc7b18..c9a4a9c 100644
--- a/ooknet-design/src/lib/rehype-ascii.ts
+++ b/ooknet-design/src/lib/rehype-ascii.ts
@@ -1,11 +1,14 @@
-// Frames fenced code blocks in an ASCII bo
x and appends a head-rule
-// span under each <h1>. Emits both the des
ktop and mobile variants in
-// the same tree; CSS toggles visibility pe
r viewport.
+// Markdown ? ASCII chrome, applied at buil
d time. Fenced code blocks
+// and tables become framed boxes, [!NOTE]-
style blockquotes become
+// labeled callouts, lone images become num
bered figures, and each <h1>
+// 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, len } from "./ascii";
+import { G, len, pad, wrap, type PadSide } 
from "./ascii";
 import { FRAME_W, MOBILE_FRAME_W } from "./
config";
 
 const FN_RE = /\b([a-zA-Z_]\w*)(?=\s*\()/g;
+const CALLOUT_RE = /^\[!(NOTE|TIP|IMPORTANT
|WARNING|CAUTION)\]\s*/;
 
 function visitElements(node: Root | RootCon
tent, fn: (el: Element) => void): void {
   if ((node as Element).type === "element")
 fn(node as Element);
@@ -13,6 +16,17 @@ function visitElements(no
de: Root | RootContent, fn: (el: Element) =>
 void): voi
   if (children) for (const c of children) v
isitElements(c, fn);
 }
 
+function hasClass(el: Element, cls: string)
: boolean {
+  const c = (el.properties as Properties | 
undefined)?.className;
+  return Array.isArray(c) && c.includes(cls
);
+}
+
+function textContent(node: RootContent): st
ring {
+  if (node.type === "text") return node.val
ue;
+  const kids = (node as { children?: RootCo
ntent[] }).children;
+  return kids ? kids.map(textContent).join(
"") : "";
+}
+
 function frame(value: string): Element {
   return {
     type: "element",
@@ -22,6 +36,15 @@ function frame(value: str
ing): Element {
   };
 }
 
+function blockSpan(cls: string, variant: st
ring, children: ElementContent[]): Element {
+  return {
+    type: "element",
+    tagName: "span",
+    properties: { className: [cls, variant]
, "aria-hidden": "true" },
+    children,
+  };
+}
+
 const copyBtn: Element = {
   type: "element",
   tagName: "button",
@@ -29,6 +52,8 @@ const copyBtn: Element = {
   children: [{ type: "text", value: "[ COPY
 ]" }],
 };
 
+// ----------------------------------------
------------------------ code
+
 function buildBoxChildren(text: string, lan
g: string, frameW: number): ElementContent[]
 {
   const inner = frameW - 4;
   const lines = text.replace(/\n+$/, "").sp
lit("\n").flatMap((line) => {
@@ -93,23 +118,125 @@ function makeCodePre(t
ext: string, lang: string, frameW: number, e
xtraClass: str
   };
 }
 
-function ruleSpan(frameW: number, variant: 
"frame-wide" | "frame-narrow"): Element {
+// ----------------------------------------
----------------------- table
+
+type Cell = { text: string; align: PadSide 
};
+
+function tableCells(tbl: Element): { rows: 
Cell[][]; headerRows: number } {
+  const rows: Cell[][] = [];
+  let headerRows = 0;
+  visitElements(tbl, (el) => {
+    if (el.tagName !== "tr") return;
+    const cells: Cell[] = [];
+    let isHeader = false;
+    for (const c of el.children) {
+      if (c.type !== "element" || (c.tagNam
e !== "td" && c.tagName !== "th")) continue;
+      if (c.tagName === "th") isHeader = tr
ue;
+      const a = (c.properties as Properties
 | undefined)?.align;
+      const align: PadSide = a === "right" 
? "start" : a === "center" ? "center" : "end
";
+      cells.push({ text: textContent(c).rep
lace(/\s+/g, " ").trim(), align });
+    }
+    if (cells.length) {
+      rows.push(cells);
+      if (isHeader) headerRows = rows.lengt
h;
+    }
+  });
+  return { rows, headerRows };
+}
+
+function buildAsciiTable(rows: Cell[][], he
aderRows: 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({ 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 out.join("\n");
+}
+
+function makeTablePre(text: string, extraCl
ass: string): Element {
   return {
     type: "element",
-    tagName: "span",
-    properties: { className: ["head-rule", 
variant] },
-    children: [{ type: "text", value: G.H.r
epeat(frameW) }],
+    tagName: "pre",
+    properties: { className: ["ascii", "tab
le-frame", extraClass], "aria-hidden": "true
" },
+    children: [{ type: "text", value: text 
}],
   };
 }
 
+// ----------------------------------------
-------------------- callouts
+
+function calloutTop(label: string, frameW: 
number, variant: string): Element {
+  return blockSpan("callout-rule", variant,
 [
+    { type: "text", value: `${G.TL}${G.H} `
 },
+    {
+      type: "element",
+      tagName: "strong",
+      properties: {},
+      children: [{ type: "text", value: lab
el }],
+    },
+    { type: "text", value: ` ${G.H.repeat(M
ath.max(0, frameW - 5 - len(label)))}${G.TR}
` },
+  ]);
+}
+
+function calloutBottom(frameW: number, vari
ant: string): Element {
+  return blockSpan("callout-rule", variant,
 [
+    { type: "text", value: `${G.BL}${G.H.re
peat(frameW - 2)}${G.BR}` },
+  ]);
+}
+
+// ----------------------------------------
--------------------- shared
+
+function ruleSpan(frameW: number, variant: 
"frame-wide" | "frame-narrow", cls = "head-r
ule"): Element {
+  return blockSpan(cls, variant, [{ type: "
text", value: G.H.repeat(frameW) }]);
+}
+
+function captionSpan(text: string, frameW: 
number, variant: string): Element {
+  return blockSpan("fig-caption", variant, 
[{ type: "text", value: wrap(text, frameW) }
]);
+}
+
 export function rehypeAscii() {
   return (tree: Root) => {
+    let fig = 0;
+
     visitElements(tree, (node) => {
       if (node.tagName === "pre") {
         // Skip the dual-rendered pres we e
mit ourselves; otherwise the
         // visitor recurses into them and r
eprocesses indefinitely.
-        const cls = (node.properties as Pro
perties | undefined)?.className;
-        if (Array.isArray(cls) && cls.inclu
des("code-block")) return;
+        if (hasClass(node, "code-block") ||
 hasClass(node, "table-frame")) return;
 
         const codeEl = node.children.find(
           (c): c is Element => c.type === "
element" && c.tagName === "code",
@@ -134,11 +261,79 @@ export function rehype
Ascii() {
         return;
       }
 
+      if (node.tagName === "table") {
+        if (hasClass(node, "sr-only")) retu
rn;
+        const { rows, headerRows } = tableC
ells(node);
+        if (!rows.length) return;
+
+        // Keep the semantic table for scre
en readers; the ASCII pres
+        // are presentation only.
+        const original: Element = {
+          type: "element",
+          tagName: "table",
+          properties: { className: ["sr-onl
y"] },
+          children: node.children,
+        };
+        node.tagName = "div";
+        node.properties = { className: ["ta
ble-frames"] };
+        node.children = [
+          makeTablePre(buildAsciiTable(rows
, headerRows, FRAME_W), "frame-wide"),
+          makeTablePre(buildAsciiTable(rows
, headerRows, MOBILE_FRAME_W), "frame-narrow
"),
+          original,
+        ];
+        return;
+      }
+
+      if (node.tagName === "blockquote" && 
!hasClass(node, "callout")) {
+        const firstP = node.children.find(
+          (c): c is Element => c.type === "
element" && c.tagName === "p",
+        );
+        const firstText = firstP?.children[
0];
+        if (!firstP || !firstText || firstT
ext.type !== "text") return;
+        const m = firstText.value.match(CAL
LOUT_RE);
+        if (!m) return;
+
+        const label = m[1];
+        firstText.value = firstText.value.s
lice(m[0].length);
+        if (!firstText.value) firstP.childr
en.shift();
+        if (!firstP.children.length) node.c
hildren = node.children.filter((c) => c !== 
firstP);
+
+        node.properties = { className: ["ca
llout"] };
+        node.children = [
+          calloutTop(label, FRAME_W, "frame
-wide"),
+          calloutTop(label, MOBILE_FRAME_W,
 "frame-narrow"),
+          ...node.children,
+          calloutBottom(FRAME_W, "frame-wid
e"),
+          calloutBottom(MOBILE_FRAME_W, "fr
ame-narrow"),
+        ];
+        return;
+      }
+
+      if (node.tagName === "p") {
+        const kids = node.children.filter((
c) => !(c.type === "text" && !c.value.trim()
));
+        const img = kids[0];
+        if (kids.length !== 1 || img.type !
== "element" || img.tagName !== "img") retur
n;
+
+        fig++;
+        const alt = String((img.properties 
as Properties | undefined)?.alt ?? "").trim(
);
+        const caption = alt ? `FIG. ${fig} 
- ${alt.toUpperCase()}` : `FIG. ${fig}`;
+        node.tagName = "figure";
+        node.properties = { className: ["fi
gure-frame"] };
+        node.children = [
+          ruleSpan(FRAME_W, "frame-wide", "
fig-rule"),
+          ruleSpan(MOBILE_FRAME_W, "frame-n
arrow", "fig-rule"),
+          img,
+          captionSpan(caption, FRAME_W, "fr
ame-wide"),
+          captionSpan(caption, MOBILE_FRAME
_W, "frame-narrow"),
+          ruleSpan(FRAME_W, "frame-wide", "
fig-rule"),
+          ruleSpan(MOBILE_FRAME_W, "frame-n
arrow", "fig-rule"),
+        ];
+        return;
+      }
+
       if (node.tagName === "h1") {
         const already = node.children.some(
-          (c) => c.type === "element" && c.
tagName === "span"
-            && Array.isArray((c.properties 
as Properties | undefined)?.className)
-            && ((c.properties as Properties
).className as unknown[]).includes("head-rul
e"),
+          (c) => c.type === "element" && c.
tagName === "span" && hasClass(c, "head-rule
"),
         );
         if (already) return;
         node.children = [
 

diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co
mponents.astro
new file mode 100644
index 0000000..899ca73
--- /dev/null
+++ b/ooknet-design/src/pages/components.astro
@@ -0,0 +1,90 @@
+---
+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 Button from "../components/Button/Button.astro";
+import { buttonWidth } from "../components/Button/Button";
+import TextField from "../components/TextField/TextField.astro";
+import Checkbox from "../components/Checkbox/Checkbox.astro";
+import Radio from "../components/Radio/Radio.astro";
+import { radioWidth } from "../components/Radio/Radio";
+import Select from "../components/Select/Select.astro";
+import { selectWidth } from "../components/Select/Select";
+import { G, rule, ruleD, row2, pad, len } from "../lib/ascii";
+import { MOBILE_FRAME_W } from "../lib/config";
+
+const section = (title: string, right: string) => (w: number) =>
+  [row2(title, right, w), ruleD(w)].join("\n");
+
+const LABEL_W = 12;
+
+// The form box is MOBILE_FRAME_W wide so one render serves both
+// viewports - interactive fields can't dual-render without duplicating
+// input names.
+const B = MOBILE_FRAME_W;
+const inner = B - 2;
+const boxTop = (t: string) => `${G.TL}${G.H} ${t} ${G.H.repeat(inner - 3 - len(t)
)}${G.TR}`;
+const boxBlank = `${G.V}${" ".repeat(inner)}${G.V}`;
+const boxBottom = `${G.BL}${rule(inner)}${G.BR}`;
+const open = `${G.V}  `;
+const close = (used: number) => `${" ".repeat(Math.max(0, inner - 2 - used))}${G.
V}`;
+
+const FIELD_CELLS = 26;
+const fieldUsed = LABEL_W + FIELD_CELLS;
+const chanUsed = LABEL_W + selectWidth(20);
+const submitUsed = buttonWidth("TRANSMIT") + 2 + buttonWidth("CLEAR");
+---
+<ArticleLayout title="OOKNET KB - Components">
+  <TopHeader />
+  <Frame build={(w) => [row2("COMPONENT INDEX", "DESIGN SYSTEM v0.1", w), ruleD(w
)].join("\n")} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("BUTTONS", "Button.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Button label="SUBMIT" variant="primary" />{"  "}<Button label="CANCEL" />
{"  "}<Button label="DISABLED" disabled />{"  "}<Button label="INDEX" href="/" /><
/Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TEXT FIELDS", "TextField.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("CALLSIGN", LABEL_W)}<TextField name="demo-callsign" cells={20} /></P
re>
+  <Pre>{pad("PASSPHRASE", LABEL_W)}<TextField name="demo-passphrase" cells={20} t
ype="password" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TOGGLES", "Checkbox.astro / Radio.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Checkbox label="ENCRYPT TRANSMISSION" name="demo-encrypt" checked /></Pre
>
+  <Pre><Checkbox label="NOTIFY ON REPLY" name="demo-notify" /></Pre>
+  <Pre><Checkbox label="DISABLED OPTION" name="demo-disabled" disabled /></Pre>
+  <Pre>{" "}</Pre>
+  <Pre><Radio label="ROUTINE" name="demo-priority" value="routine" checked /></Pr
e>
+  <Pre><Radio label="URGENT" name="demo-priority" value="urgent" /></Pre>
+  <Pre><Radio label="FLASH" name="demo-priority" value="flash" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SELECT", "Select.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("CHANNEL", LABEL_W)}<Select name="demo-channel" cells={20} options={[
"ALPHA", "BRAVO", "OOKNET-9"]} value="ALPHA" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("COMPOSED FORM", "48-cell box")} />
+  <Pre>{" "}</Pre>
+  <Pre>{boxTop("FIELD REPORT")}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}{pad("CALLSIGN", LABEL_W)}<TextField name="callsign" cells={FIELD_CE
LLS} />{close(fieldUsed)}</Pre>
+  <Pre>{open}{pad("FREQUENCY", LABEL_W)}<TextField name="frequency" cells={FIELD_
CELLS} />{close(fieldUsed)}</Pre>
+  <Pre>{open}{pad("CHANNEL", LABEL_W)}<Select name="channel" cells={20} options={
["ALPHA", "BRAVO", "OOKNET-9"]} value="ALPHA" />{close(chanUsed)}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}{pad("PRIORITY", LABEL_W)}<Radio label="ROUTINE" name="priority" val
ue="routine" checked />{close(LABEL_W + radioWidth("ROUTINE"))}</Pre>
+  <Pre>{open}{" ".repeat(LABEL_W)}<Radio label="URGENT" name="priority" value="ur
gent" />{close(LABEL_W + radioWidth("URGENT"))}</Pre>
+  <Pre>{open}{" ".repeat(LABEL_W)}<Radio label="FLASH" name="priority" value="fla
sh" />{close(LABEL_W + radioWidth("FLASH"))}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}<Checkbox label="ENCRYPT TRANSMISSION" name="encrypt" checked />{clo
se(len("ENCRYPT TRANSMISSION") + 4)}</Pre>
+  <Pre>{open}<Checkbox label="NOTIFY ON REPLY" name="notify" />{close(len("NOTIFY
 ON REPLY") + 4)}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}<Button label="TRANSMIT" variant="primary" />{"  "}<Button label="CL
EAR" />{close(submitUsed)}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{boxBottom}</Pre>
+  <Pre>{" "}</Pre>
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>

diff --git a/ooknet-design/src/pages/compone
nts.astro b/ooknet-design/src/pages/componen
ts.astro
new file mode 100644
index 0000000..899ca73
--- /dev/null
+++ b/ooknet-design/src/pages/components.ast
ro
@@ -0,0 +1,90 @@
+---
+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 Button from "../components/Button/Bu
tton.astro";
+import { buttonWidth } from "../components/
Button/Button";
+import TextField from "../components/TextFi
eld/TextField.astro";
+import Checkbox from "../components/Checkbo
x/Checkbox.astro";
+import Radio from "../components/Radio/Radi
o.astro";
+import { radioWidth } from "../components/R
adio/Radio";
+import Select from "../components/Select/Se
lect.astro";
+import { selectWidth } from "../components/
Select/Select";
+import { G, rule, ruleD, row2, pad, len } f
rom "../lib/ascii";
+import { MOBILE_FRAME_W } from "../lib/conf
ig";
+
+const section = (title: string, right: stri
ng) => (w: number) =>
+  [row2(title, right, w), ruleD(w)].join("\
n");
+
+const LABEL_W = 12;
+
+// The form box is MOBILE_FRAME_W wide so o
ne render serves both
+// viewports - interactive fields can't dua
l-render without duplicating
+// input names.
+const B = MOBILE_FRAME_W;
+const inner = B - 2;
+const boxTop = (t: string) => `${G.TL}${G.H
} ${t} ${G.H.repeat(inner - 3 - len(t))}${G.
TR}`;
+const boxBlank = `${G.V}${" ".repeat(inner)
}${G.V}`;
+const boxBottom = `${G.BL}${rule(inner)}${G
.BR}`;
+const open = `${G.V}  `;
+const close = (used: number) => `${" ".repe
at(Math.max(0, inner - 2 - used))}${G.V}`;
+
+const FIELD_CELLS = 26;
+const fieldUsed = LABEL_W + FIELD_CELLS;
+const chanUsed = LABEL_W + selectWidth(20);
+const submitUsed = buttonWidth("TRANSMIT") 
+ 2 + buttonWidth("CLEAR");
+---
+<ArticleLayout title="OOKNET KB - Component
s">
+  <TopHeader />
+  <Frame build={(w) => [row2("COMPONENT IND
EX", "DESIGN SYSTEM v0.1", w), ruleD(w)].joi
n("\n")} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("BUTTONS", "Button.
astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Button label="SUBMIT" variant="prim
ary" />{"  "}<Button label="CANCEL" />{"  "}
<Button label="DISABLED" disabled />{"  "}<B
utton label="INDEX" href="/" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TEXT FIELDS", "Tex
tField.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("CALLSIGN", LABEL_W)}<TextField
 name="demo-callsign" cells={20} /></Pre>
+  <Pre>{pad("PASSPHRASE", LABEL_W)}<TextFie
ld name="demo-passphrase" cells={20} type="p
assword" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TOGGLES", "Checkbo
x.astro / Radio.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Checkbox label="ENCRYPT TRANSMISSIO
N" name="demo-encrypt" checked /></Pre>
+  <Pre><Checkbox label="NOTIFY ON REPLY" na
me="demo-notify" /></Pre>
+  <Pre><Checkbox label="DISABLED OPTION" na
me="demo-disabled" disabled /></Pre>
+  <Pre>{" "}</Pre>
+  <Pre><Radio label="ROUTINE" name="demo-pr
iority" value="routine" checked /></Pre>
+  <Pre><Radio label="URGENT" name="demo-pri
ority" value="urgent" /></Pre>
+  <Pre><Radio label="FLASH" name="demo-prio
rity" value="flash" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SELECT", "Select.a
stro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("CHANNEL", LABEL_W)}<Select nam
e="demo-channel" cells={20} options={["ALPHA
", "BRAVO", "OOKNET-9"]} value="ALPHA" /></P
re>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("COMPOSED FORM", "4
8-cell box")} />
+  <Pre>{" "}</Pre>
+  <Pre>{boxTop("FIELD REPORT")}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}{pad("CALLSIGN", LABEL_W)}<Tex
tField name="callsign" cells={FIELD_CELLS} /
>{close(fieldUsed)}</Pre>
+  <Pre>{open}{pad("FREQUENCY", LABEL_W)}<Te
xtField name="frequency" cells={FIELD_CELLS}
 />{close(fieldUsed)}</Pre>
+  <Pre>{open}{pad("CHANNEL", LABEL_W)}<Sele
ct name="channel" cells={20} options={["ALPH
A", "BRAVO", "OOKNET-9"]} value="ALPHA" />{c
lose(chanUsed)}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}{pad("PRIORITY", LABEL_W)}<Rad
io label="ROUTINE" name="priority" value="ro
utine" checked />{close(LABEL_W + radioWidth
("ROUTINE"))}</Pre>
+  <Pre>{open}{" ".repeat(LABEL_W)}<Radio la
bel="URGENT" name="priority" value="urgent" 
/>{close(LABEL_W + radioWidth("URGENT"))}</P
re>
+  <Pre>{open}{" ".repeat(LABEL_W)}<Radio la
bel="FLASH" name="priority" value="flash" />
{close(LABEL_W + radioWidth("FLASH"))}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}<Checkbox label="ENCRYPT TRANS
MISSION" name="encrypt" checked />{close(len
("ENCRYPT TRANSMISSION") + 4)}</Pre>
+  <Pre>{open}<Checkbox label="NOTIFY ON REP
LY" name="notify" />{close(len("NOTIFY ON RE
PLY") + 4)}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{open}<Button label="TRANSMIT" varia
nt="primary" />{"  "}<Button label="CLEAR" /
>{close(submitUsed)}</Pre>
+  <Pre>{boxBlank}</Pre>
+  <Pre>{boxBottom}</Pre>
+  <Pre>{" "}</Pre>
+  <Frame build={(w) => rule(w)} />
+</ArticleLayout>
 

diff --git a/ooknet-design/src/pages/notes/[slug].astro b/ooknet-design/src/pages/
notes/[slug].astro
index 429df24..343b055 100644
--- a/ooknet-design/src/pages/notes/[slug].astro
+++ b/ooknet-design/src/pages/notes/[slug].astro
@@ -6,16 +6,31 @@ import ArticleHeader from "../../components/ArticleHeader/Articl
eHeader.astro";
 import DocumentTitle from "../../components/DocumentTitle/DocumentTitle.astro";
 import ArticleMeta from "../../components/ArticleMeta/ArticleMeta.astro";
 import ArticleSeeAlso from "../../components/ArticleSeeAlso/ArticleSeeAlso.astro"
;
+import ArticleTags from "../../components/ArticleTags/ArticleTags.astro";
+import ArticlePagination from "../../components/ArticlePagination/ArticlePaginati
on.astro";
 import ArticleFooter from "../../components/ArticleFooter/ArticleFooter.astro";
 import Prose from "../../components/Prose/Prose.astro";
+import Pre from "../../components/Pre/Pre.astro";
+import type { PageLink } from "../../components/ArticlePagination/ArticlePaginati
on";
 
 export async function getStaticPaths() {
   const notes = await getCollection("notes", ({ data }) => !data.draft);
-  return notes.map((n) => ({ params: { slug: n.id }, props: { note: n } }));
+  const num = (s: string) => parseInt(s.replace(/\D/g, ""), 10) || 0;
+  notes.sort((a, b) => num(a.data.note) - num(b.data.note));
+  const link = (n?: (typeof notes)[number]): PageLink | null =>
+    n ? { id: n.id, note: n.data.note } : null;
+  return notes.map((n, i) => ({
+    params: { slug: n.id },
+    props: { note: n, prev: link(notes[i - 1]), next: link(notes[i + 1]) },
+  }));
 }
 
-interface Props { note: CollectionEntry<"notes">; }
-const { note } = Astro.props as Props;
+interface Props {
+  note: CollectionEntry<"notes">;
+  prev: PageLink | null;
+  next: PageLink | null;
+}
+const { note, prev, next } = Astro.props as Props;
 const { Content } = await render(note);
 ---
 <ArticleLayout title={note.data.title}>
@@ -25,5 +40,8 @@ const { Content } = await render(note);
   <ArticleMeta fm={note.data} path={`notes/${note.id}`} />
   <Prose><Content /></Prose>
   <ArticleSeeAlso fm={note.data} />
+  <ArticleTags fm={note.data} />
+  <Pre>{" "}</Pre>
+  <ArticlePagination prev={prev} next={next} />
   <ArticleFooter page={note.data.page ?? 1} />
 </ArticleLayout>

diff --git a/ooknet-design/src/pages/notes/[
slug].astro b/ooknet-design/src/pages/notes/
[slug].astro
index 429df24..343b055 100644
--- a/ooknet-design/src/pages/notes/[slug].a
stro
+++ b/ooknet-design/src/pages/notes/[slug].a
stro
@@ -6,16 +6,31 @@ import ArticleHeader from 
"../../components/ArticleHeader/ArticleHeade
r.astro";
 import DocumentTitle from "../../components
/DocumentTitle/DocumentTitle.astro";
 import ArticleMeta from "../../components/A
rticleMeta/ArticleMeta.astro";
 import ArticleSeeAlso from "../../component
s/ArticleSeeAlso/ArticleSeeAlso.astro";
+import ArticleTags from "../../components/A
rticleTags/ArticleTags.astro";
+import ArticlePagination from "../../compon
ents/ArticlePagination/ArticlePagination.ast
ro";
 import ArticleFooter from "../../components
/ArticleFooter/ArticleFooter.astro";
 import Prose from "../../components/Prose/P
rose.astro";
+import Pre from "../../components/Pre/Pre.a
stro";
+import type { PageLink } from "../../compon
ents/ArticlePagination/ArticlePagination";
 
 export async function getStaticPaths() {
   const notes = await getCollection("notes"
, ({ data }) => !data.draft);
-  return notes.map((n) => ({ params: { slug
: n.id }, props: { note: n } }));
+  const num = (s: string) => parseInt(s.rep
lace(/\D/g, ""), 10) || 0;
+  notes.sort((a, b) => num(a.data.note) - n
um(b.data.note));
+  const link = (n?: (typeof notes)[number])
: PageLink | null =>
+    n ? { id: n.id, note: n.data.note } : n
ull;
+  return notes.map((n, i) => ({
+    params: { slug: n.id },
+    props: { note: n, prev: link(notes[i - 
1]), next: link(notes[i + 1]) },
+  }));
 }
 
-interface Props { note: CollectionEntry<"no
tes">; }
-const { note } = Astro.props as Props;
+interface Props {
+  note: CollectionEntry<"notes">;
+  prev: PageLink | null;
+  next: PageLink | null;
+}
+const { note, prev, next } = Astro.props as
 Props;
 const { Content } = await render(note);
 ---
 <ArticleLayout title={note.data.title}>
@@ -25,5 +40,8 @@ const { Content } = await 
render(note);
   <ArticleMeta fm={note.data} path={`notes/
${note.id}`} />
   <Prose><Content /></Prose>
   <ArticleSeeAlso fm={note.data} />
+  <ArticleTags fm={note.data} />
+  <Pre>{" "}</Pre>
+  <ArticlePagination prev={prev} next={next
} />
   <ArticleFooter page={note.data.page ?? 1}
 />
 </ArticleLayout>
 

diff --git a/ooknet-design/src/styles/global.scss b/ooknet-design/src/styles/globa
l.scss
index 2b1babc..f7d4677 100644
--- a/ooknet-design/src/styles/global.scss
+++ b/ooknet-design/src/styles/global.scss
@@ -84,6 +84,20 @@
   box-sizing: border-box;
 }
 
+// Kept in the DOM for screen readers (e.g. the semantic table behind an
+// ASCII-rendered one) but visually removed.
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+
 body {
   margin: 0;
   font-family: var(--mono);

diff --git a/ooknet-design/src/styles/global
.scss b/ooknet-design/src/styles/global.scss
index 2b1babc..f7d4677 100644
--- a/ooknet-design/src/styles/global.scss
+++ b/ooknet-design/src/styles/global.scss
@@ -84,6 +84,20 @@
   box-sizing: border-box;
 }
 
+// Kept in the DOM for screen readers (e.g.
 the semantic table behind an
+// ASCII-rendered one) but visually removed
.
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+
 body {
   margin: 0;
   font-family: var(--mono);
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET